// Package service 身份域业务逻辑(登录主链路编排:两步登录 / me / refresh / logout)。 // // 分层职责(架构规格 §3.2 三层): // - 本包(Service):编排 Repository(数据)+ token.Issuer(凭证)+ redis.LoginTicketStore(票据), // 只返回 **sentinel 业务错误**(不泄漏 gorm/redis 原错——那是 Repository/存储层的事); // - Repository(identity/repository):平台级数据访问(本域不继承 scope.BaseRepo); // - Handler(T4-4):把 Service 的 *AppError 映射为统一响应信封(response.Fail)。 // // 两步登录协议(T4 契约,2026-11-06 拍板): // // ① Login(ctx, identityType, identifier, password):验账密 + 惰性升级 → 返回 loginTicket + 用户基础信息 + // memberships[](机构/校区/角色,供前端"作用域选择页"展示)。**不签 token**。 // ② Issue(ctx, loginTicket, institutionId, campusId):消费一次性票据(Redis GETDEL 原子)→ 服务端校验 // (机构,校区) 确属该用户(防越权)→ 取该条 role → 签 access+refresh。 // 移动端"自动取第一条" = ①之后立即用 memberships[0] 调 ②,同一套协议,无独立端点。 // // 安全要点: // - **防枚举(anti-user-enumeration)**:"账号不存在 / 密码错 / 账号禁用 / 无密码哈希" 一律统一 // 文案"账号或密码错误"(code 1001),并对"账号不存在"路径补跑一次 argon2(timing floor), // 使响应时间不泄漏"该账号是否存在"。 // - **惰性升级(ADR-016)**:命中旧算法(md5_salt/sha_salt)即 argon2 重哈希写回 + 撤该用户 token; // 升级写回失败则**阻断登录**(凭证半升级态 = 安全漏洞,宁可让用户重试)。 // - **作用域校验**:② 必须服务端确认 (institutionId, campusId) 确属该用户的 membership, // 否则拒绝(客户端不能挑任意作用域签 token = 越权)。 // // 依赖方向:service → repository(接口) + token + infra/redis + credential + model(全部为下层/同域), // 不依赖 handler/main(纯业务逻辑,可纯单测:mock repo + 真 Issuer + miniredis 票据)。 package service import ( "context" "crypto/rand" "encoding/hex" stderrors "errors" "strings" "time" "yuansiwei/unified-backend/internal/identity/credential" "yuansiwei/unified-backend/internal/identity/model" "yuansiwei/unified-backend/internal/identity/repository" "yuansiwei/unified-backend/internal/identity/token" apperrors "yuansiwei/unified-backend/internal/infra/errors" appredis "yuansiwei/unified-backend/internal/infra/redis" orgmodel "yuansiwei/unified-backend/internal/org/model" "yuansiwei/unified-backend/internal/org/rbac" ) // errAuthFailed 登录主链路统一失败(防枚举:账号不存在/密码错/禁用/无哈希 全部用此)。 // code 1001(认证类),msg 脱敏不区分具体原因。 var errAuthFailed = apperrors.New(apperrors.CodeUnauth, "账号或密码错误") // defaultLoginTicketTTL 登录票据有效期(①→② 之间的窗口;5min 给前端留足操作时间, // 又不至于让泄露的 ticket 长期可换 token)。 const defaultLoginTicketTTL = 5 * time.Minute // AuthService 身份域业务服务(两步登录 / me / refresh / logout 编排)。 // // 字段(装配入口 NewAuthService 注入,均必填): // - users / identities / memberships:三仓储接口(生产注入 *repository.UserRepo 等具体实现, // 单测注入 mock——依赖倒置,纯单测不连 DB)。 // - issuer:token 签发器(RS256 私钥 + access/refresh TTL + refresh 白名单/吊销存储,T4-1/T4-6 装配)。 // - tickets:登录票据存储(Redis GETDEL 原子消费,T4-3 新增)。 // - accessTTL:access 有效期(算 expiresAt + logout 撤销的剩余 TTL)。 // - pubKeys / issuerStr / revocation:验签三件套(Refresh 轮换后回读新 access 的 claims 用, // 走 stateless 的 token.Verify;与验签中间件同源,来自 config + 同一吊销存储)。 type AuthService struct { users repository.UserRepository identities repository.IdentityRepository memberships repository.MembershipRepository issuer *token.Issuer tickets *appredis.LoginTicketStore accessTTL time.Duration ticketTTL time.Duration pubKeys []string issuerStr string revocation token.RevocationStore // menuProvider 角色→菜单树/权限码 解析器(T6.5-d;可空——未注入时 menuTree/permissions 恒空集, // 菜单全隐/按钮全隐,不阻断登录。生产装配注入 *rbac.Resolver)。 menuProvider MenuProvider // dummyHash 防枚举的 timing floor:① "账号不存在"路径补跑一次 argon2 校验(对 dummyHash), // 使该路径 CPU 成本与"存在 + argon2 用户"同量级,响应时间不泄漏账号存在性(稳态全 argon2 后对齐)。 dummyHash string } // Config AuthService 装配参数(T4-6 main 按此构造;全部必填,缺则 NewAuthService 报错)。 type Config struct { Users repository.UserRepository Identities repository.IdentityRepository Memberships repository.MembershipRepository Issuer *token.Issuer Tickets *appredis.LoginTicketStore AccessTTL time.Duration PublicKeys []string IssuerStr string Revocation token.RevocationStore // LoginTicketTTL 可选:登录票据有效期;0/缺省用 defaultLoginTicketTTL(5min)。 LoginTicketTTL time.Duration // MenuProvider 可选:角色→菜单树/权限码 解析器(T6.5-d,注入 *rbac.Resolver); // nil 时 menuTree/permissions 恒空集(菜单全隐,不阻断登录——RBAC 未装配时的兜底语义)。 MenuProvider MenuProvider } // NewAuthService 构造身份域业务服务(启动装配一次,全进程共享)。 // // 入参:cfg Config:见 Config 字段说明。 // 出参: // - *AuthService:就绪服务。 // - error:必填依赖缺失(nil 检查,fail-fast——缺仓储/签发器/票据存储时不允许启动); // AccessTTL ≤ 0 视为配置错误(access 不能无有效期,安全红线)。 func NewAuthService(cfg Config) (*AuthService, error) { if cfg.Users == nil || cfg.Identities == nil || cfg.Memberships == nil { return nil, apperrors.New(apperrors.CodeInternal, "service: 仓储接口未注入(users/identities/memberships 必填)") } if cfg.Issuer == nil { return nil, apperrors.New(apperrors.CodeInternal, "service: token.Issuer 未注入") } if cfg.Tickets == nil { return nil, apperrors.New(apperrors.CodeInternal, "service: 登录票据存储未注入(redis.LoginTicketStore 必填)") } if cfg.AccessTTL <= 0 { return nil, apperrors.New(apperrors.CodeInternal, "service: AccessTTL 必须 > 0(access 无有效期 = 安全红线)") } ttl := cfg.LoginTicketTTL if ttl <= 0 { ttl = defaultLoginTicketTTL } // 防枚举 timing floor:启动时生成一个 argon2 dummy(64MB/1 次,~百毫秒,仅启动开销)。 // 失败不阻断启动(dummy 仅优化计时,非安全必需)——但会告警级降级。 dummy, _ := credential.HashArgon2("timing-equalization-dummy") return &AuthService{ users: cfg.Users, identities: cfg.Identities, memberships: cfg.Memberships, issuer: cfg.Issuer, tickets: cfg.Tickets, accessTTL: cfg.AccessTTL, ticketTTL: ttl, pubKeys: cfg.PublicKeys, issuerStr: cfg.IssuerStr, revocation: cfg.Revocation, menuProvider: cfg.MenuProvider, dummyHash: dummy, }, nil } // resolveMenu 按角色解析菜单树 + 权限码(T6.5-d;MenuProvider 未注入时返回空集,不阻断登录)。 // // 入参: // - ctx 请求上下文; // - roleCode 选定作用域的角色码(token 选定 membership 的 role;通常单条)。 // // 出参: // - []MenuNode:可见菜单树(**非 nil**,解析失败/未注入时返回 []); // - []string:全量权限点 code 集(**非 nil**,解析失败/未注入时返回 [])。 // // 语义:菜单/权限是"展示层"(拍板),DB 异常不应阻断已签发的 token 下发—— // 返回空集 + 前端菜单全隐,用户仍可登录(数据层权限由路由白名单/Scoping 兜底)。 func (s *AuthService) resolveMenu(ctx context.Context, roleCode string) (menu []MenuNode, perms []string) { menu = []MenuNode{} perms = []string{} if s.menuProvider == nil { return menu, perms } if m, err := s.menuProvider.MenuTree(ctx, roleCode); err == nil && m != nil { menu = m } if p, err := s.menuProvider.Permissions(ctx, roleCode); err == nil && p != nil { perms = p } return menu, perms } // ---- 出参 DTO(Service 层领域对象;Handler 层映射为统一响应信封 R)---- // UserView 用户对外视图(不含密码三列——model 已 json:"-" 脱敏,这里再显式收口,防误带出)。 type UserView struct { ID string `json:"id"` // canonical_user_id(ULID) Username string `json:"username"` // 用户名(可空) RealName string `json:"realName"` // 真实姓名(可空) PersonType string `json:"personType"` // 业务身份:student/teacher/parent/staff Status string `json:"status"` // active/disabled/merged } // MembershipView 一条成员关系(登录第一步② 作用域选择列表项)。 type MembershipView struct { MembershipID int64 `json:"membershipId"` InstitutionID int64 `json:"institutionId"` CampusID int64 `json:"campusId"` Role string `json:"role"` // 字符串角色码(super_admin/academic/...) Status string `json:"status"` // active/inactive } // LoginStep1 两步登录第一步出参(验账密成功,未签 token)。 type LoginStep1 struct { LoginTicket string `json:"loginTicket"` // 一次性票据(② 消费;5min 有效,单次) User *UserView `json:"user"` // 登录用户基础信息 Memberships []MembershipView `json:"memberships"` // 可选作用域列表(空 = 该用户暂无机构归属) } // TokenPair 两步登录第二步出参(签发的凭证对 + 作用域 + 用户 + RBAC 菜单/权限码,T6.5-d 起)。 // // MenuTree / Permissions 由注入的 MenuProvider(rbac.Resolver)按选定角色解析: // - MenuTree:可见菜单树(PC 前端原样渲染,不二次过滤;契约见 rbac.MenuNode); // - Permissions:全量权限点 code 集(含 button,前端 v-perm 依据); // // 两者**非 nil**(无可见菜单/权限 = [],JSON 序列化为 [] 而非 null); // 未注入 MenuProvider 时恒为空集(菜单全隐、按钮全隐,不阻断登录——RBAC 子系统装配失败的兜底)。 type TokenPair struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` ExpiresAt int64 `json:"expiresAt"` // access 过期(Unix 毫秒) User *UserView `json:"user"` Roles []string `json:"roles"` // 选定作用域的角色码(通常单条) InstitutionID int64 `json:"institutionId"` // 选定作用域 CampusID int64 `json:"campusId"` MenuTree []MenuNode `json:"menuTree"` // 可见菜单树(按选定角色过滤后;PC 原样渲染) Permissions []string `json:"permissions"` // 全量权限点 code 集(含 button,v-perm 依据) } // MenuNode 菜单树节点,直接复用 rbac.MenuNode(PC 前端契约的权威定义在 org/rbac 包)。 // 不在此包重定义:*rbac.Resolver.MenuTree 返回 []rbac.MenuNode,复用同一类型使 // MenuProvider 接口与生产实现签名严格一致(否则需装配层做类型转换,徒增漂移风险)。 // 依赖方向说明:identity/service 已 import org/model(登录引用 membership),再 import // org/rbac(纯解析器,无 DB/handler 依赖)不破坏分层——rbac 是更下层的展示逻辑。 type MenuNode = rbac.MenuNode // MenuProvider 角色 → 权限点 code 集 / 菜单树 解析器(T6.5-d 引入的依赖倒置接口)。 // // 生产实现:*rbac.Resolver(org/rbac 包,TTL 30s 进程内缓存;超管全量、普通查 t_role_permission)。 // 接口定义在消费方 identity/service(消费方定义接口,供装配层注入 *rbac.Resolver / 单测注入 fake); // rbac.Resolver 的方法签名与本接口一致,故 *rbac.Resolver 隐式满足(编译期断言在 main.go)。 type MenuProvider interface { // Permissions 角色 code 集(超管返回全量;普通角色查矩阵;空集 = 该角色无授权)。 Permissions(ctx context.Context, roleCode string) ([]string, error) // MenuTree 角色可见菜单树(button 不进树;父缺失提升顶级;非 nil 切片)。 MenuTree(ctx context.Context, roleCode string) ([]rbac.MenuNode, error) } // MeResult 登录态自查出参(/auth/me,菜单刷新恢复用;T6.5-d 起含 MenuTree/Permissions)。 type MeResult struct { *UserView Roles []string `json:"roles"` InstitutionID int64 `json:"institutionId"` // 当前作用域(来自已验签 claims) CampusID int64 `json:"campusId"` Memberships []MembershipView `json:"memberships"` MenuTree []MenuNode `json:"menuTree"` // 可见菜单树(同 TokenPair;菜单刷新恢复) Permissions []string `json:"permissions"` // 全量权限点 code 集 } // ---- 两步登录 ---- // Login 两步登录**第一步**:验账密(惰性升级)→ 发一次性票据 + 用户信息 + 作用域列表。 // // 入参: // - ctx context.Context:请求上下文。 // - identityType string:登录身份类型(model.IdentityType* 之一:username/phone/email/wechat)。 // PC 当前走 "username"(账号);phone/email/wechat 后续开通。 // - identifier string:归一前标识(username 原值 / phone 11 位 / email 大小写混 / wechat open_id)。 // - password string:明文密码(仅内存调用栈,禁止日志/响应)。 // // 出参: // - *LoginStep1:验账密成功(含一次性 loginTicket + memberships)。 // - error(*AppError): // - errAuthFailed(1001):账号不存在 / 密码错 / 账号禁用 / 无哈希(**统一文案**,防枚举); // - ErrBadParam(2001):phone 非 11 位(入参格式非法); // - ErrDBError(5002):DB/Redis 异常(含票据写失败——阻断,不放行半套)。 // // 时序纪律(防枚举 timing floor): // // "账号不存在" 与 "无密码哈希" 路径都会补跑一次 argon2(对 dummyHash),使响应时间与 // "存在 + argon2 用户"同量级——稳态(全员惰性升级到 argon2)下不可区分账号是否存在。 func (s *AuthService) Login(ctx context.Context, identityType, identifier, password string) (*LoginStep1, error) { ident := s.normalize(identityType, identifier) if identityType == model.IdentityTypePhone && !isValidPhone(ident) { return nil, apperrors.ErrBadParam } it, err := s.identities.GetByTypeIdentifierAppID(ctx, identityType, ident, "") if err != nil { // 区分"不存在"(统一文案,防枚举)与"DB 故障"(5002) if stderrors.Is(err, apperrors.ErrNotFound) { s.burnTiming(ctx, password) // timing floor:补跑一次 argon2 return nil, errAuthFailed } return nil, err // *AppError(ErrDBError 等)直接上抛 } u, err := s.users.GetByCanonicalUserID(ctx, it.CanonicalUserID) if err != nil { if stderrors.Is(err, apperrors.ErrNotFound) { // 身份指向的用户不存在(数据不一致,理论不该发生)→ 统一文案 + timing floor s.burnTiming(ctx, password) return nil, errAuthFailed } return nil, err } // 账号状态:disabled/merged 一律统一文案(不泄漏"该账号存在但被禁用") if u.Status != model.UserStatusActive { s.burnTiming(ctx, password) return nil, errAuthFailed } // 密码三列缺失(无哈希):统一文案 + timing floor(不区分"无哈希"与"密码错") if u.PasswordHash == nil || *u.PasswordHash == "" { s.burnTiming(ctx, password) return nil, errAuthFailed } var salt, version string if u.PasswordSalt != nil { salt = *u.PasswordSalt } if u.HashVersion != nil { version = *u.HashVersion } res, err := credential.Check(ctx, password, *u.PasswordHash, salt, version) if err != nil { // 未知 hash_version / 数据污染:拒绝登录(非密码错误,但对外统一文案防枚举; // 根因进日志,运维凭 ErrUnknownVersion 排查) s.burnTiming(ctx, password) return nil, errAuthFailed } if !res.Match { return nil, errAuthFailed } // 惰性升级(ADR-016 后半段):命中旧算法 → argon2 重哈希写回;失败则阻断登录(半升级态不放行) if res.NeedsUpgrade { newHash, herr := credential.HashArgon2(password) if herr != nil { return nil, apperrors.WithCause(apperrors.ErrInternal, herr) } if uerr := s.users.UpdatePasswordLazyUpgrade(ctx, u.CanonicalUserID, newHash); uerr != nil { // 升级写回失败:阻断(凭证已改语义但未落库,放行 = 旧凭证仍可登录 = 安全漏洞) return nil, uerr // *AppError(ErrDBError 3001/5002) } // 注:首次登录场景无"该用户既有 token"需撤销(RevokeAll 用于"改密后踢旧端",非首登)。 } // 取作用域列表(登录第一步展示用;空 = 该用户暂无机构归属,前端提示"联系管理员分配机构") mergs, merr := s.memberships.ListByCanonicalUserID(ctx, u.CanonicalUserID) if merr != nil { return nil, merr // *AppError(ErrDBError) } // 发一次性登录票据(Redis;写失败阻断——无票据则无法完成②,不放行半套凭证) ticket, terr := s.newTicket() if terr != nil { return nil, apperrors.WithCause(apperrors.ErrInternal, terr) } if setErr := s.tickets.SetTicket(ctx, ticket, u.CanonicalUserID, s.ticketTTL); setErr != nil { return nil, setErr // *AppError(ErrDBError) } return &LoginStep1{ LoginTicket: ticket, User: toUserView(u), Memberships: toMembershipViews(mergs), }, nil } // Issue 两步登录**第二步**:消费一次性票据 → 校验作用域归属 → 取 role → 签 access+refresh。 // // 入参: // - ctx context.Context:请求上下文。 // - loginTicket string:① 返回的一次性票据(32 hex;已消费/过期/不存在 → 拒绝)。 // - institutionID int64:选定机构(scope 根;0=平台级超管)。 // - campusID int64:选定校区(0=机构级/不限)。 // // 出参: // - *TokenPair:签发成功的凭证对 + 选定作用域 + 用户。 // - error(*AppError): // - ErrUnauth(1001):票据失效(不存在/已消费/过期,需重新走①); // - ErrForbidden(1003):该 (机构,校区) 不属于当前账号(越权)/ 该归属已失效(inactive); // - ErrDBError(5002):票据消费 Redis 故障(fail-closed)/ 取用户·成员关系 DB 故障 / 签发写白名单失败。 // // 安全要点: // - 票据**原子消费**(Redis GETDEL):同一 ticket 并发双请求只有一个能签 token(防重放)。 // - 作用域**服务端校验**:(institutionID, campusID) 必须命中该用户的 membership(防客户端挑任意作用域)。 // - 用户状态**二次校验**:①→② 之间账号可能被禁用/合并,② 重新取 user 校验 active。 func (s *AuthService) Issue(ctx context.Context, loginTicket string, institutionID, campusID int64) (*TokenPair, error) { // 原子消费票据(Redis 故障 fail-closed;未命中返回 "") uid, err := s.tickets.GetAndDeleteTicket(ctx, loginTicket) if err != nil { return nil, err // *AppError(ErrDBError)——Redis 故障,fail-closed } if uid == "" { return nil, apperrors.New(apperrors.CodeUnauth, "登录票据已失效,请重新登录") } // 二次校验用户状态(①→② 间可能被禁用/合并) u, err := s.users.GetByCanonicalUserID(ctx, uid) if err != nil { if stderrors.Is(err, apperrors.ErrNotFound) { return nil, apperrors.New(apperrors.CodeUnauth, "登录票据已失效,请重新登录") } return nil, err } if u.Status != model.UserStatusActive { return nil, apperrors.New(apperrors.CodeUnauth, "登录票据已失效,请重新登录") } // 作用域归属校验(防越权:客户端不能挑任意 (机构,校区)) m, err := s.memberships.GetByUserInstitutionCampus(ctx, uid, institutionID, campusID) if err != nil { if stderrors.Is(err, apperrors.ErrNotFound) { return nil, apperrors.New(apperrors.CodeForbidden, "该机构/校区不属于当前账号") } return nil, err } if m.Status != "active" { return nil, apperrors.New(apperrors.CodeForbidden, "该归属已失效,请重新登录") } claims := token.Claims{ Sub: u.CanonicalUserID, Roles: []string{m.Role}, InstitutionID: institutionID, CampusID: campusID, } access, refresh, err := s.issuer.Issue(ctx, claims) if err != nil { return nil, apperrors.WithCause(apperrors.ErrDBError, err) // 写 refresh 白名单失败(Redis)等 } menu, perms := s.resolveMenu(ctx, m.Role) // T6.5-d:按选定角色下发菜单树 + 权限码(失败→空集,不阻断) return &TokenPair{ AccessToken: access, RefreshToken: refresh, ExpiresAt: time.Now().Add(s.accessTTL).UnixMilli(), User: toUserView(u), Roles: []string{m.Role}, InstitutionID: institutionID, CampusID: campusID, MenuTree: menu, Permissions: perms, }, nil } // Me 登录态自查(/auth/me):凭已验签 claims 回读用户 + 当前全部作用域(菜单刷新恢复)。 // // 入参: // - ctx context.Context:请求上下文。 // - claims token.Claims:验签中间件解析出的 claims(Sub 必填)。 // // 出参: // - *MeResult:用户 + 角色 + 当前作用域 + 全部 membership 列表。 // - error(*AppError): // - ErrNotFound(3001):claims.Sub 对应用户不存在(token 仍在有效期但用户被删,罕见——前端按登出处理); // - ErrDBError(5002):取用户/成员关系 DB 故障。 func (s *AuthService) Me(ctx context.Context, claims token.Claims) (*MeResult, error) { u, err := s.users.GetByCanonicalUserID(ctx, claims.Sub) if err != nil { return nil, err // ErrNotFound / ErrDBError } mergs, err := s.memberships.ListByCanonicalUserID(ctx, claims.Sub) if err != nil { return nil, err } roleCode := "" if len(claims.Roles) > 0 { roleCode = claims.Roles[0] // 主角色(签发侧保证多角色时主角色在前) } menu, perms := s.resolveMenu(ctx, roleCode) // T6.5-d:菜单刷新恢复(同 Issue) return &MeResult{ UserView: toUserView(u), Roles: claims.Roles, InstitutionID: claims.InstitutionID, CampusID: claims.CampusID, Memberships: toMembershipViews(mergs), MenuTree: menu, Permissions: perms, }, nil } // Refresh refresh 轮换:用旧 refresh 换新 access + 新 refresh(旧串立即失效,防重放)。 // // 入参: // - ctx context.Context:请求上下文。 // - oldRefresh string:旧 refresh 不透明串(64 hex = 32 字节随机)。 // // 出参: // - *TokenPair:新凭证对(claims 从旧 refresh 载荷恢复,role/作用域原样)。 // - error(*AppError): // - ErrTokenExpired(1002):refresh 无效(已轮换/已登出/过期/格式错)——前端跳重新登录; // - ErrDBError(5002):轮换写白名单失败 / 回读用户 DB 故障。 // // 实现说明:Rotate 只回 access+refresh 不回 claims,故轮换后用 token.Verify 回读新 access 的 claims // (stateless,与验签中间件同源),再取用户视图——避免在 Issuer 上额外暴露"带 claims 的 Rotate"。 func (s *AuthService) Refresh(ctx context.Context, oldRefresh string) (*TokenPair, error) { newAccess, newRefresh, err := s.issuer.Rotate(ctx, oldRefresh) if err != nil { if stderrors.Is(err, token.ErrRefreshInvalid) { return nil, apperrors.ErrTokenExpired // 1002:refresh 失效,前端跳登录 } return nil, apperrors.WithCause(apperrors.ErrDBError, err) } // 回读新 access 的 claims(Sub/Roles/InstitutionID/CampusID) claims, verr := token.Verify(ctx, s.pubKeys, s.revocation, s.issuerStr, newAccess) if verr != nil { // 理论上刚签发的 access 必可验签;失败属配置异常(公钥/issuer 不一致)→ 5001 return nil, apperrors.WithCause(apperrors.ErrInternal, verr) } u, uerr := s.users.GetByCanonicalUserID(ctx, claims.Sub) if uerr != nil { return nil, uerr // ErrNotFound / ErrDBError } roleCode := "" if len(claims.Roles) > 0 { roleCode = claims.Roles[0] } menu, perms := s.resolveMenu(ctx, roleCode) // T6.5-d:轮换后同补菜单/权限(claims 原样恢复角色) return &TokenPair{ AccessToken: newAccess, RefreshToken: newRefresh, ExpiresAt: time.Now().Add(s.accessTTL).UnixMilli(), User: toUserView(u), Roles: claims.Roles, InstitutionID: claims.InstitutionID, CampusID: claims.CampusID, MenuTree: menu, Permissions: perms, }, nil } // Logout 登出:吊销当前 access jti(黑名单)+ 删除 refresh 白名单键(幂等、尽力而为)。 // // 入参: // - ctx context.Context:请求上下文。 // - refreshToken string:当前 refresh 串(删其白名单键,防再换 access)。 // - accessJTI string:当前 access 的 jti(拉黑;来自已验签 claims 的 ID)。 // - accessRemaining time.Duration:该 access 剩余有效期(黑名单键 TTL;≤0 时存储跳过写——本已过期)。 // // 出参: // - error:**登出对客户端恒为"成功"(本地态必清)**,本方法的错误仅用于服务端告警—— // Handler 层对 logout 恒返回 200(对齐前端 doLogout 的幂等语义:后端失败也清本地)。 // 故调用方(Handler)可忽略 err 直接返回成功;此处保留 error 供日志/告警,不阻断登出。 // - 幂等:refresh 键已删/不存在 → DeleteRefreshToken 不报错(天然幂等)。 func (s *AuthService) Logout(ctx context.Context, refreshToken, accessJTI string, accessRemaining time.Duration) error { // 删 refresh 白名单(尽力而为;格式非法/Redis 故障都不阻断登出——access 30min 后自然过期 + 黑名单兜底) if refreshToken != "" { if err := s.issuer.DeleteRefreshToken(ctx, refreshToken); err != nil { // 记录但不阻断(登出幂等):返回 err 供 Handler 记日志 return err } } // 吊销当前 access(尽力而为;jti 为空或 remaining≤0 时存储跳过) if accessJTI != "" { if err := s.issuer.RevokeAll(ctx, accessJTI, accessRemaining); err != nil { return err } } return nil } // ---- 私有工具 ---- // normalize 入参归一(P0-02 数据归一规则):email 小写 / phone 去空白 / 其余 trim。 // 归一在 Service 入参处理(Repository 不做归一,见 identity_repo.go 注释)。 func (s *AuthService) normalize(identityType, identifier string) string { switch identityType { case model.IdentityTypeEmail: return strings.ToLower(strings.TrimSpace(identifier)) default: return strings.TrimSpace(identifier) } } // burnTiming 防枚举 timing floor:对 dummyHash 跑一次 argon2 校验(丢弃结果), // 使"账号不存在"路径的 CPU 成本与"存在 + argon2 用户"同量级。 // ctx 预留(当前 credential.VerifyArgon2 不消费 ctx,传 _ 占位)。 func (s *AuthService) burnTiming(_ context.Context, password string) { _ = credential.VerifyArgon2(s.dummyHash, password) } // newTicket 生成一次性登录票据(16 字节随机 → 32 hex)。 // crypto/rand 故障概率≈0;失败返回 error(调用方阻断登录,不放行半套凭证)。 func (s *AuthService) newTicket() (string, error) { buf := make([]byte, 16) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil } // toUserView 用户实体 → 对外视图(密码三列 model 已 json:"-",此处再收口取可空字段)。 func toUserView(u *model.User) *UserView { if u == nil { return nil } v := &UserView{ID: u.CanonicalUserID, PersonType: u.PersonType, Status: u.Status} if u.Username != nil { v.Username = *u.Username } if u.DisplayName != nil { v.RealName = *u.DisplayName } return v } // toMembershipViews 成员关系切片 → 对外视图(空切片保持非 nil,JSON 序列化为 [] 而非 null)。 func toMembershipViews(list []orgmodel.Membership) []MembershipView { out := make([]MembershipView, 0, len(list)) for _, m := range list { out = append(out, MembershipView{ MembershipID: m.MembershipID, InstitutionID: m.InstitutionID, CampusID: m.CampusID, Role: m.Role, Status: m.Status, }) } return out } // isValidPhone 手机号基本校验(11 位数字;中文手机号 1 开头,此处仅校验位数+数字,不过度限制)。 func isValidPhone(p string) bool { if len(p) != 11 { return false } for i := 0; i < len(p); i++ { if p[i] < '0' || p[i] > '9' { return false } } return true }