feat: add Yuheng ticket bind, trial SMS off, shared bindings

Ship ticket-exchange and bind/policy for Z13, keep trial binds SMS-free, allow shared company bindings, and align SyncPage plus sync docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-08-05 15:09:49 +08:00
parent f90245db9c
commit d195aa4804
19 changed files with 957 additions and 96 deletions

View File

@@ -30,6 +30,8 @@ var Catalog = []Entry{
{Method: "POST", Path: "/api/v1/auth/bind-code/redeem", OperationID: "redeemBindCode", Summary: "绑定码兑换host_key+code", Public: true, Group: "auth"},
{Method: "POST", Path: "/api/v1/auth/bind/phone-lookup", OperationID: "bindPhoneLookup", Summary: "同号探测(不绑定)", Public: true, Group: "auth"},
{Method: "POST", Path: "/api/v1/auth/bind/phone-confirm", OperationID: "bindPhoneConfirm", Summary: "同号确认后绑定", Public: true, Group: "auth"},
{Method: "GET", Path: "/api/v1/auth/bind/policy", OperationID: "bindPolicy", Summary: "绑定策略(试运行/短信/凭票开关)", Public: true, Group: "auth"},
{Method: "POST", Path: "/api/v1/auth/yuheng/ticket-exchange", OperationID: "yuhengTicketExchange", Summary: "宇恒凭票免登录换票(仅宇恒)", Public: true, Group: "auth"},
{Method: "POST", Path: "/api/v1/auth/invites/accept", OperationID: "acceptInvite", Summary: "接受邀请加入租户", Group: "auth"},
{Method: "POST", Path: "/api/v1/tenants", OperationID: "createTenant", Summary: "pending 用户创建自己的公司", Group: "auth"},

View File

@@ -54,11 +54,25 @@ type SMSConf struct {
CodeTTLSeconds int `json:",default=300"`
ResendSeconds int `json:",default=60"`
DevFixedCode string `json:",optional"` // 开发可固定如 123456
// RequireForBind手机号确认绑定时是否强制短信。
// 试运行/未接短信平台false关闭验证正式接入后改为 true。
RequireForBind bool `json:",optional"`
}
type AgentConf struct {
CapsuleSecret string `json:",optional"`
RegisterSecret string `json:",optional"` // 宿主自注册防刷;空则回退 IssueSecret/AccessSecret
// YuhengTicket仅宇恒平台签名凭票免登录HMAC其它客户端勿用。
YuhengTicket YuhengTicketConf `json:",optional"`
}
// YuhengTicketConf 宇恒 → 智建 短时凭票scope=sync_bind
type YuhengTicketConf struct {
Enabled bool `json:",optional"`
Secret string `json:",optional"` // 与宇恒共享;生产必换
Issuer string `json:",default=yuheng"`
Audience string `json:",default=aijianzhan"`
MaxAgeSec int `json:",default=120"` // 签发端建议 TTL验票另限 exp 不超过 now+10m
}
type StorageConf struct {

View File

@@ -19,6 +19,8 @@ type Binding struct {
LocalDatabaseID string `json:"local_database_id"`
OnlineDBID string `json:"online_db_id"`
ChannelID string `json:"channel_id,omitempty"`
// Shared公司共享库Z12c-2同租户任意成员可 push/pull 该 online_db_id
Shared bool `json:"shared,omitempty"`
// 可读名(与宇恒 database_name / display_name 对齐;控制台优先展示)
DatabaseName string `json:"database_name,omitempty"` // 本地库可读名
DisplayName string `json:"display_name,omitempty"` // 线上库可读名
@@ -70,6 +72,7 @@ func (s *FileStore) EnsureBinding(b Binding) (Binding, error) {
if b.UserID > 0 {
list[i].UserID = b.UserID
}
list[i].Shared = b.Shared
list[i].UpdatedAt = now
if err := s.writeBindingsUnlocked(list); err != nil {
return b, err
@@ -117,18 +120,33 @@ func (s *FileStore) ListBindingsFiltered(tenantID, userID int64, localDatabaseID
return out, nil
}
// UserOwnsOnlineDB 用户是否登记了该 online_db_id可选限定 channel
// UserOwnsOnlineDB 用户是否登记了该 online_db_id可选限定 channel;或该库为公司 Shared
func (s *FileStore) UserOwnsOnlineDB(tenantID, userID int64, channelID, onlineDBID string) bool {
if tenantID <= 0 || userID <= 0 || strings.TrimSpace(onlineDBID) == "" {
return false
}
list, err := s.ListBindingsFiltered(tenantID, userID, "")
online := strings.TrimSpace(onlineDBID)
ch := strings.TrimSpace(channelID)
own, err := s.ListBindingsFiltered(tenantID, userID, "")
if err == nil {
for _, b := range own {
if strings.TrimSpace(b.OnlineDBID) != online {
continue
}
if ch != "" && b.ChannelID != "" && b.ChannelID != ch {
continue
}
return true
}
}
all, err := s.ListBindingsFiltered(tenantID, 0, "")
if err != nil {
return false
}
online := strings.TrimSpace(onlineDBID)
ch := strings.TrimSpace(channelID)
for _, b := range list {
for _, b := range all {
if !b.Shared {
continue
}
if strings.TrimSpace(b.OnlineDBID) != online {
continue
}
@@ -140,17 +158,28 @@ func (s *FileStore) UserOwnsOnlineDB(tenantID, userID int64, channelID, onlineDB
return false
}
// UserCanAccessChannel 用户是否登记了指向该通道的 Bindingchannel_id 空视为未限定通道)。
// UserCanAccessChannel 用户是否登记了指向该通道的 Bindingchannel_id 空视为未限定通道);含 Shared
func (s *FileStore) UserCanAccessChannel(tenantID, userID int64, channelID string) bool {
if tenantID <= 0 || userID <= 0 || strings.TrimSpace(channelID) == "" {
return false
}
ch := strings.TrimSpace(channelID)
list, err := s.ListBindingsFiltered(tenantID, userID, "")
if err != nil || len(list) == 0 {
if err == nil {
for _, b := range list {
if b.ChannelID == "" || b.ChannelID == ch {
return true
}
}
}
all, err := s.ListBindingsFiltered(tenantID, 0, "")
if err != nil {
return false
}
ch := strings.TrimSpace(channelID)
for _, b := range list {
for _, b := range all {
if !b.Shared {
continue
}
if b.ChannelID == "" || b.ChannelID == ch {
return true
}

View File

@@ -216,7 +216,7 @@ func authorizeUserOnlineDB(svcCtx *svc.ServiceContext, w http.ResponseWriter, r
uid := authx.UserID(r.Context())
tid := syncTenantID(r)
if !svcCtx.DBSync.Store().UserOwnsOnlineDB(tid, uid, channelID, online) {
authx.WriteError(w, http.StatusForbidden, "无权访问该 online_db_id非本人 Binding")
authx.WriteError(w, http.StatusForbidden, "无权访问该 online_db_id非本人 Binding / 非公司共享库")
return false
}
return true

View File

@@ -44,6 +44,8 @@ func RegisterHandlers(server *rest.Server, svcCtx *svc.ServiceContext) {
{Method: http.MethodPost, Path: "/api/v1/auth/bind-code/redeem", Handler: rl(bindCodeRedeemHandler(svcCtx))},
{Method: http.MethodPost, Path: "/api/v1/auth/bind/phone-lookup", Handler: rl(bindPhoneLookupHandler(svcCtx))},
{Method: http.MethodPost, Path: "/api/v1/auth/bind/phone-confirm", Handler: rl(bindPhoneConfirmHandler(svcCtx))},
{Method: http.MethodGet, Path: "/api/v1/auth/bind/policy", Handler: rl(bindPolicyHandler(svcCtx))},
{Method: http.MethodPost, Path: "/api/v1/auth/yuheng/ticket-exchange", Handler: rl(yuhengTicketExchangeHandler(svcCtx))},
{Method: http.MethodPost, Path: "/api/v1/auth/register", Handler: rl(registerHandler(svcCtx))},
{Method: http.MethodPost, Path: "/api/v1/auth/login", Handler: rl(loginHandler(svcCtx))},
{Method: http.MethodPost, Path: "/api/v1/auth/sms/send", Handler: rl(sendLoginSMSHandler(svcCtx))},
@@ -388,6 +390,12 @@ func bindPhoneLookupHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
}
}
func bindPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
httpx.OkJson(w, applogic.NewAuthLogic(r.Context(), svcCtx).BindPolicy())
}
}
func bindPhoneConfirmHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req applogic.PhoneConfirmReq
@@ -404,6 +412,22 @@ func bindPhoneConfirmHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
}
}
func yuhengTicketExchangeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req applogic.YuhengTicketExchangeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
resp, err := applogic.NewAuthLogic(r.Context(), svcCtx).ExchangeYuhengTicket(req)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, resp)
}
}
func roleListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
items, err := applogic.NewRoleAdminLogic(r.Context(), svcCtx).List()

View File

@@ -26,6 +26,21 @@ func syncBindingsListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
list, err = svcCtx.DBSync.Store().ListBindings(tid, localID)
} else {
list, err = svcCtx.DBSync.Store().ListBindingsFiltered(tid, authx.UserID(r.Context()), localID)
if err == nil {
// 附带公司共享库 Binding便于客户端展示
all, e2 := svcCtx.DBSync.Store().ListBindings(tid, localID)
if e2 == nil {
seen := map[string]bool{}
for _, b := range list {
seen[b.ID] = true
}
for _, b := range all {
if b.Shared && !seen[b.ID] {
list = append(list, b)
}
}
}
}
}
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
@@ -52,8 +67,12 @@ func syncBindingsEnsureHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
body.UserID = uid
}
} else {
// 用户自助:强制绑定到本人,禁止冒用他人 user_id
// 用户自助:强制绑定到本人,禁止冒用他人 user_id;禁止自助标共享库
body.UserID = uid
if body.Shared {
authx.WriteError(w, http.StatusForbidden, "仅公司管理员可将 Binding 标为公司共享库shared")
return
}
}
saved, err := svcCtx.DBSync.Store().EnsureBinding(body)
if err != nil {

View File

@@ -10,6 +10,7 @@ import (
"aijianzhan/platform/internal/authx"
"aijianzhan/platform/internal/bindcodestore"
"aijianzhan/platform/internal/dbsync"
"aijianzhan/platform/internal/smsstore"
"aijianzhan/platform/internal/userstore"
)
@@ -192,6 +193,50 @@ type PhoneLookupResp struct {
MaskedName string `json:"masked_name,omitempty"`
NeedConfirm bool `json:"need_confirm"`
Message string `json:"message,omitempty"`
// Z13c-2试运行 RequireForBind=false 时为 false正式开启后为 true宇恒同号可 attested 免验)
SMSRequiredUnlessAttested bool `json:"sms_required_unless_attested"`
}
func (l *AuthLogic) bindSMSRequired() bool {
if l == nil || l.svcCtx == nil {
return false
}
if strings.EqualFold(strings.TrimSpace(l.svcCtx.Config.SMS.Provider), "off") {
return false
}
return l.svcCtx.Config.SMS.RequireForBind
}
// BindPolicy 公开策略,供宇恒决定是否弹短信 / 走凭票。
type BindPolicyResp struct {
RequireForBind bool `json:"require_for_bind"`
SMSProvider string `json:"sms_provider"`
YuhengTicketEnabled bool `json:"yuheng_ticket_enabled"`
TrialMode bool `json:"trial_mode"` // !require_for_bind
Message string `json:"message,omitempty"`
}
func (l *AuthLogic) BindPolicy() BindPolicyResp {
req := l.bindSMSRequired()
ticketOn := l.svcCtx != nil && l.svcCtx.Config.Agent.YuhengTicket.Enabled
prov := ""
if l.svcCtx != nil {
prov = strings.TrimSpace(l.svcCtx.Config.SMS.Provider)
}
msg := "试运行:绑定可不校验短信,仍须用户确认"
if req {
msg = "正式:异号须 sms_code同号请用宇恒凭票 ticket-exchange或 attested若未启凭票"
if ticketOn {
msg = "正式:异号须 sms_code同号请用 POST /api/v1/auth/yuheng/ticket-exchange已禁 attested_same_phone"
}
}
return BindPolicyResp{
RequireForBind: req,
SMSProvider: prov,
YuhengTicketEnabled: ticketOn,
TrialMode: !req,
Message: msg,
}
}
func maskDisplayName(name string) string {
@@ -210,31 +255,41 @@ func maskDisplayName(name string) string {
}
func (l *AuthLogic) PhoneLookup(req PhoneLookupReq) (*PhoneLookupResp, error) {
smsReq := l.bindSMSRequired()
if l.svcCtx.Users == nil {
return nil, fmt.Errorf("user store unavailable")
}
phone, err := userstore.NormalizePhone(req.Phone)
if err != nil {
return &PhoneLookupResp{Exists: false, Message: "手机号格式不正确"}, nil
return &PhoneLookupResp{Exists: false, Message: "手机号格式不正确", SMSRequiredUnlessAttested: smsReq}, nil
}
u, err := l.svcCtx.Users.GetByPhone(l.ctx, phone)
if err != nil || u == nil {
return &PhoneLookupResp{Exists: false, NeedConfirm: false, Message: "无此成员;请使用绑定码或联系管理员"}, nil
return &PhoneLookupResp{Exists: false, NeedConfirm: false, Message: "无此成员;请使用绑定码或联系管理员", SMSRequiredUnlessAttested: smsReq}, nil
}
if u.TenantID <= 0 {
return &PhoneLookupResp{Exists: true, NeedConfirm: false, Message: "该手机号账号尚未加入公司"}, nil
return &PhoneLookupResp{Exists: true, NeedConfirm: false, Message: "该手机号账号尚未加入公司", SMSRequiredUnlessAttested: smsReq}, nil
}
tenantName := ""
if t, err := l.svcCtx.Users.GetTenant(l.ctx, u.TenantID); err == nil && t != nil {
tenantName = t.Name
}
msg := fmt.Sprintf("已找到账号「%s」所属「%s」是否绑定到本机", maskDisplayName(u.DisplayName), tenantName)
if !smsReq {
msg += "(试运行:短信验证已关闭,确认即可)"
} else if l.svcCtx.Config.Agent.YuhengTicket.Enabled {
msg += "(正式:同号请用宇恒凭票 ticket-exchange异号须 sms_code"
} else {
msg += "(正式:宇恒同号可 attested_same_phone=true异号须 sms_code"
}
return &PhoneLookupResp{
Exists: true,
TenantID: u.TenantID,
TenantName: tenantName,
MaskedName: maskDisplayName(u.DisplayName),
NeedConfirm: true,
Message: fmt.Sprintf("已找到账号「%s」所属「%s」是否绑定到本机", maskDisplayName(u.DisplayName), tenantName),
Exists: true,
TenantID: u.TenantID,
TenantName: tenantName,
MaskedName: maskDisplayName(u.DisplayName),
NeedConfirm: true,
SMSRequiredUnlessAttested: smsReq,
Message: msg,
}, nil
}
@@ -244,6 +299,9 @@ type PhoneConfirmReq struct {
Name string `json:"name"`
Confirm bool `json:"confirm"` // 必须 true
LocalDBID string `json:"local_database_id"`
// Z13c-2输入号=宇恒已绑手机时,宇恒置 true 可免短信;异号必须带 sms_code
AttestedSamePhone bool `json:"attested_same_phone"`
SMSCode string `json:"sms_code"`
}
type PhoneConfirmResp struct {
@@ -257,6 +315,35 @@ type PhoneConfirmResp struct {
Message string `json:"message,omitempty"`
}
func (l *AuthLogic) requirePhoneBindProof(phone string, attested bool, smsCode string) error {
// 正式模式且启用宇恒凭票:禁止明文 attested须走 ticket-exchange
formal := l.bindSMSRequired()
if attested && formal && l.svcCtx.Config.Agent.YuhengTicket.Enabled {
return fmt.Errorf("已启用宇恒凭票:请使用 POST /api/v1/auth/yuheng/ticket-exchange勿再传 attested_same_phone")
}
if attested {
return nil
}
// 试运行RequireForBind=false 或 Provider=off跳过短信
if !formal {
return nil
}
code := strings.TrimSpace(smsCode)
if code == "" {
return fmt.Errorf("须提供 sms_code或使用宇恒凭票 ticket-exchange同号")
}
if l.svcCtx.SMS == nil {
return fmt.Errorf("短信服务未启用,无法校验验证码")
}
if err := l.svcCtx.SMS.Consume(smsstore.PurposeBind, phone, code); err == nil {
return nil
}
if err := l.svcCtx.SMS.Consume(smsstore.PurposeLogin, phone, code); err == nil {
return nil
}
return fmt.Errorf("短信验证码无效或已过期")
}
func (l *AuthLogic) PhoneConfirm(req PhoneConfirmReq) (*PhoneConfirmResp, error) {
if !req.Confirm {
return nil, fmt.Errorf("须明确确认绑定confirm=true")
@@ -268,6 +355,9 @@ func (l *AuthLogic) PhoneConfirm(req PhoneConfirmReq) (*PhoneConfirmResp, error)
if err != nil {
return nil, fmt.Errorf("手机号格式不正确")
}
if err := l.requirePhoneBindProof(phone, req.AttestedSamePhone, req.SMSCode); err != nil {
return nil, err
}
hostKey := strings.TrimSpace(req.HostKey)
if hostKey == "" {
return nil, fmt.Errorf("host_key required")
@@ -279,58 +369,13 @@ func (l *AuthLogic) PhoneConfirm(req PhoneConfirmReq) (*PhoneConfirmResp, error)
if u.TenantID <= 0 {
return nil, fmt.Errorf("该账号尚未加入公司")
}
cfg := l.svcCtx.Config.DBSync
driver := dbsync.Driver(strings.TrimSpace(cfg.DefaultRemoteDriver))
if driver == "" {
driver = dbsync.DriverPostgres
}
ch, err := l.svcCtx.DBSync.Store().EnsureSystemDefaultChannel(dbsync.DefaultChannelOpts{
TenantID: u.TenantID,
RemoteDriver: driver,
RemoteDSN: strings.TrimSpace(cfg.DefaultRemoteDSN),
})
if err != nil {
return nil, fmt.Errorf("ensure channel: %w", err)
}
acc, err := l.svcCtx.Agents.FindByHostKey(l.ctx, hostKey)
if err != nil {
name := strings.TrimSpace(req.Name)
if name == "" {
name = "离线终端 · " + maskDisplayName(u.DisplayName)
}
created, _, _, regErr := l.svcCtx.Agents.Register(l.ctx, u.TenantID, name, hostKey)
if regErr != nil {
return nil, regErr
}
acc = created
}
online := dbsync.ResolveOnlineDBID("", ch.ID)
// 个人落点:按用户隔离 online_db_id
online = fmt.Sprintf("%s_u%d", online, u.UserID)
dbName := fmt.Sprintf("%s", strings.TrimSpace(u.DisplayName))
if dbName == "" {
dbName = fmt.Sprintf("user_%d", u.UserID)
}
updated, err := l.svcCtx.Agents.AttachSyncBind(l.ctx, acc.AgentID, u.TenantID, ch.ID, online, dbName, true)
updated, _, err := l.bindUserHostSync(u, hostKey, req.Name, req.LocalDBID, "phone-confirm")
if err != nil {
return nil, err
}
localID := strings.TrimSpace(req.LocalDBID)
if localID == "" {
localID = "host:" + hostKey
}
_, _ = l.svcCtx.DBSync.Store().EnsureBinding(dbsync.Binding{
TenantID: u.TenantID,
UserID: u.UserID,
LocalDatabaseID: localID,
OnlineDBID: online,
ChannelID: ch.ID,
DatabaseName: dbName,
DisplayName: dbName,
Note: "phone-confirm",
})
_ = l.ensureAgentSyncPerm(updated)
_ = l.writeBindAudit("phone_confirm_bind", u.TenantID, updated.AgentID, map[string]any{
"phone": phone, "user_id": u.UserID, "channel_id": ch.ID, "online_db_id": online,
"phone": phone, "user_id": u.UserID, "channel_id": updated.ChannelID, "online_db_id": updated.OnlineDBID,
})
return &PhoneConfirmResp{
OK: true,

View File

@@ -0,0 +1,210 @@
package applogic
import (
"fmt"
"strings"
"aijianzhan/platform/internal/agentcap"
"aijianzhan/platform/internal/agentstore"
"aijianzhan/platform/internal/authx"
"aijianzhan/platform/internal/dbsync"
"aijianzhan/platform/internal/types"
"aijianzhan/platform/internal/userstore"
"aijianzhan/platform/internal/yuhticket"
)
type YuhengTicketExchangeReq struct {
Ticket string `json:"ticket"`
HostKey string `json:"host_key"` // 须与票内一致
LocalDatabaseID string `json:"local_database_id,omitempty"`
Name string `json:"name,omitempty"`
}
type YuhengTicketExchangeResp struct {
*types.TokenResp
OK bool `json:"ok"`
ClientID string `json:"client_id,omitempty"`
ClientSecret string `json:"client_secret,omitempty"` // 仅新注册/轮换时返回一次
Message string `json:"message,omitempty"`
}
// ExchangeYuhengTicket 宇恒专属:验签凭票 → 绑定落点 → 签发智能体 JWT免智建账号密码登录
func (l *AuthLogic) ExchangeYuhengTicket(req YuhengTicketExchangeReq) (*YuhengTicketExchangeResp, error) {
cfg := l.svcCtx.Config.Agent.YuhengTicket
if !cfg.Enabled {
return nil, fmt.Errorf("宇恒凭票未启用Agent.YuhengTicket.Enabled")
}
if strings.TrimSpace(cfg.Secret) == "" {
return nil, fmt.Errorf("宇恒凭票 Secret 未配置")
}
if l.svcCtx.Users == nil || l.svcCtx.Agents == nil || l.svcCtx.DBSync == 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,
})
if err != nil {
return nil, err
}
hostKey := strings.TrimSpace(req.HostKey)
if hostKey == "" {
hostKey = strings.TrimSpace(claims.HostKey)
}
if hostKey == "" || hostKey != strings.TrimSpace(claims.HostKey) {
return nil, fmt.Errorf("host_key 与凭票不一致")
}
if l.svcCtx.YuhengJTI != nil {
if err := l.svcCtx.YuhengJTI.Consume(claims.JTI, claims.Exp); err != nil {
return nil, err
}
}
phone, err := userstore.NormalizePhone(claims.Phone)
if err != nil {
return nil, fmt.Errorf("凭票手机号无效")
}
u, err := l.svcCtx.Users.GetByPhone(l.ctx, phone)
if err != nil || u == nil {
return nil, fmt.Errorf("无此成员;请先在智建绑定手机或使用绑定码")
}
if u.TenantID <= 0 {
return nil, fmt.Errorf("该账号尚未加入公司")
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = strings.TrimSpace(claims.Name)
}
localID := strings.TrimSpace(req.LocalDatabaseID)
if localID == "" {
localID = strings.TrimSpace(claims.LocalDatabaseID)
}
acc, secret, err := l.bindUserHostSync(u, hostKey, name, localID, "yuheng-ticket")
if err != nil {
return nil, err
}
if err := l.ensureAgentSyncPerm(acc); err != nil {
return nil, err
}
// 重新加载 perms
acc, err = l.svcCtx.Agents.Get(l.ctx, acc.TenantID, acc.AgentID)
if err != nil {
return nil, 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)
_ = l.writeBindAudit("yuheng_ticket_exchange", acc.TenantID, acc.AgentID, map[string]any{
"phone": phone, "jti": claims.JTI, "yuheng_user_id": claims.YuhengUserID,
})
return &YuhengTicketExchangeResp{
TokenResp: tr,
OK: true,
ClientID: acc.ClientID,
ClientSecret: secret,
Message: "凭票换票成功(仅宇恒)",
}, nil
}
func (l *AuthLogic) ensureAgentSyncPerm(acc *agentstore.Account) error {
if acc == nil {
return nil
}
has := false
for _, p := range acc.Perms {
if p == authx.Perm数据同步 {
has = true
break
}
}
if has && acc.Status == agentstore.StatusActive {
return nil
}
perms := append([]string{}, acc.Perms...)
if !has {
perms = append(perms, authx.Perm数据同步)
}
st := agentstore.StatusActive
_, err := l.svcCtx.Agents.Update(l.ctx, acc.TenantID, acc.AgentID, agentstore.UpdateInput{
Status: &st,
Perms: &perms,
})
return err
}
// bindUserHostSync 将 host_key 智能体挂到用户公司默认同步落点并写 Binding。
func (l *AuthLogic) bindUserHostSync(u *userstore.User, hostKey, name, localID, note string) (*agentstore.Account, string, error) {
cfg := l.svcCtx.Config.DBSync
driver := dbsync.Driver(strings.TrimSpace(cfg.DefaultRemoteDriver))
if driver == "" {
driver = dbsync.DriverPostgres
}
ch, err := l.svcCtx.DBSync.Store().EnsureSystemDefaultChannel(dbsync.DefaultChannelOpts{
TenantID: u.TenantID,
RemoteDriver: driver,
RemoteDSN: strings.TrimSpace(cfg.DefaultRemoteDSN),
})
if err != nil {
return nil, "", fmt.Errorf("ensure channel: %w", err)
}
var secret string
acc, err := l.svcCtx.Agents.FindByHostKey(l.ctx, hostKey)
if err != nil {
if name == "" {
name = "离线终端 · " + maskDisplayName(u.DisplayName)
}
created, sec, _, regErr := l.svcCtx.Agents.Register(l.ctx, u.TenantID, name, hostKey)
if regErr != nil {
return nil, "", regErr
}
acc, secret = created, sec
} else if acc.TenantID != u.TenantID && acc.Status == agentstore.StatusPending {
// 允许 pending 迁公司;已激活异租户拒绝
} else if acc.TenantID != u.TenantID {
return nil, "", fmt.Errorf("host_key 已绑定其它公司")
}
online := fmt.Sprintf("%s_u%d", dbsync.ResolveOnlineDBID("", ch.ID), u.UserID)
dbName := strings.TrimSpace(u.DisplayName)
if dbName == "" {
dbName = fmt.Sprintf("user_%d", u.UserID)
}
updated, err := l.svcCtx.Agents.AttachSyncBind(l.ctx, acc.AgentID, u.TenantID, ch.ID, online, dbName, true)
if err != nil {
return nil, "", err
}
if localID == "" {
localID = "host:" + hostKey
}
_, _ = l.svcCtx.DBSync.Store().EnsureBinding(dbsync.Binding{
TenantID: u.TenantID,
UserID: u.UserID,
LocalDatabaseID: localID,
OnlineDBID: online,
ChannelID: ch.ID,
DatabaseName: dbName,
DisplayName: dbName,
Note: note,
})
return updated, secret, nil
}

View File

@@ -26,6 +26,7 @@ import (
"aijianzhan/platform/internal/storage"
"aijianzhan/platform/internal/tenantperm"
"aijianzhan/platform/internal/userstore"
"aijianzhan/platform/internal/yuhticket"
_ "github.com/lib/pq"
)
@@ -49,6 +50,7 @@ type ServiceContext struct {
JWT authx.JWTConfig
DBSync *dbsync.Manager
BindCodes bindcodestore.Store
YuhengJTI *yuhticket.JTIStore
TenantPerm tenantperm.Store
SMS *smsstore.Store
License *license.Manager
@@ -189,6 +191,11 @@ func NewServiceContext(c config.Config) *ServiceContext {
} else {
ctx.BindCodes = bc
}
if jti, err := yuhticket.NewJTIStore(dir); err != nil {
log.Printf("yuheng ticket jti: %v", err)
} else {
ctx.YuhengJTI = jti
}
}
}

View File

@@ -0,0 +1,187 @@
package yuhticket
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const Prefix = "YHT1"
// Claims 宇恒签发的短时凭票(仅用于智建 sync 绑定/免登录换票)。
type Claims struct {
Iss string `json:"iss"`
Aud string `json:"aud"`
Phone string `json:"phone"`
HostKey string `json:"host_key"`
Name string `json:"name,omitempty"`
LocalDatabaseID string `json:"local_database_id,omitempty"`
Exp int64 `json:"exp"`
JTI string `json:"jti"`
Scope string `json:"scope,omitempty"` // sync_bind
YuhengUserID string `json:"yuheng_user_id,omitempty"`
}
type VerifyOpts struct {
Secret string
Issuer string // default yuheng
Audience string // default aijianzhan
Now time.Time
MaxSkew time.Duration // clock skew; default 30s
}
// Sign 供联调/测试;生产由宇恒侧用同一 Secret 签发。
func Sign(secret string, c Claims) (string, error) {
secret = strings.TrimSpace(secret)
if secret == "" {
return "", fmt.Errorf("ticket secret empty")
}
if c.JTI == "" || c.Phone == "" || c.HostKey == "" || c.Exp == 0 {
return "", fmt.Errorf("jti/phone/host_key/exp required")
}
if c.Iss == "" {
c.Iss = "yuheng"
}
if c.Aud == "" {
c.Aud = "aijianzhan"
}
if c.Scope == "" {
c.Scope = "sync_bind"
}
raw, err := json.Marshal(c)
if err != nil {
return "", err
}
payload := base64.RawURLEncoding.EncodeToString(raw)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(Prefix + "." + payload))
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return Prefix + "." + payload + "." + sig, nil
}
func Verify(ticket string, opts VerifyOpts) (*Claims, error) {
ticket = strings.TrimSpace(ticket)
secret := strings.TrimSpace(opts.Secret)
if secret == "" {
return nil, fmt.Errorf("宇恒凭票未配置Agent.YuhengTicket.Secret")
}
parts := strings.Split(ticket, ".")
if len(parts) != 3 || parts[0] != Prefix {
return nil, fmt.Errorf("invalid ticket format")
}
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(parts[0] + "." + parts[1]))
want := mac.Sum(nil)
got, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || !hmac.Equal(want, got) {
return nil, fmt.Errorf("ticket signature invalid")
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("ticket payload invalid")
}
var c Claims
if err := json.Unmarshal(raw, &c); err != nil {
return nil, fmt.Errorf("ticket claims invalid")
}
iss := strings.TrimSpace(opts.Issuer)
if iss == "" {
iss = "yuheng"
}
aud := strings.TrimSpace(opts.Audience)
if aud == "" {
aud = "aijianzhan"
}
if c.Iss != iss {
return nil, fmt.Errorf("ticket issuer not allowed")
}
if c.Aud != aud {
return nil, fmt.Errorf("ticket audience mismatch")
}
if c.Scope != "" && c.Scope != "sync_bind" {
return nil, fmt.Errorf("ticket scope not allowed")
}
now := opts.Now
if now.IsZero() {
now = time.Now().UTC()
}
skew := opts.MaxSkew
if skew <= 0 {
skew = 30 * time.Second
}
exp := time.Unix(c.Exp, 0).UTC()
if now.After(exp.Add(skew)) {
return nil, fmt.Errorf("ticket expired")
}
// 拒绝过远未来的 exp防永久票
if exp.After(now.Add(10 * time.Minute)) {
return nil, fmt.Errorf("ticket exp too far")
}
if strings.TrimSpace(c.Phone) == "" || strings.TrimSpace(c.HostKey) == "" || strings.TrimSpace(c.JTI) == "" {
return nil, fmt.Errorf("ticket missing phone/host_key/jti")
}
return &c, nil
}
// JTIStore 防重放(短 TTL
type JTIStore struct {
mu sync.Mutex
path string
seen map[string]int64 // jti -> exp unix
}
func NewJTIStore(dir string) (*JTIStore, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
s := &JTIStore{path: filepath.Join(dir, "yuheng_ticket_jti.json"), seen: map[string]int64{}}
_ = s.load()
return s, nil
}
func (s *JTIStore) load() error {
b, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
return json.Unmarshal(b, &s.seen)
}
func (s *JTIStore) save() error {
b, err := json.Marshal(s.seen)
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// Consume 若 jti 已用则报错;否则记入至 exp。
func (s *JTIStore) Consume(jti string, expUnix int64) error {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().Unix()
for k, exp := range s.seen {
if exp < now {
delete(s.seen, k)
}
}
if _, ok := s.seen[jti]; ok {
return fmt.Errorf("ticket already used")
}
s.seen[jti] = expUnix
return s.save()
}

View File

@@ -0,0 +1,51 @@
package yuhticket
import (
"testing"
"time"
)
func TestSignVerifyRoundTrip(t *testing.T) {
secret := "test-secret"
now := time.Unix(1_700_000_000, 0).UTC()
c := Claims{
Iss: "yuheng", Aud: "aijianzhan", Phone: "13531041944",
HostKey: "host-1", Exp: now.Add(90 * time.Second).Unix(), JTI: "jti-ok", Scope: "sync_bind",
}
tok, err := Sign(secret, c)
if err != nil {
t.Fatal(err)
}
got, err := Verify(tok, VerifyOpts{Secret: secret, Now: now})
if err != nil {
t.Fatal(err)
}
if got.Phone != c.Phone || got.HostKey != c.HostKey {
t.Fatalf("claims mismatch: %+v", got)
}
}
func TestVerifyBadSig(t *testing.T) {
now := time.Unix(1_700_000_000, 0).UTC()
tok, _ := Sign("a", Claims{
Phone: "13800000001", HostKey: "h", Exp: now.Add(60 * time.Second).Unix(), JTI: "j1",
})
if _, err := Verify(tok, VerifyOpts{Secret: "b", Now: now}); err == nil {
t.Fatal("expected bad sig")
}
}
func TestJTIConsume(t *testing.T) {
dir := t.TempDir()
st, err := NewJTIStore(dir)
if err != nil {
t.Fatal(err)
}
exp := time.Now().Add(time.Minute).Unix()
if err := st.Consume("x", exp); err != nil {
t.Fatal(err)
}
if err := st.Consume("x", exp); err == nil {
t.Fatal("expected replay")
}
}