auth_service.go 24 KB

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