auth_service.go 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  1. // Package service 身份域业务逻辑(登录主链路编排:两步登录 / me / refresh / logout)。
  2. //
  3. // 分层职责(架构规格 §3.2 三层):
  4. // - 本包(Service):编排 Repository(数据)+ token.Issuer(凭证)+ redis.LoginTicketStore(票据),
  5. // 只返回 **sentinel 业务错误**(不泄漏 gorm/redis 原错——那是 Repository/存储层的事);
  6. // - Repository(identity/repository):平台级数据访问(本域不继承 scope.BaseRepo);
  7. // - Handler(T4-4):把 Service 的 *AppError 映射为统一响应信封(response.Fail)。
  8. //
  9. // 两步登录协议(T4 契约,2026-11-06 拍板):
  10. //
  11. // ① Login(ctx, identityType, identifier, password):验账密 + 惰性升级 → 返回 loginTicket + 用户基础信息 +
  12. // memberships[](机构/校区/角色,供前端"作用域选择页"展示)。**不签 token**。
  13. // ② Issue(ctx, loginTicket, institutionId, campusId):消费一次性票据(Redis GETDEL 原子)→ 服务端校验
  14. // (机构,校区) 确属该用户(防越权)→ 取该条 role → 签 access+refresh。
  15. // 移动端"自动取第一条" = ①之后立即用 memberships[0] 调 ②,同一套协议,无独立端点。
  16. //
  17. // 安全要点:
  18. // - **防枚举(anti-user-enumeration)**:"账号不存在 / 密码错 / 账号禁用 / 无密码哈希" 一律统一
  19. // 文案"账号或密码错误"(code 1001),并对"账号不存在"路径补跑一次 argon2(timing floor),
  20. // 使响应时间不泄漏"该账号是否存在"。
  21. // - **惰性升级(ADR-016)**:命中旧算法(md5_salt/sha_salt)即 argon2 重哈希写回 + 撤该用户 token;
  22. // 升级写回失败则**阻断登录**(凭证半升级态 = 安全漏洞,宁可让用户重试)。
  23. // - **作用域校验**:② 必须服务端确认 (institutionId, campusId) 确属该用户的 membership,
  24. // 否则拒绝(客户端不能挑任意作用域签 token = 越权)。
  25. //
  26. // 依赖方向:service → repository(接口) + token + infra/redis + credential + model(全部为下层/同域),
  27. // 不依赖 handler/main(纯业务逻辑,可纯单测:mock repo + 真 Issuer + miniredis 票据)。
  28. package service
  29. import (
  30. "context"
  31. "crypto/rand"
  32. "encoding/hex"
  33. stderrors "errors"
  34. "strings"
  35. "time"
  36. "yuansiwei/unified-backend/internal/identity/credential"
  37. "yuansiwei/unified-backend/internal/identity/model"
  38. "yuansiwei/unified-backend/internal/identity/repository"
  39. "yuansiwei/unified-backend/internal/identity/token"
  40. apperrors "yuansiwei/unified-backend/internal/infra/errors"
  41. appredis "yuansiwei/unified-backend/internal/infra/redis"
  42. orgmodel "yuansiwei/unified-backend/internal/org/model"
  43. "yuansiwei/unified-backend/internal/org/rbac"
  44. )
  45. // errAuthFailed 登录主链路统一失败(防枚举:账号不存在/密码错/禁用/无哈希 全部用此)。
  46. // code 1001(认证类),msg 脱敏不区分具体原因。
  47. var errAuthFailed = apperrors.New(apperrors.CodeUnauth, "账号或密码错误")
  48. // defaultLoginTicketTTL 登录票据有效期(①→② 之间的窗口;5min 给前端留足操作时间,
  49. // 又不至于让泄露的 ticket 长期可换 token)。
  50. const defaultLoginTicketTTL = 5 * time.Minute
  51. // AuthService 身份域业务服务(两步登录 / me / refresh / logout 编排)。
  52. //
  53. // 字段(装配入口 NewAuthService 注入,均必填):
  54. // - users / identities / memberships:三仓储接口(生产注入 *repository.UserRepo 等具体实现,
  55. // 单测注入 mock——依赖倒置,纯单测不连 DB)。
  56. // - issuer:token 签发器(RS256 私钥 + access/refresh TTL + refresh 白名单/吊销存储,T4-1/T4-6 装配)。
  57. // - tickets:登录票据存储(Redis GETDEL 原子消费,T4-3 新增)。
  58. // - accessTTL:access 有效期(算 expiresAt + logout 撤销的剩余 TTL)。
  59. // - pubKeys / issuerStr / revocation:验签三件套(Refresh 轮换后回读新 access 的 claims 用,
  60. // 走 stateless 的 token.Verify;与验签中间件同源,来自 config + 同一吊销存储)。
  61. type AuthService struct {
  62. users repository.UserRepository
  63. identities repository.IdentityRepository
  64. memberships repository.MembershipRepository
  65. issuer *token.Issuer
  66. tickets *appredis.LoginTicketStore
  67. accessTTL time.Duration
  68. ticketTTL time.Duration
  69. pubKeys []string
  70. issuerStr string
  71. revocation token.RevocationStore
  72. // menuProvider 角色→菜单树/权限码 解析器(T6.5-d;可空——未注入时 menuTree/permissions 恒空集,
  73. // 菜单全隐/按钮全隐,不阻断登录。生产装配注入 *rbac.Resolver)。
  74. menuProvider MenuProvider
  75. // dummyHash 防枚举的 timing floor:① "账号不存在"路径补跑一次 argon2 校验(对 dummyHash),
  76. // 使该路径 CPU 成本与"存在 + argon2 用户"同量级,响应时间不泄漏账号存在性(稳态全 argon2 后对齐)。
  77. dummyHash string
  78. }
  79. // Config AuthService 装配参数(T4-6 main 按此构造;全部必填,缺则 NewAuthService 报错)。
  80. type Config struct {
  81. Users repository.UserRepository
  82. Identities repository.IdentityRepository
  83. Memberships repository.MembershipRepository
  84. Issuer *token.Issuer
  85. Tickets *appredis.LoginTicketStore
  86. AccessTTL time.Duration
  87. PublicKeys []string
  88. IssuerStr string
  89. Revocation token.RevocationStore
  90. // LoginTicketTTL 可选:登录票据有效期;0/缺省用 defaultLoginTicketTTL(5min)。
  91. LoginTicketTTL time.Duration
  92. // MenuProvider 可选:角色→菜单树/权限码 解析器(T6.5-d,注入 *rbac.Resolver);
  93. // nil 时 menuTree/permissions 恒空集(菜单全隐,不阻断登录——RBAC 未装配时的兜底语义)。
  94. MenuProvider MenuProvider
  95. }
  96. // NewAuthService 构造身份域业务服务(启动装配一次,全进程共享)。
  97. //
  98. // 入参:cfg Config:见 Config 字段说明。
  99. // 出参:
  100. // - *AuthService:就绪服务。
  101. // - error:必填依赖缺失(nil 检查,fail-fast——缺仓储/签发器/票据存储时不允许启动);
  102. // AccessTTL ≤ 0 视为配置错误(access 不能无有效期,安全红线)。
  103. func NewAuthService(cfg Config) (*AuthService, error) {
  104. if cfg.Users == nil || cfg.Identities == nil || cfg.Memberships == nil {
  105. return nil, apperrors.New(apperrors.CodeInternal, "service: 仓储接口未注入(users/identities/memberships 必填)")
  106. }
  107. if cfg.Issuer == nil {
  108. return nil, apperrors.New(apperrors.CodeInternal, "service: token.Issuer 未注入")
  109. }
  110. if cfg.Tickets == nil {
  111. return nil, apperrors.New(apperrors.CodeInternal, "service: 登录票据存储未注入(redis.LoginTicketStore 必填)")
  112. }
  113. if cfg.AccessTTL <= 0 {
  114. return nil, apperrors.New(apperrors.CodeInternal, "service: AccessTTL 必须 > 0(access 无有效期 = 安全红线)")
  115. }
  116. ttl := cfg.LoginTicketTTL
  117. if ttl <= 0 {
  118. ttl = defaultLoginTicketTTL
  119. }
  120. // 防枚举 timing floor:启动时生成一个 argon2 dummy(64MB/1 次,~百毫秒,仅启动开销)。
  121. // 失败不阻断启动(dummy 仅优化计时,非安全必需)——但会告警级降级。
  122. dummy, _ := credential.HashArgon2("timing-equalization-dummy")
  123. return &AuthService{
  124. users: cfg.Users,
  125. identities: cfg.Identities,
  126. memberships: cfg.Memberships,
  127. issuer: cfg.Issuer,
  128. tickets: cfg.Tickets,
  129. accessTTL: cfg.AccessTTL,
  130. ticketTTL: ttl,
  131. pubKeys: cfg.PublicKeys,
  132. issuerStr: cfg.IssuerStr,
  133. revocation: cfg.Revocation,
  134. menuProvider: cfg.MenuProvider,
  135. dummyHash: dummy,
  136. }, nil
  137. }
  138. // resolveMenu 按角色解析菜单树 + 权限码(T6.5-d;MenuProvider 未注入时返回空集,不阻断登录)。
  139. //
  140. // 入参:
  141. // - ctx 请求上下文;
  142. // - roleCode 选定作用域的角色码(token 选定 membership 的 role;通常单条)。
  143. //
  144. // 出参:
  145. // - []MenuNode:可见菜单树(**非 nil**,解析失败/未注入时返回 []);
  146. // - []string:全量权限点 code 集(**非 nil**,解析失败/未注入时返回 [])。
  147. //
  148. // 语义:菜单/权限是"展示层"(拍板),DB 异常不应阻断已签发的 token 下发——
  149. // 返回空集 + 前端菜单全隐,用户仍可登录(数据层权限由路由白名单/Scoping 兜底)。
  150. func (s *AuthService) resolveMenu(ctx context.Context, roleCode string) (menu []MenuNode, perms []string) {
  151. menu = []MenuNode{}
  152. perms = []string{}
  153. if s.menuProvider == nil {
  154. return menu, perms
  155. }
  156. if m, err := s.menuProvider.MenuTree(ctx, roleCode); err == nil && m != nil {
  157. menu = m
  158. }
  159. if p, err := s.menuProvider.Permissions(ctx, roleCode); err == nil && p != nil {
  160. perms = p
  161. }
  162. return menu, perms
  163. }
  164. // ---- 出参 DTO(Service 层领域对象;Handler 层映射为统一响应信封 R<T>)----
  165. // UserView 用户对外视图(不含密码三列——model 已 json:"-" 脱敏,这里再显式收口,防误带出)。
  166. type UserView struct {
  167. ID string `json:"id"` // canonical_user_id(ULID)
  168. Username string `json:"username"` // 用户名(可空)
  169. RealName string `json:"realName"` // 真实姓名(可空)
  170. PersonType string `json:"personType"` // 业务身份:student/teacher/parent/staff
  171. Status string `json:"status"` // active/disabled/merged
  172. }
  173. // MembershipView 一条成员关系(登录第一步② 作用域选择列表项)。
  174. type MembershipView struct {
  175. MembershipID int64 `json:"membershipId"`
  176. InstitutionID int64 `json:"institutionId"`
  177. CampusID int64 `json:"campusId"`
  178. Role string `json:"role"` // 字符串角色码(super_admin/academic/...)
  179. Status string `json:"status"` // active/inactive
  180. }
  181. // LoginStep1 两步登录第一步出参(验账密成功,未签 token)。
  182. type LoginStep1 struct {
  183. LoginTicket string `json:"loginTicket"` // 一次性票据(② 消费;5min 有效,单次)
  184. User *UserView `json:"user"` // 登录用户基础信息
  185. Memberships []MembershipView `json:"memberships"` // 可选作用域列表(空 = 该用户暂无机构归属)
  186. }
  187. // TokenPair 两步登录第二步出参(签发的凭证对 + 作用域 + 用户 + RBAC 菜单/权限码,T6.5-d 起)。
  188. //
  189. // MenuTree / Permissions 由注入的 MenuProvider(rbac.Resolver)按选定角色解析:
  190. // - MenuTree:可见菜单树(PC 前端原样渲染,不二次过滤;契约见 rbac.MenuNode);
  191. // - Permissions:全量权限点 code 集(含 button,前端 v-perm 依据);
  192. //
  193. // 两者**非 nil**(无可见菜单/权限 = [],JSON 序列化为 [] 而非 null);
  194. // 未注入 MenuProvider 时恒为空集(菜单全隐、按钮全隐,不阻断登录——RBAC 子系统装配失败的兜底)。
  195. type TokenPair struct {
  196. AccessToken string `json:"accessToken"`
  197. RefreshToken string `json:"refreshToken"`
  198. ExpiresAt int64 `json:"expiresAt"` // access 过期(Unix 毫秒)
  199. User *UserView `json:"user"`
  200. Roles []string `json:"roles"` // 选定作用域的角色码(通常单条)
  201. InstitutionID int64 `json:"institutionId"` // 选定作用域
  202. CampusID int64 `json:"campusId"`
  203. MenuTree []MenuNode `json:"menuTree"` // 可见菜单树(按选定角色过滤后;PC 原样渲染)
  204. Permissions []string `json:"permissions"` // 全量权限点 code 集(含 button,v-perm 依据)
  205. }
  206. // MenuNode 菜单树节点,直接复用 rbac.MenuNode(PC 前端契约的权威定义在 org/rbac 包)。
  207. // 不在此包重定义:*rbac.Resolver.MenuTree 返回 []rbac.MenuNode,复用同一类型使
  208. // MenuProvider 接口与生产实现签名严格一致(否则需装配层做类型转换,徒增漂移风险)。
  209. // 依赖方向说明:identity/service 已 import org/model(登录引用 membership),再 import
  210. // org/rbac(纯解析器,无 DB/handler 依赖)不破坏分层——rbac 是更下层的展示逻辑。
  211. type MenuNode = rbac.MenuNode
  212. // MenuProvider 角色 → 权限点 code 集 / 菜单树 解析器(T6.5-d 引入的依赖倒置接口)。
  213. //
  214. // 生产实现:*rbac.Resolver(org/rbac 包,TTL 30s 进程内缓存;超管全量、普通查 t_role_permission)。
  215. // 接口定义在消费方 identity/service(消费方定义接口,供装配层注入 *rbac.Resolver / 单测注入 fake);
  216. // rbac.Resolver 的方法签名与本接口一致,故 *rbac.Resolver 隐式满足(编译期断言在 main.go)。
  217. type MenuProvider interface {
  218. // Permissions 角色 code 集(超管返回全量;普通角色查矩阵;空集 = 该角色无授权)。
  219. Permissions(ctx context.Context, roleCode string) ([]string, error)
  220. // MenuTree 角色可见菜单树(button 不进树;父缺失提升顶级;非 nil 切片)。
  221. MenuTree(ctx context.Context, roleCode string) ([]rbac.MenuNode, error)
  222. }
  223. // MeResult 登录态自查出参(/auth/me,菜单刷新恢复用;T6.5-d 起含 MenuTree/Permissions)。
  224. type MeResult struct {
  225. *UserView
  226. Roles []string `json:"roles"`
  227. InstitutionID int64 `json:"institutionId"` // 当前作用域(来自已验签 claims)
  228. CampusID int64 `json:"campusId"`
  229. Memberships []MembershipView `json:"memberships"`
  230. MenuTree []MenuNode `json:"menuTree"` // 可见菜单树(同 TokenPair;菜单刷新恢复)
  231. Permissions []string `json:"permissions"` // 全量权限点 code 集
  232. }
  233. // ---- 两步登录 ----
  234. // Login 两步登录**第一步**:验账密(惰性升级)→ 发一次性票据 + 用户信息 + 作用域列表。
  235. //
  236. // 入参:
  237. // - ctx context.Context:请求上下文。
  238. // - identityType string:登录身份类型(model.IdentityType* 之一:username/phone/email/wechat)。
  239. // PC 当前走 "username"(账号);phone/email/wechat 后续开通。
  240. // - identifier string:归一前标识(username 原值 / phone 11 位 / email 大小写混 / wechat open_id)。
  241. // - password string:明文密码(仅内存调用栈,禁止日志/响应)。
  242. //
  243. // 出参:
  244. // - *LoginStep1:验账密成功(含一次性 loginTicket + memberships)。
  245. // - error(*AppError):
  246. // - errAuthFailed(1001):账号不存在 / 密码错 / 账号禁用 / 无哈希(**统一文案**,防枚举);
  247. // - ErrBadParam(2001):phone 非 11 位(入参格式非法);
  248. // - ErrDBError(5002):DB/Redis 异常(含票据写失败——阻断,不放行半套)。
  249. //
  250. // 时序纪律(防枚举 timing floor):
  251. //
  252. // "账号不存在" 与 "无密码哈希" 路径都会补跑一次 argon2(对 dummyHash),使响应时间与
  253. // "存在 + argon2 用户"同量级——稳态(全员惰性升级到 argon2)下不可区分账号是否存在。
  254. func (s *AuthService) Login(ctx context.Context, identityType, identifier, password string) (*LoginStep1, error) {
  255. ident := s.normalize(identityType, identifier)
  256. if identityType == model.IdentityTypePhone && !isValidPhone(ident) {
  257. return nil, apperrors.ErrBadParam
  258. }
  259. it, err := s.identities.GetByTypeIdentifier(ctx, identityType, ident)
  260. if err != nil {
  261. // 区分"不存在"(统一文案,防枚举)与"DB 故障"(5002)
  262. if stderrors.Is(err, apperrors.ErrNotFound) {
  263. s.burnTiming(ctx, password) // timing floor:补跑一次 argon2
  264. return nil, errAuthFailed
  265. }
  266. return nil, err // *AppError(ErrDBError 等)直接上抛
  267. }
  268. u, err := s.users.GetByCanonicalUserID(ctx, it.CanonicalUserID)
  269. if err != nil {
  270. if stderrors.Is(err, apperrors.ErrNotFound) {
  271. // 身份指向的用户不存在(数据不一致,理论不该发生)→ 统一文案 + timing floor
  272. s.burnTiming(ctx, password)
  273. return nil, errAuthFailed
  274. }
  275. return nil, err
  276. }
  277. // 账号状态:disabled/merged 一律统一文案(不泄漏"该账号存在但被禁用")
  278. if u.Status != model.UserStatusActive {
  279. s.burnTiming(ctx, password)
  280. return nil, errAuthFailed
  281. }
  282. // 密码三列缺失(无哈希):统一文案 + timing floor(不区分"无哈希"与"密码错")
  283. if u.PasswordHash == nil || *u.PasswordHash == "" {
  284. s.burnTiming(ctx, password)
  285. return nil, errAuthFailed
  286. }
  287. var salt, version string
  288. if u.PasswordSalt != nil {
  289. salt = *u.PasswordSalt
  290. }
  291. if u.HashVersion != nil {
  292. version = *u.HashVersion
  293. }
  294. res, err := credential.Check(ctx, password, *u.PasswordHash, salt, version)
  295. if err != nil {
  296. // 未知 hash_version / 数据污染:拒绝登录(非密码错误,但对外统一文案防枚举;
  297. // 根因进日志,运维凭 ErrUnknownVersion 排查)
  298. s.burnTiming(ctx, password)
  299. return nil, errAuthFailed
  300. }
  301. if !res.Match {
  302. return nil, errAuthFailed
  303. }
  304. // 惰性升级(ADR-016 后半段):命中旧算法 → argon2 重哈希写回;失败则阻断登录(半升级态不放行)
  305. if res.NeedsUpgrade {
  306. newHash, herr := credential.HashArgon2(password)
  307. if herr != nil {
  308. return nil, apperrors.WithCause(apperrors.ErrInternal, herr)
  309. }
  310. if uerr := s.users.UpdatePasswordLazyUpgrade(ctx, u.CanonicalUserID, newHash); uerr != nil {
  311. // 升级写回失败:阻断(凭证已改语义但未落库,放行 = 旧凭证仍可登录 = 安全漏洞)
  312. return nil, uerr // *AppError(ErrDBError 3001/5002)
  313. }
  314. // 注:首次登录场景无"该用户既有 token"需撤销(RevokeAll 用于"改密后踢旧端",非首登)。
  315. }
  316. // 取作用域列表(登录第一步展示用;空 = 该用户暂无机构归属,前端提示"联系管理员分配机构")
  317. mergs, merr := s.memberships.ListByCanonicalUserID(ctx, u.CanonicalUserID)
  318. if merr != nil {
  319. return nil, merr // *AppError(ErrDBError)
  320. }
  321. // 发一次性登录票据(Redis;写失败阻断——无票据则无法完成②,不放行半套凭证)
  322. ticket, terr := s.newTicket()
  323. if terr != nil {
  324. return nil, apperrors.WithCause(apperrors.ErrInternal, terr)
  325. }
  326. if setErr := s.tickets.SetTicket(ctx, ticket, u.CanonicalUserID, s.ticketTTL); setErr != nil {
  327. return nil, setErr // *AppError(ErrDBError)
  328. }
  329. return &LoginStep1{
  330. LoginTicket: ticket,
  331. User: toUserView(u),
  332. Memberships: toMembershipViews(mergs),
  333. }, nil
  334. }
  335. // Issue 两步登录**第二步**:消费一次性票据 → 校验作用域归属 → 取 role → 签 access+refresh。
  336. //
  337. // 入参:
  338. // - ctx context.Context:请求上下文。
  339. // - loginTicket string:① 返回的一次性票据(32 hex;已消费/过期/不存在 → 拒绝)。
  340. // - institutionID int64:选定机构(scope 根;0=平台级超管)。
  341. // - campusID int64:选定校区(0=机构级/不限)。
  342. //
  343. // 出参:
  344. // - *TokenPair:签发成功的凭证对 + 选定作用域 + 用户。
  345. // - error(*AppError):
  346. // - ErrUnauth(1001):票据失效(不存在/已消费/过期,需重新走①);
  347. // - ErrForbidden(1003):该 (机构,校区) 不属于当前账号(越权)/ 该归属已失效(inactive);
  348. // - ErrDBError(5002):票据消费 Redis 故障(fail-closed)/ 取用户·成员关系 DB 故障 / 签发写白名单失败。
  349. //
  350. // 安全要点:
  351. // - 票据**原子消费**(Redis GETDEL):同一 ticket 并发双请求只有一个能签 token(防重放)。
  352. // - 作用域**服务端校验**:(institutionID, campusID) 必须命中该用户的 membership(防客户端挑任意作用域)。
  353. // - 用户状态**二次校验**:①→② 之间账号可能被禁用/合并,② 重新取 user 校验 active。
  354. func (s *AuthService) Issue(ctx context.Context, loginTicket string, institutionID, campusID int64) (*TokenPair, error) {
  355. // 原子消费票据(Redis 故障 fail-closed;未命中返回 "")
  356. uid, err := s.tickets.GetAndDeleteTicket(ctx, loginTicket)
  357. if err != nil {
  358. return nil, err // *AppError(ErrDBError)——Redis 故障,fail-closed
  359. }
  360. if uid == "" {
  361. return nil, apperrors.New(apperrors.CodeUnauth, "登录票据已失效,请重新登录")
  362. }
  363. // 二次校验用户状态(①→② 间可能被禁用/合并)
  364. u, err := s.users.GetByCanonicalUserID(ctx, uid)
  365. if err != nil {
  366. if stderrors.Is(err, apperrors.ErrNotFound) {
  367. return nil, apperrors.New(apperrors.CodeUnauth, "登录票据已失效,请重新登录")
  368. }
  369. return nil, err
  370. }
  371. if u.Status != model.UserStatusActive {
  372. return nil, apperrors.New(apperrors.CodeUnauth, "登录票据已失效,请重新登录")
  373. }
  374. // 作用域归属校验(防越权:客户端不能挑任意 (机构,校区))
  375. m, err := s.memberships.GetByUserInstitutionCampus(ctx, uid, institutionID, campusID)
  376. if err != nil {
  377. if stderrors.Is(err, apperrors.ErrNotFound) {
  378. return nil, apperrors.New(apperrors.CodeForbidden, "该机构/校区不属于当前账号")
  379. }
  380. return nil, err
  381. }
  382. if m.Status != "active" {
  383. return nil, apperrors.New(apperrors.CodeForbidden, "该归属已失效,请重新登录")
  384. }
  385. claims := token.Claims{
  386. Sub: u.CanonicalUserID,
  387. Roles: []string{m.Role},
  388. InstitutionID: institutionID,
  389. CampusID: campusID,
  390. }
  391. access, refresh, err := s.issuer.Issue(ctx, claims)
  392. if err != nil {
  393. return nil, apperrors.WithCause(apperrors.ErrDBError, err) // 写 refresh 白名单失败(Redis)等
  394. }
  395. menu, perms := s.resolveMenu(ctx, m.Role) // T6.5-d:按选定角色下发菜单树 + 权限码(失败→空集,不阻断)
  396. return &TokenPair{
  397. AccessToken: access,
  398. RefreshToken: refresh,
  399. ExpiresAt: time.Now().Add(s.accessTTL).UnixMilli(),
  400. User: toUserView(u),
  401. Roles: []string{m.Role},
  402. InstitutionID: institutionID,
  403. CampusID: campusID,
  404. MenuTree: menu,
  405. Permissions: perms,
  406. }, nil
  407. }
  408. // Me 登录态自查(/auth/me):凭已验签 claims 回读用户 + 当前全部作用域(菜单刷新恢复)。
  409. //
  410. // 入参:
  411. // - ctx context.Context:请求上下文。
  412. // - claims token.Claims:验签中间件解析出的 claims(Sub 必填)。
  413. //
  414. // 出参:
  415. // - *MeResult:用户 + 角色 + 当前作用域 + 全部 membership 列表。
  416. // - error(*AppError):
  417. // - ErrNotFound(3001):claims.Sub 对应用户不存在(token 仍在有效期但用户被删,罕见——前端按登出处理);
  418. // - ErrDBError(5002):取用户/成员关系 DB 故障。
  419. func (s *AuthService) Me(ctx context.Context, claims token.Claims) (*MeResult, error) {
  420. u, err := s.users.GetByCanonicalUserID(ctx, claims.Sub)
  421. if err != nil {
  422. return nil, err // ErrNotFound / ErrDBError
  423. }
  424. mergs, err := s.memberships.ListByCanonicalUserID(ctx, claims.Sub)
  425. if err != nil {
  426. return nil, err
  427. }
  428. roleCode := ""
  429. if len(claims.Roles) > 0 {
  430. roleCode = claims.Roles[0] // 主角色(签发侧保证多角色时主角色在前)
  431. }
  432. menu, perms := s.resolveMenu(ctx, roleCode) // T6.5-d:菜单刷新恢复(同 Issue)
  433. return &MeResult{
  434. UserView: toUserView(u),
  435. Roles: claims.Roles,
  436. InstitutionID: claims.InstitutionID,
  437. CampusID: claims.CampusID,
  438. Memberships: toMembershipViews(mergs),
  439. MenuTree: menu,
  440. Permissions: perms,
  441. }, nil
  442. }
  443. // Refresh refresh 轮换:用旧 refresh 换新 access + 新 refresh(旧串立即失效,防重放)。
  444. //
  445. // 入参:
  446. // - ctx context.Context:请求上下文。
  447. // - oldRefresh string:旧 refresh 不透明串(64 hex = 32 字节随机)。
  448. //
  449. // 出参:
  450. // - *TokenPair:新凭证对(claims 从旧 refresh 载荷恢复,role/作用域原样)。
  451. // - error(*AppError):
  452. // - ErrTokenExpired(1002):refresh 无效(已轮换/已登出/过期/格式错)——前端跳重新登录;
  453. // - ErrDBError(5002):轮换写白名单失败 / 回读用户 DB 故障。
  454. //
  455. // 实现说明:Rotate 只回 access+refresh 不回 claims,故轮换后用 token.Verify 回读新 access 的 claims
  456. // (stateless,与验签中间件同源),再取用户视图——避免在 Issuer 上额外暴露"带 claims 的 Rotate"。
  457. func (s *AuthService) Refresh(ctx context.Context, oldRefresh string) (*TokenPair, error) {
  458. newAccess, newRefresh, err := s.issuer.Rotate(ctx, oldRefresh)
  459. if err != nil {
  460. if stderrors.Is(err, token.ErrRefreshInvalid) {
  461. return nil, apperrors.ErrTokenExpired // 1002:refresh 失效,前端跳登录
  462. }
  463. return nil, apperrors.WithCause(apperrors.ErrDBError, err)
  464. }
  465. // 回读新 access 的 claims(Sub/Roles/InstitutionID/CampusID)
  466. claims, verr := token.Verify(ctx, s.pubKeys, s.revocation, s.issuerStr, newAccess)
  467. if verr != nil {
  468. // 理论上刚签发的 access 必可验签;失败属配置异常(公钥/issuer 不一致)→ 5001
  469. return nil, apperrors.WithCause(apperrors.ErrInternal, verr)
  470. }
  471. u, uerr := s.users.GetByCanonicalUserID(ctx, claims.Sub)
  472. if uerr != nil {
  473. return nil, uerr // ErrNotFound / ErrDBError
  474. }
  475. roleCode := ""
  476. if len(claims.Roles) > 0 {
  477. roleCode = claims.Roles[0]
  478. }
  479. menu, perms := s.resolveMenu(ctx, roleCode) // T6.5-d:轮换后同补菜单/权限(claims 原样恢复角色)
  480. return &TokenPair{
  481. AccessToken: newAccess,
  482. RefreshToken: newRefresh,
  483. ExpiresAt: time.Now().Add(s.accessTTL).UnixMilli(),
  484. User: toUserView(u),
  485. Roles: claims.Roles,
  486. InstitutionID: claims.InstitutionID,
  487. CampusID: claims.CampusID,
  488. MenuTree: menu,
  489. Permissions: perms,
  490. }, nil
  491. }
  492. // Logout 登出:吊销当前 access jti(黑名单)+ 删除 refresh 白名单键(幂等、尽力而为)。
  493. //
  494. // 入参:
  495. // - ctx context.Context:请求上下文。
  496. // - refreshToken string:当前 refresh 串(删其白名单键,防再换 access)。
  497. // - accessJTI string:当前 access 的 jti(拉黑;来自已验签 claims 的 ID)。
  498. // - accessRemaining time.Duration:该 access 剩余有效期(黑名单键 TTL;≤0 时存储跳过写——本已过期)。
  499. //
  500. // 出参:
  501. // - error:**登出对客户端恒为"成功"(本地态必清)**,本方法的错误仅用于服务端告警——
  502. // Handler 层对 logout 恒返回 200(对齐前端 doLogout 的幂等语义:后端失败也清本地)。
  503. // 故调用方(Handler)可忽略 err 直接返回成功;此处保留 error 供日志/告警,不阻断登出。
  504. // - 幂等:refresh 键已删/不存在 → DeleteRefreshToken 不报错(天然幂等)。
  505. func (s *AuthService) Logout(ctx context.Context, refreshToken, accessJTI string, accessRemaining time.Duration) error {
  506. // 删 refresh 白名单(尽力而为;格式非法/Redis 故障都不阻断登出——access 30min 后自然过期 + 黑名单兜底)
  507. if refreshToken != "" {
  508. if err := s.issuer.DeleteRefreshToken(ctx, refreshToken); err != nil {
  509. // 记录但不阻断(登出幂等):返回 err 供 Handler 记日志
  510. return err
  511. }
  512. }
  513. // 吊销当前 access(尽力而为;jti 为空或 remaining≤0 时存储跳过)
  514. if accessJTI != "" {
  515. if err := s.issuer.RevokeAll(ctx, accessJTI, accessRemaining); err != nil {
  516. return err
  517. }
  518. }
  519. return nil
  520. }
  521. // ---- 私有工具 ----
  522. // normalize 入参归一(P0-02 数据归一规则):email 小写 / phone 去空白 / 其余 trim。
  523. // 归一在 Service 入参处理(Repository 不做归一,见 identity_repo.go 注释)。
  524. func (s *AuthService) normalize(identityType, identifier string) string {
  525. switch identityType {
  526. case model.IdentityTypeEmail:
  527. return strings.ToLower(strings.TrimSpace(identifier))
  528. default:
  529. return strings.TrimSpace(identifier)
  530. }
  531. }
  532. // burnTiming 防枚举 timing floor:对 dummyHash 跑一次 argon2 校验(丢弃结果),
  533. // 使"账号不存在"路径的 CPU 成本与"存在 + argon2 用户"同量级。
  534. // ctx 预留(当前 credential.VerifyArgon2 不消费 ctx,传 _ 占位)。
  535. func (s *AuthService) burnTiming(_ context.Context, password string) {
  536. _ = credential.VerifyArgon2(s.dummyHash, password)
  537. }
  538. // newTicket 生成一次性登录票据(16 字节随机 → 32 hex)。
  539. // crypto/rand 故障概率≈0;失败返回 error(调用方阻断登录,不放行半套凭证)。
  540. func (s *AuthService) newTicket() (string, error) {
  541. buf := make([]byte, 16)
  542. if _, err := rand.Read(buf); err != nil {
  543. return "", err
  544. }
  545. return hex.EncodeToString(buf), nil
  546. }
  547. // toUserView 用户实体 → 对外视图(密码三列 model 已 json:"-",此处再收口取可空字段)。
  548. func toUserView(u *model.User) *UserView {
  549. if u == nil {
  550. return nil
  551. }
  552. v := &UserView{ID: u.CanonicalUserID, PersonType: u.PersonType, Status: u.Status}
  553. if u.Username != nil {
  554. v.Username = *u.Username
  555. }
  556. if u.DisplayName != nil {
  557. v.RealName = *u.DisplayName
  558. }
  559. return v
  560. }
  561. // toMembershipViews 成员关系切片 → 对外视图(空切片保持非 nil,JSON 序列化为 [] 而非 null)。
  562. func toMembershipViews(list []orgmodel.Membership) []MembershipView {
  563. out := make([]MembershipView, 0, len(list))
  564. for _, m := range list {
  565. out = append(out, MembershipView{
  566. MembershipID: m.MembershipID,
  567. InstitutionID: m.InstitutionID,
  568. CampusID: m.CampusID,
  569. Role: m.Role,
  570. Status: m.Status,
  571. })
  572. }
  573. return out
  574. }
  575. // isValidPhone 手机号基本校验(11 位数字;中文手机号 1 开头,此处仅校验位数+数字,不过度限制)。
  576. func isValidPhone(p string) bool {
  577. if len(p) != 11 {
  578. return false
  579. }
  580. for i := 0; i < len(p); i++ {
  581. if p[i] < '0' || p[i] > '9' {
  582. return false
  583. }
  584. }
  585. return true
  586. }