Files
heicode-win/heicode/controller/heicode_agnet_session.go
T
chenchenandClaude Opus 4.7 f70d80ca4c Revert "fix(agnet): preserve root admin's group during Agnet session sync"
This reverts commit 992a965. After re-reading the upstream Heicode
design docs (heicode.md, heicode-runtime-auth-newapi-secret-design.md,
plan.md), it is clear that:

  1. users.group = channelId is the correct upstream behaviour. Agnet's
     /me is the source of truth for which NewAPI channel a user belongs
     to. Forking that logic in NewAPI to special-case role>=root breaks
     the documented "Manager owns identity, NewAPI is just the model
     gateway" boundary.

  2. The empty-abilities symptom isn't a NewAPI fork bug. It's that
     chenchen was created by raw SQL INSERT into NewAPI's users table —
     a path that doesn't exist in the design. Real users get their
     channelId from Manager (mcp-server) at login, and ability rows for
     that channelId are provisioned out-of-band by platform operations
     when the channel goes live.

  3. Patching NewAPI to silently keep an admin's hand-edited group hides
     the real provisioning gap and pollutes the upstream sync logic for
     every future user.

Restoring upstream behaviour. Out-of-band fixes (whether to
provision abilities, route mcp-server logins, etc.) belong elsewhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:15:17 +08:00

353 lines
9.7 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
}
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: 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
}
if ch := strings.TrimSpace(me.Data.ChannelID); ch != "" && user.Group != ch {
user.Group = ch
changed = true
}
// 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
}
}
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,
})
}