feat(auth): Manager session from Agnet tokens with JIT local user
Replace password-based /api/user/login bridge after external auth with POST /api/user/session/from-agnet: verify access (and optional refresh) against Agnet /api/auth/me, upsert local user by email, then issue the Manager session cookie. Frontend sends bearer tokens only. Includes HEICODE_AUTH_BASE_URL in compose defaults and .env.example. Made-with: Cursor
This commit is contained in:
@@ -68,6 +68,9 @@
|
||||
# 会话密钥
|
||||
# SESSION_SECRET=random_string
|
||||
|
||||
# Agnet / Heicode 身份平台 Base URL(与前端 VITE_HEICODE_AUTH_BASE_URL 一致,勿尾缀斜杠)
|
||||
# HEICODE_AUTH_BASE_URL=https://apimtaiji.azure-api.net/api/mcp
|
||||
|
||||
# 其他配置
|
||||
# 生成默认token
|
||||
# GENERATE_DEFAULT_TOKEN=false
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/i18n"
|
||||
"github.com/heicode/manager/model"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HeicodeAgnetSessionRequest accepts tokens obtained only from Agnet identity platform.
|
||||
// Manager verifies them server-side and issues the browser session cookie (same as password login).
|
||||
type HeicodeAgnetSessionRequest struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
type agnetMeEnvelope struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Detail string `json:"detail"`
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
ChannelID string `json:"channelId"`
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type agnetRefreshEnvelope struct {
|
||||
Success bool `json:"success"`
|
||||
Detail string `json:"detail"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func defaultHeicodeAuthBaseURL() string {
|
||||
if v := strings.TrimSpace(os.Getenv("HEICODE_AUTH_BASE_URL")); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
return "https://apimtaiji.azure-api.net/api/mcp"
|
||||
}
|
||||
|
||||
func jitUsernameFromEmail(email string) string {
|
||||
e := strings.TrimSpace(email)
|
||||
if len(e) <= common.UserNameMaxLength {
|
||||
return e
|
||||
}
|
||||
sum := sha256.Sum256([]byte(e))
|
||||
return "ag_" + hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
|
||||
func roleFromAgnet(role string) int {
|
||||
switch strings.ToLower(strings.TrimSpace(role)) {
|
||||
case "root":
|
||||
return common.RoleRootUser
|
||||
case "admin":
|
||||
return common.RoleAdminUser
|
||||
default:
|
||||
return common.RoleCommonUser
|
||||
}
|
||||
}
|
||||
|
||||
func statusFromAgnet(status string) int {
|
||||
if strings.EqualFold(strings.TrimSpace(status), "active") {
|
||||
return common.UserStatusEnabled
|
||||
}
|
||||
return common.UserStatusDisabled
|
||||
}
|
||||
|
||||
func agnetHTTPClient() *http.Client {
|
||||
return &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
|
||||
func fetchAgnetMe(baseURL, accessToken string) (agnetMeEnvelope, int, error) {
|
||||
var out agnetMeEnvelope
|
||||
req, err := http.NewRequest(http.MethodGet, baseURL+"/api/auth/me", nil)
|
||||
if err != nil {
|
||||
return out, 0, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("X-Request-Id", common.GetUUID())
|
||||
res, err := agnetHTTPClient().Do(req)
|
||||
if err != nil {
|
||||
return out, 0, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||
if err != nil {
|
||||
return out, res.StatusCode, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, res.StatusCode, fmt.Errorf("invalid response from Agnet /me: %w", err)
|
||||
}
|
||||
return out, res.StatusCode, nil
|
||||
}
|
||||
|
||||
func fetchAgnetRefresh(baseURL, refreshToken string) (access string, refresh string, err error) {
|
||||
req, err := http.NewRequest(http.MethodPost, baseURL+"/api/auth/refresh", nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+refreshToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Request-Id", common.GetUUID())
|
||||
res, err := agnetHTTPClient().Do(req)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var env agnetRefreshEnvelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return "", "", fmt.Errorf("invalid response from Agnet /refresh: %w", err)
|
||||
}
|
||||
if !env.Success || env.Data.Token == "" {
|
||||
msg := env.Message
|
||||
if msg == "" {
|
||||
msg = env.Detail
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "refresh failed"
|
||||
}
|
||||
return "", "", errors.New(msg)
|
||||
}
|
||||
return env.Data.Token, env.Data.RefreshToken, nil
|
||||
}
|
||||
|
||||
func syncLocalUserFromAgnet(me agnetMeEnvelope) (*model.User, error) {
|
||||
email := strings.TrimSpace(me.Data.Email)
|
||||
if email == "" {
|
||||
return nil, errors.New("Agnet account has no email")
|
||||
}
|
||||
|
||||
var user model.User
|
||||
err := model.DB.Where("email = ?", email).First(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
display := strings.TrimSpace(me.Data.Name)
|
||||
if display == "" {
|
||||
display = strings.Split(email, "@")[0]
|
||||
}
|
||||
group := strings.TrimSpace(me.Data.ChannelID)
|
||||
if group == "" {
|
||||
group = "default"
|
||||
}
|
||||
nu := model.User{
|
||||
Username: jitUsernameFromEmail(email),
|
||||
Password: common.GetRandomString(32),
|
||||
DisplayName: display,
|
||||
Email: email,
|
||||
Role: roleFromAgnet(me.Data.Role),
|
||||
Status: statusFromAgnet(me.Data.Status),
|
||||
Group: group,
|
||||
}
|
||||
if nu.Status != common.UserStatusEnabled {
|
||||
return nil, errors.New("Agnet account is not active")
|
||||
}
|
||||
if err := nu.Insert(0); err != nil {
|
||||
// Possible race: duplicate email/username — reload.
|
||||
if err2 := model.DB.Where("email = ?", email).First(&user).Error; err2 != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := model.DB.Where("email = ?", email).First(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user.Status != common.UserStatusEnabled {
|
||||
return nil, errors.New("local account is disabled")
|
||||
}
|
||||
|
||||
changed := false
|
||||
if name := strings.TrimSpace(me.Data.Name); name != "" && user.DisplayName != name {
|
||||
user.DisplayName = name
|
||||
changed = true
|
||||
}
|
||||
if ch := strings.TrimSpace(me.Data.ChannelID); ch != "" && user.Group != ch {
|
||||
user.Group = ch
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
if err := user.Update(false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// HeicodeAgnetSessionLogin establishes Manager session after Agnet identity verified via token(s).
|
||||
func HeicodeAgnetSessionLogin(c *gin.Context) {
|
||||
var req HeicodeAgnetSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"})
|
||||
return
|
||||
}
|
||||
access := strings.TrimSpace(req.AccessToken)
|
||||
refresh := strings.TrimSpace(req.RefreshToken)
|
||||
if access == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "access_token required"})
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := defaultHeicodeAuthBaseURL()
|
||||
me, status, err := fetchAgnetMe(baseURL, access)
|
||||
newAccess := ""
|
||||
newRefresh := ""
|
||||
|
||||
if (err != nil || status == http.StatusUnauthorized || !me.Success) && refresh != "" {
|
||||
na, nr, refErr := fetchAgnetRefresh(baseURL, refresh)
|
||||
if refErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": refErr.Error()})
|
||||
return
|
||||
}
|
||||
newAccess = na
|
||||
newRefresh = nr
|
||||
me, _, err = fetchAgnetMe(baseURL, newAccess)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if !me.Success {
|
||||
msg := me.Message
|
||||
if msg == "" {
|
||||
msg = me.Detail
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "unable to verify identity with Agnet"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := syncLocalUserFromAgnet(me)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if model.IsTwoFAEnabled(user.Id) {
|
||||
session := sessions.Default(c)
|
||||
session.Set("pending_username", user.Username)
|
||||
session.Set("pending_user_id", user.Id)
|
||||
if err := session.Save(); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": i18n.T(c, i18n.MsgUserRequire2FA),
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"require_2fa": true,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
model.UpdateUserLastLoginAt(user.Id)
|
||||
session := sessions.Default(c)
|
||||
session.Set("id", user.Id)
|
||||
session.Set("username", user.Username)
|
||||
session.Set("role", user.Role)
|
||||
session.Set("status", user.Status)
|
||||
session.Set("group", user.Group)
|
||||
if err := session.Save(); err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed)
|
||||
return
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"id": user.Id,
|
||||
"username": user.Username,
|
||||
"display_name": user.DisplayName,
|
||||
"role": user.Role,
|
||||
"status": user.Status,
|
||||
"group": user.Group,
|
||||
}
|
||||
if newAccess != "" {
|
||||
data["heicode_token"] = newAccess
|
||||
if newRefresh != "" {
|
||||
data["heicode_refresh_token"] = newRefresh
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "",
|
||||
"success": true,
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
@@ -42,6 +42,7 @@ services:
|
||||
- ERROR_LOG_ENABLED=true
|
||||
- BATCH_UPDATE_ENABLED=true
|
||||
- NODE_NAME=heicode-node-1
|
||||
- HEICODE_AUTH_BASE_URL=${HEICODE_AUTH_BASE_URL:-https://apimtaiji.azure-api.net/api/mcp}
|
||||
networks:
|
||||
- heicode-network
|
||||
healthcheck:
|
||||
|
||||
@@ -57,6 +57,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
userRoute := apiRouter.Group("/user")
|
||||
{
|
||||
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
|
||||
userRoute.POST("/session/from-agnet", middleware.CriticalRateLimit(), controller.HeicodeAgnetSessionLogin)
|
||||
userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login)
|
||||
userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), controller.Verify2FALogin)
|
||||
userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), controller.PasskeyLoginBegin)
|
||||
|
||||
+26
-12
@@ -49,20 +49,23 @@ export function clearHeicodeTokens() {
|
||||
resetHeicodeAuthenticatedSession()
|
||||
}
|
||||
|
||||
/** 在外部 Heicode 登录成功后,向本站点 Manager 写入会话 Cookie(否则 /api/* 会 401 →「会话已过期」) */
|
||||
async function establishManagerCookieSession(payload: LoginPayload) {
|
||||
const params =
|
||||
payload.turnstile && payload.turnstile.length > 0
|
||||
? { turnstile: payload.turnstile }
|
||||
: undefined
|
||||
/**
|
||||
* 外部 Agnet 登录成功后,用 token 向本站校验身份并写入 Manager 会话 Cookie。
|
||||
* 不在本站再做密码校验;本地用户按需 JIT 创建。
|
||||
*/
|
||||
async function establishManagerSessionFromAgnet() {
|
||||
const access_token = readToken(ACCESS_TOKEN_KEY)
|
||||
const refresh_token = readToken(REFRESH_TOKEN_KEY)
|
||||
if (!access_token) {
|
||||
throw new Error('Missing Heicode access token')
|
||||
}
|
||||
const res = await api.post(
|
||||
'/api/user/login',
|
||||
'/api/user/session/from-agnet',
|
||||
{
|
||||
username: payload.username,
|
||||
password: payload.password,
|
||||
access_token,
|
||||
refresh_token: refresh_token || undefined,
|
||||
},
|
||||
{
|
||||
params,
|
||||
skipBusinessError: true,
|
||||
skipErrorHandler: true,
|
||||
} as Record<string, unknown>
|
||||
@@ -70,7 +73,12 @@ async function establishManagerCookieSession(payload: LoginPayload) {
|
||||
const body = res.data as {
|
||||
success?: boolean
|
||||
message?: string
|
||||
data?: { require_2fa?: boolean; id?: number }
|
||||
data?: {
|
||||
require_2fa?: boolean
|
||||
id?: number
|
||||
heicode_token?: string
|
||||
heicode_refresh_token?: string
|
||||
}
|
||||
}
|
||||
if (!body?.success) {
|
||||
throw new Error(body?.message || 'Unable to establish Manager session')
|
||||
@@ -78,6 +86,12 @@ async function establishManagerCookieSession(payload: LoginPayload) {
|
||||
if (body.data?.require_2fa) {
|
||||
throw new TwoFactorRequiredError()
|
||||
}
|
||||
if (body.data?.heicode_token) {
|
||||
writeTokens(
|
||||
body.data.heicode_token,
|
||||
body.data.heicode_refresh_token || undefined
|
||||
)
|
||||
}
|
||||
if (body.data?.id != null) {
|
||||
saveUserId(body.data.id)
|
||||
}
|
||||
@@ -153,7 +167,7 @@ export async function login(payload: LoginPayload) {
|
||||
if (res?.success) {
|
||||
writeTokens(res.data?.token, res.data?.refreshToken)
|
||||
try {
|
||||
await establishManagerCookieSession(payload)
|
||||
await establishManagerSessionFromAgnet()
|
||||
} catch (syncErr) {
|
||||
if (isTwoFactorRequiredError(syncErr)) {
|
||||
throw syncErr
|
||||
|
||||
Reference in New Issue
Block a user