| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628 |
- // 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<T>)----
- // 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
- }
|