1. /api/heicode-auth/* proxy: CriticalRateLimit (20/20min) → GlobalAPIRateLimit (180/180s). The Heicode external-identity proxy is hit on every page render for /me + /refresh plus the login burst — CriticalRateLimit is sized for sensitive ops (password reset, 2FA) and trips at ~5 quick page loads, returning 429 to a normal user. APIM upstream rate-limits itself, so a second tight layer here adds no security and just manufactures 429s. 2. JIT-create user group: seed "default" instead of me.Data.ChannelID. Companion to578a68fwhich only patched the every-login overwrite path. New users (yj2824269760@gmail.com et al, JIT-created after578a68f) still landed in a UUID group → empty /v1/models response → desktop client showed the static 3-Claude fallback list. Both fixes were applied on the production VM directly today (sed + python patch) — committing them so the next docker rebuild on VM keeps them instead of reverting to the buggy file via git checkout. DB hot-fix already applied: 6 affected users moved to group=default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
422 lines
12 KiB
Go
422 lines
12 KiB
Go
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, "/")
|
|
}
|
|
// 与 docs/integration/Heicode-登录接口对接文档.md §2.1 生产 Base URL 一致(前端 VITE 默认同源)。
|
|
return "https://apimtaiji.azure-api.net/api/mcp"
|
|
}
|
|
|
|
func jitUsernameFromEmail(email string) string {
|
|
e := strings.TrimSpace(email)
|
|
if len(e) <= model.UserNameMaxLength {
|
|
return e
|
|
}
|
|
sum := sha256.Sum256([]byte(e))
|
|
return "ag_" + hex.EncodeToString(sum[:])[:16]
|
|
}
|
|
|
|
// parseEmailList returns a set of normalized lowercase emails from an env value.
|
|
func parseEmailList(raw string) map[string]struct{} {
|
|
out := map[string]struct{}{}
|
|
for _, e := range strings.Split(raw, ",") {
|
|
e = strings.ToLower(strings.TrimSpace(e))
|
|
if e != "" {
|
|
out[e] = struct{}{}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// roleFromAgnetWithEmail decides the local role for a JIT-synced Agnet user.
|
|
//
|
|
// 安全策略:管理员权限只能通过本地配置(环境变量白名单)显式授予,
|
|
// **不信任** Agnet 平台返回的 role 字段。这样防止外部身份平台
|
|
// 的角色被直接映射到 Manager 的高权限角色。
|
|
//
|
|
// - 邮箱命中 HEICODE_ROOT_EMAILS -> RoleRootUser
|
|
// - 邮箱命中 HEICODE_ADMIN_EMAILS -> RoleAdminUser
|
|
// - 其他任何情况 -> RoleCommonUser(默认普通用户)
|
|
//
|
|
// 第二参数 `role` 当前未使用,保留是为了未来扩展(例如在策略中允许
|
|
// 信任部分上游 role),不破坏调用点签名。
|
|
func roleFromAgnetWithEmail(_ string, email string) int {
|
|
emailKey := strings.ToLower(strings.TrimSpace(email))
|
|
rootEmails := parseEmailList(os.Getenv("HEICODE_ROOT_EMAILS"))
|
|
if _, ok := rootEmails[emailKey]; ok {
|
|
return common.RoleRootUser
|
|
}
|
|
adminEmails := parseEmailList(os.Getenv("HEICODE_ADMIN_EMAILS"))
|
|
if _, ok := adminEmails[emailKey]; ok {
|
|
return common.RoleAdminUser
|
|
}
|
|
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
|
|
}
|
|
|
|
// markBillingProviderNewapi notifies mcp-server that this user's billing
|
|
// provider should be set to "newapi" (per Heicode 决策 7.7.1 ②).
|
|
//
|
|
// Fire-and-forget: 失败不影响登录主流程;mcp-server 那边也允许重复调用
|
|
// (内部端点对相同 email 幂等)。详见 Heicode-对接进度与待办.md §7.8.1。
|
|
//
|
|
// Token 通过 K8s secret / docker compose env `MCP_SERVER_INTERNAL_TOKEN`
|
|
// 注入;未配置时跳过(开发环境兼容)。
|
|
func markBillingProviderNewapi(email string) {
|
|
tok := strings.TrimSpace(os.Getenv("MCP_SERVER_INTERNAL_TOKEN"))
|
|
if tok == "" {
|
|
return
|
|
}
|
|
email = strings.TrimSpace(email)
|
|
if email == "" {
|
|
return
|
|
}
|
|
go func(email, tok string) {
|
|
body, err := common.Marshal(map[string]string{
|
|
"email": email,
|
|
"billing_provider": "newapi",
|
|
})
|
|
if err != nil {
|
|
common.SysLog("markBillingProviderNewapi: marshal failed: " + err.Error())
|
|
return
|
|
}
|
|
baseURL := defaultHeicodeAuthBaseURL()
|
|
req, err := http.NewRequest(
|
|
http.MethodPut,
|
|
baseURL+"/api/auth/internal/billing-provider",
|
|
strings.NewReader(string(body)),
|
|
)
|
|
if err != nil {
|
|
common.SysLog("markBillingProviderNewapi: req build failed: " + err.Error())
|
|
return
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Request-Id", common.GetUUID())
|
|
res, err := agnetHTTPClient().Do(req)
|
|
if err != nil {
|
|
common.SysLog("markBillingProviderNewapi: send failed: " + err.Error())
|
|
return
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode >= 400 {
|
|
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<14))
|
|
common.SysLog(fmt.Sprintf(
|
|
"markBillingProviderNewapi: %d %s — %s",
|
|
res.StatusCode, email, strings.TrimSpace(string(b)),
|
|
))
|
|
}
|
|
}(email, tok)
|
|
}
|
|
|
|
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]
|
|
}
|
|
// Local model-access bucket — never seed with the Agnet channelId
|
|
// (no abilities row matches a random UUID, so the new user would land
|
|
// with zero models on first /v1/models call). Admins control group
|
|
// from the NewAPI dashboard after JIT-create. Fix companion to
|
|
// 578a68f which only patched the every-login overwrite path.
|
|
group := "default"
|
|
_ = me.Data.ChannelID
|
|
nu := model.User{
|
|
Username: jitUsernameFromEmail(email),
|
|
Password: common.GetRandomString(32),
|
|
DisplayName: display,
|
|
Email: email,
|
|
Role: roleFromAgnetWithEmail(me.Data.Role, email),
|
|
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
|
|
}
|
|
// Don't overwrite the existing user's group with the Agnet channelId on
|
|
// every login: NewAPI's `users.group` is the **local model-access bucket**
|
|
// (must match a row in the `abilities` / `channels` group column to expose
|
|
// any models). The Agnet channelId is a cross-platform identity that
|
|
// rarely matches a NewAPI-side group, so overwriting strands the user
|
|
// with zero models. mcp-server side already tracks channelId separately
|
|
// (see markBillingProviderNewapi), so we don't need it duplicated here.
|
|
//
|
|
// Only seed the group on the JIT-create path above (when the user record
|
|
// is new and has no admin-set group yet). After that, NewAPI admins own
|
|
// the group via the dashboard.
|
|
_ = me.Data.ChannelID
|
|
// Promote role from Agnet / email whitelist on every login (never demote).
|
|
desiredRole := roleFromAgnetWithEmail(me.Data.Role, email)
|
|
if desiredRole > user.Role {
|
|
user.Role = desiredRole
|
|
changed = true
|
|
}
|
|
if changed {
|
|
if err := user.Update(false); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Notify mcp-server billing_provider=newapi (fire-and-forget; goroutine).
|
|
markBillingProviderNewapi(user.Email)
|
|
|
|
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
|
|
}
|
|
|
|
model.EnsureUserRelayToken(user.Id, user.Username)
|
|
|
|
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,
|
|
})
|
|
}
|