Add HMAC restore-by-host for phone-less rebind; resolve gateway at request time and recreate web after stack up. Co-authored-by: Cursor <cursoragent@cursor.com>
249 lines
7.6 KiB
Go
249 lines
7.6 KiB
Go
package applogic
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"regexp"
|
||
"strings"
|
||
|
||
"aijianzhan/platform/internal/agentcap"
|
||
"aijianzhan/platform/internal/agentstore"
|
||
"aijianzhan/platform/internal/authx"
|
||
"aijianzhan/platform/internal/types"
|
||
"aijianzhan/platform/internal/yuhticket"
|
||
)
|
||
|
||
// YuhengRestoreByHostReq Z34b:仅凭宇恒账号 host_key 恢复已有同步绑定。
|
||
type YuhengRestoreByHostReq struct {
|
||
Ticket string `json:"ticket"`
|
||
HostKey string `json:"host_key"` // 须与票内一致(= 宇恒用户 _id)
|
||
}
|
||
|
||
// YuhengRestoreByHostResp 与绑定成功对齐,便于宇恒 apply_bind_success。
|
||
type YuhengRestoreByHostResp struct {
|
||
*types.TokenResp
|
||
OK bool `json:"ok"`
|
||
SyncBound bool `json:"sync_bound"`
|
||
ClientID string `json:"client_id,omitempty"`
|
||
ClientSecret string `json:"client_secret,omitempty"`
|
||
ChannelID string `json:"channel_id,omitempty"`
|
||
OnlineDBID string `json:"online_db_id,omitempty"`
|
||
TenantID int64 `json:"tenant_id,omitempty"`
|
||
AgentID int64 `json:"agent_id,omitempty"`
|
||
HostKey string `json:"host_key,omitempty"`
|
||
PhoneMasked string `json:"phone_masked,omitempty"`
|
||
Message string `json:"message,omitempty"`
|
||
}
|
||
|
||
// RestoreBindError 映射 HTTP 状态(404 need_bind / 401 / 403)。
|
||
type RestoreBindError struct {
|
||
HTTPStatus int
|
||
Message string
|
||
NeedBind bool
|
||
}
|
||
|
||
func (e *RestoreBindError) Error() string {
|
||
if e == nil {
|
||
return ""
|
||
}
|
||
return e.Message
|
||
}
|
||
|
||
func restoreNeedBind(msg string) error {
|
||
return &RestoreBindError{HTTPStatus: http.StatusNotFound, Message: msg, NeedBind: true}
|
||
}
|
||
|
||
func restoreUnauthorized(msg string) error {
|
||
return &RestoreBindError{HTTPStatus: http.StatusUnauthorized, Message: msg}
|
||
}
|
||
|
||
func restoreForbidden(msg string) error {
|
||
return &RestoreBindError{HTTPStatus: http.StatusForbidden, Message: msg}
|
||
}
|
||
|
||
var onlineUserSuffix = regexp.MustCompile(`_u(\d+)$`)
|
||
|
||
// RestoreByYuhengHost Z34b:验签凭票 → FindByHostKey → 已 sync_bound 则轮换密钥并回传落点。
|
||
func (l *AuthLogic) RestoreByYuhengHost(req YuhengRestoreByHostReq) (*YuhengRestoreByHostResp, error) {
|
||
cfg := l.svcCtx.Config.Agent.YuhengTicket
|
||
if !cfg.Enabled {
|
||
return nil, restoreUnauthorized("宇恒凭票未启用(Agent.YuhengTicket.Enabled)")
|
||
}
|
||
if strings.TrimSpace(cfg.Secret) == "" {
|
||
return nil, restoreUnauthorized("宇恒凭票 Secret 未配置")
|
||
}
|
||
if l.svcCtx.Agents == nil {
|
||
return nil, fmt.Errorf("bind service unavailable")
|
||
}
|
||
claims, err := yuhticket.Verify(req.Ticket, yuhticket.VerifyOpts{
|
||
Secret: cfg.Secret,
|
||
Issuer: cfg.Issuer,
|
||
Audience: cfg.Audience,
|
||
AllowEmptyPhone: true,
|
||
AllowedScopes: []string{"", "sync_bind", "sync_restore"},
|
||
})
|
||
if err != nil {
|
||
msg := err.Error()
|
||
if strings.Contains(msg, "expired") || strings.Contains(msg, "signature") ||
|
||
strings.Contains(msg, "already used") || strings.Contains(msg, "invalid") ||
|
||
strings.Contains(msg, "not allowed") || strings.Contains(msg, "mismatch") ||
|
||
strings.Contains(msg, "未配置") {
|
||
return nil, restoreUnauthorized(msg)
|
||
}
|
||
return nil, restoreUnauthorized(msg)
|
||
}
|
||
hostKey := strings.TrimSpace(req.HostKey)
|
||
if hostKey == "" {
|
||
hostKey = strings.TrimSpace(claims.HostKey)
|
||
}
|
||
if hostKey == "" || hostKey != strings.TrimSpace(claims.HostKey) {
|
||
return nil, restoreForbidden("host_key 与凭票不一致")
|
||
}
|
||
if yid := strings.TrimSpace(claims.YuhengUserID); yid != "" && yid != hostKey {
|
||
return nil, restoreForbidden("yuheng_user_id 与 host_key 不一致")
|
||
}
|
||
if l.svcCtx.YuhengJTI != nil {
|
||
if err := l.svcCtx.YuhengJTI.Consume(claims.JTI, claims.Exp); err != nil {
|
||
return nil, restoreUnauthorized(err.Error())
|
||
}
|
||
}
|
||
|
||
acc, err := l.svcCtx.Agents.FindByHostKey(l.ctx, hostKey)
|
||
if err != nil || acc == nil {
|
||
return nil, restoreNeedBind("无此 host_key 同步绑定;请先绑定码/手机号绑定")
|
||
}
|
||
// 通道误删时先自愈再判断 sync_bound
|
||
if _, hErr := HealTenantSyncBind(l.ctx, l.svcCtx, acc.TenantID); hErr == nil {
|
||
if refreshed, gErr := l.svcCtx.Agents.Get(l.ctx, acc.TenantID, acc.AgentID); gErr == nil && refreshed != nil {
|
||
acc = refreshed
|
||
}
|
||
}
|
||
syncBound := strings.TrimSpace(acc.ChannelID) != "" && strings.TrimSpace(acc.OnlineDBID) != ""
|
||
if acc.Status != agentstore.StatusActive || !syncBound {
|
||
return nil, restoreNeedBind("host_key 尚未完成同步绑定;请先绑定码/手机号绑定")
|
||
}
|
||
|
||
if err := l.ensureAgentSyncPerm(acc); err != nil {
|
||
return nil, err
|
||
}
|
||
acc, err = l.svcCtx.Agents.Get(l.ctx, acc.TenantID, acc.AgentID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
secret, err := l.svcCtx.Agents.RotateSecret(l.ctx, acc.TenantID, acc.AgentID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("rotate client_secret: %w", err)
|
||
}
|
||
|
||
token, exp, err := authx.IssueAgentToken(l.svcCtx.JWT, acc.TenantID, acc.AgentID, acc.Perms)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
_ = l.svcCtx.Agents.TouchToken(l.ctx, acc.AgentID)
|
||
capSecret := l.svcCtx.Config.Agent.CapsuleSecret
|
||
if capSecret == "" {
|
||
capSecret = l.svcCtx.JWT.AccessSecret
|
||
}
|
||
tr := &types.TokenResp{
|
||
AccessToken: token,
|
||
TokenType: "Bearer",
|
||
ExpiresAt: exp,
|
||
TenantID: acc.TenantID,
|
||
UserID: acc.AgentID,
|
||
Username: acc.ClientID,
|
||
DisplayName: acc.Name,
|
||
Role: authx.RoleAgent,
|
||
AgentKey: agentcap.PublicAgentKey(capSecret, acc.TenantID, acc.AgentID),
|
||
AgentID: acc.AgentID,
|
||
Permissions: append([]string{}, acc.Perms...),
|
||
AppSlugs: append([]string{}, acc.AppSlugs...),
|
||
}
|
||
fillAgentSyncOnToken(tr, acc)
|
||
|
||
phoneMasked := maskPhoneDigits(strings.TrimSpace(claims.Phone))
|
||
if phoneMasked == "" {
|
||
phoneMasked = l.phoneMaskedForAgent(acc)
|
||
}
|
||
|
||
_ = l.writeBindAudit("yuheng_restore_by_host", acc.TenantID, acc.AgentID, map[string]any{
|
||
"host_key": hostKey, "jti": claims.JTI, "yuheng_user_id": claims.YuhengUserID,
|
||
})
|
||
|
||
return &YuhengRestoreByHostResp{
|
||
TokenResp: tr,
|
||
OK: true,
|
||
SyncBound: true,
|
||
ClientID: acc.ClientID,
|
||
ClientSecret: secret,
|
||
ChannelID: acc.ChannelID,
|
||
OnlineDBID: acc.OnlineDBID,
|
||
TenantID: acc.TenantID,
|
||
AgentID: acc.AgentID,
|
||
HostKey: hostKey,
|
||
PhoneMasked: phoneMasked,
|
||
Message: "已按宇恒账号恢复同步绑定",
|
||
}, nil
|
||
}
|
||
|
||
func maskPhoneDigits(phone string) string {
|
||
phone = strings.TrimSpace(phone)
|
||
if len(phone) < 7 {
|
||
return ""
|
||
}
|
||
// 11 位国内号:135****1944
|
||
if len(phone) == 11 {
|
||
return phone[:3] + "****" + phone[7:]
|
||
}
|
||
keep := 3
|
||
if len(phone) < keep*2 {
|
||
return phone[:1] + "****"
|
||
}
|
||
return phone[:keep] + "****" + phone[len(phone)-keep:]
|
||
}
|
||
|
||
func (l *AuthLogic) phoneMaskedForAgent(acc *agentstore.Account) string {
|
||
if acc == nil || l.svcCtx.Users == nil {
|
||
return ""
|
||
}
|
||
// 优先 online_db_id 后缀 _u{userID}
|
||
if m := onlineUserSuffix.FindStringSubmatch(strings.TrimSpace(acc.OnlineDBID)); len(m) == 2 {
|
||
var uid int64
|
||
_, _ = fmt.Sscanf(m[1], "%d", &uid)
|
||
if uid > 0 {
|
||
if u, err := l.svcCtx.Users.GetByID(l.ctx, uid); err == nil && u != nil {
|
||
return maskPhoneDigits(u.Phone)
|
||
}
|
||
}
|
||
}
|
||
// 回落:同租户 Binding 上挂该 online_db_id 的用户
|
||
if l.svcCtx.DBSync == nil {
|
||
return ""
|
||
}
|
||
list, err := l.svcCtx.DBSync.Store().ListBindingsFiltered(acc.TenantID, 0, "")
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
online := strings.TrimSpace(acc.OnlineDBID)
|
||
for _, b := range list {
|
||
if strings.TrimSpace(b.OnlineDBID) != online || b.UserID <= 0 {
|
||
continue
|
||
}
|
||
if u, err := l.svcCtx.Users.GetByID(l.ctx, b.UserID); err == nil && u != nil {
|
||
if m := maskPhoneDigits(u.Phone); m != "" {
|
||
return m
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// AsRestoreBindError 供 handler 取 HTTP 状态。
|
||
func AsRestoreBindError(err error) *RestoreBindError {
|
||
var e *RestoreBindError
|
||
if errors.As(err, &e) {
|
||
return e
|
||
}
|
||
return nil
|
||
}
|