credential.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. // Package credential 密码凭证(argon2 统一 + 登录成功惰性升级,ADR-016)。
  2. //
  3. // 背景:两套旧系统密码算法不一致——教务 `md5(password + salt)`(salt 独立列)、
  4. // 教学 `sha1(password + salt)`,均为弱哈希且两侧 salt 语义不同。ADR-016 拍板:
  5. // - 目标算法统一 argon2id(golang.org/x/crypto/argon2,纯 Go 实现);
  6. // - **惰性升级**:登录时先按 hash_version 路由到旧算法比对,命中后立即用 argon2
  7. // 重哈希写回(调用方在同一事务内完成"验旧 + 写新");**不批量迁移、不强制重置**,
  8. // 避免割接窗口阻断老用户(风险登记册"割接期登录失败");
  9. // - hash_version 取值:md5_salt / sha_salt / argon2(迁移期新增列,DDL 见 unified/02-data-model)。
  10. //
  11. // 典型使用(identity 域 AuthService.Login):
  12. //
  13. // res, err := credential.Check(ctx, input, u.PasswordHash, u.Salt, u.HashVersion)
  14. // if err != nil { return nil, errors.WithCause(errors.ErrInternal, err) }
  15. // if !res.Match { return nil, errors.New(errors.CodeRuleConflict, "账号或密码错误") } // 统一文案,不区分"账号不存在/密码错"(防枚举)
  16. // if res.NeedsUpgrade {
  17. // // 后半段:argon2 重哈希 + hash_version='argon2' + salt 置 NULL(同事务)
  18. // enc, err := credential.HashArgon2(input)
  19. // // UPDATE t_user SET password_hash=?, salt=NULL, hash_version='argon2' WHERE canonical_user_id=?
  20. // // 升级成功后撤销该用户全部 token(对齐旧系统语义,见架构规格 §7.4 改密链路)
  21. // }
  22. // // 继续签发 access + refresh
  23. package credential
  24. import (
  25. "context"
  26. "crypto/md5"
  27. "crypto/rand"
  28. "crypto/sha1"
  29. "crypto/subtle"
  30. "encoding/base64"
  31. "encoding/hex"
  32. "fmt"
  33. "io"
  34. "golang.org/x/crypto/argon2"
  35. )
  36. // HashVersion 密码哈希版本(t_user.hash_version 列取值,ADR-016 路由依据)。
  37. //
  38. // 取值:
  39. // - HashVersionMD5Salt:"md5_salt"——教务旧算法 md5(password + salt),salt 存独立列;
  40. // - HashVersionShASalt:"sha_salt"——教学旧算法 sha1(password + salt),salt 存独立列;
  41. // - HashVersionArgon2:"argon2"——目标算法(argon2id,参数见参数常量;salt 内嵌于编码串,独立列置 NULL)。
  42. const (
  43. HashVersionMD5Salt = "md5_salt"
  44. HashVersionShASalt = "sha_salt"
  45. HashVersionArgon2 = "argon2"
  46. )
  47. // argon2id 参数(OWASP 2024 推荐下限;纯 CPU 哈希,与 GPU 显存无关,
  48. // 双实例同机部署无额外压力)。
  49. // 参数变更 = 全体老 argon2 用户需重登(编码串内嵌参数校验失败),
  50. // 仅在安全审计要求时调整,属重大变更。
  51. // 类型说明:argon2.IDKey 签名为 (password, salt []byte, time uint32, memory uint32, threads uint8, keyLen uint32)。
  52. const (
  53. argon2Time uint32 = 3 // 迭代轮数
  54. argon2Memory uint32 = 64 * 1024 // 64MB 内存
  55. argon2Threads uint8 = 4 // 并行度(API 要求 uint8)
  56. argon2KeyLen uint32 = 32 // 输出 32 字节(API 要求 uint32)
  57. argon2SaltLen int = 16 // 盐 16 字节
  58. )
  59. // ErrUnknownVersion hash_version 取值不在已知集合(迁移脚本漏跑/数据污染)。
  60. // 调用方按"拒绝登录 + 告警"处理(不是密码错误——该用户需运维介入)。
  61. var ErrUnknownVersion = fmt.Errorf("credential: 未知 hash_version(非 %s/%s/%s)",
  62. HashVersionMD5Salt, HashVersionShASalt, HashVersionArgon2)
  63. // Result 密码校验结果(Check 的出参结构)。
  64. //
  65. // 字段:
  66. // - Match bool:密码是否匹配(false 时调用方必须返回统一文案"账号或密码错误",
  67. // 禁止区分"账号不存在"与"密码错误"——防用户名枚举)。
  68. // - NeedsUpgrade bool:校验成功但仍是旧算法(md5_salt/sha_salt),调用方应紧接着
  69. // 执行升级(argon2 重哈希 + 写 hash_version + 撤销全部 token)。
  70. type Result struct {
  71. Match bool
  72. NeedsUpgrade bool
  73. }
  74. // HashArgon2 用 argon2id 生成密码哈希(新注册/改密/惰性升级统一入口)。
  75. //
  76. // 入参:
  77. // - password string:明文密码(仅存在于内存调用栈,禁止写日志/配置/DB 注释)。
  78. //
  79. // 出参:
  80. // - string:PHC 格式编码串 "argon2id$v=19$m=65536,t=3,p=4$<salt-nopad-b64>$<hash-nopad-b64>"
  81. // (单字段自包含 salt + 参数,存 t_user.password_hash,salt 列置 NULL)。
  82. // - error:仅当 crypto/rand 读取盐失败(熵源故障)时返回。
  83. //
  84. // 样例:
  85. //
  86. // enc, _ := credential.HashArgon2("X#9f2k")
  87. // // "argon2id$v=19$m=65536,t=3,p=4$BwJ3...$Qm2..."
  88. func HashArgon2(password string) (string, error) {
  89. salt := make([]byte, argon2SaltLen)
  90. if _, err := io.ReadFull(rand.Reader, salt); err != nil {
  91. return "", fmt.Errorf("credential: 生成 argon2 salt 失败: %w", err)
  92. }
  93. dst := argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
  94. saltB64 := base64.RawStdEncoding.EncodeToString(salt)
  95. hashB64 := base64.RawStdEncoding.EncodeToString(dst)
  96. return fmt.Sprintf("argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
  97. argon2Memory, argon2Time, argon2Threads, saltB64, hashB64), nil
  98. }
  99. // VerifyArgon2 校验明文密码是否匹配 argon2 编码串。
  100. //
  101. // 入参:
  102. // - encoded string:HashArgon2 产出的 PHC 编码串(含内嵌参数/salt)。
  103. // - password string:待校验明文密码。
  104. //
  105. // 出参:
  106. // - bool:true 匹配;false 不匹配**或编码串格式非法**(格式非法按不匹配处理——
  107. // 脏数据不产生 5xx,由数据质量报表另行修复)。
  108. //
  109. // 说明:恒定时间比较(subtle.ConstantTimeCompare),防时序侧信道。
  110. func VerifyArgon2(encoded, password string) bool {
  111. parts := splitPHC(encoded) // "argon2id" | "v=19" | "m=...,t=...,p=..." | saltB64 | hashB64
  112. if len(parts) != 5 || parts[0] != "argon2id" {
  113. return false
  114. }
  115. salt, err := base64.RawStdEncoding.DecodeString(parts[3])
  116. if err != nil {
  117. return false
  118. }
  119. expected, err := base64.RawStdEncoding.DecodeString(parts[4])
  120. if err != nil || uint32(len(expected)) != argon2KeyLen {
  121. return false
  122. }
  123. var m, t uint32
  124. var p uint8
  125. if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
  126. return false
  127. }
  128. actual := argon2.IDKey([]byte(password), salt, t, m, p, argon2KeyLen)
  129. return subtle.ConstantTimeCompare(actual, expected) == 1
  130. }
  131. // Check 按 hash_version 路由做密码校验(登录主链路入口,ADR-016 惰性升级的前半段)。
  132. //
  133. // 入参:
  134. // - ctx context.Context:请求上下文(预留:限流/审计钩子;当前未消费)。
  135. // - password string:用户输入的明文密码。
  136. // - storedHash string:t_user.password_hash 列当前值。
  137. // - salt string:t_user.salt 列当前值(argon2 版本下应为 NULL/空——盐已内嵌编码串)。
  138. // - version string:t_user.hash_version 列当前值(HashVersion* 常量之一)。
  139. //
  140. // 出参:
  141. // - Result:Match/NeedsUpgrade 见 Result 结构注释。
  142. // - error:仅以下场景非 nil:
  143. // - version 不在已知集合 → 包装 ErrUnknownVersion(拒绝登录 + 告警,非密码错误);
  144. // - storedHash 为空 → 该账号无凭证,需运维重建(同样走告警分支)。
  145. //
  146. // 路由逻辑(避免双算法误判,ADR-016 核心):
  147. // 1. md5_salt:md5(password + salt) hex 小写与 storedHash 比较(教务旧算法);
  148. // 2. sha_salt:sha1(password + salt) hex 小写与 storedHash 比较(教学旧算法,
  149. // 迁移前须抽样双跑确认教学确为 sha1 而非 sha256);
  150. // 3. argon2:VerifyArgon2(storedHash, password)。
  151. //
  152. // 命中旧算法时 NeedsUpgrade=true,调用方负责后半段(写回 + 撤 token,见包注释"典型使用")。
  153. //
  154. // 异常场景:
  155. // - 大小写差异:旧库 hex 可能存大写(历史脚本差异),比较前统一转小写——
  156. // 该归一属于"数据保真"例外,单测须覆盖大写 storedHash 用例。
  157. func Check(ctx context.Context, password, storedHash, salt, version string) (Result, error) {
  158. _ = ctx // 预留参数位
  159. if storedHash == "" {
  160. return Result{}, fmt.Errorf("credential: 账号无密码哈希(version=%q),需运维重建凭证", version)
  161. }
  162. switch version {
  163. case HashVersionMD5Salt:
  164. return Result{Match: hexEqFold(legacyDigest(md5.New(), password+salt), storedHash), NeedsUpgrade: true}, nil
  165. case HashVersionShASalt:
  166. return Result{Match: hexEqFold(legacyDigest(sha1.New(), password+salt), storedHash), NeedsUpgrade: true}, nil
  167. case HashVersionArgon2:
  168. return Result{Match: VerifyArgon2(storedHash, password)}, nil
  169. default:
  170. return Result{}, fmt.Errorf("%w(实际=%q)", ErrUnknownVersion, version)
  171. }
  172. }
  173. // legacyDigest 计算旧算法摘要的 hex 小写串(md5/sha1 仅用于旧数据兼容比对,
  174. // 禁止用于任何新数据路径)。
  175. func legacyDigest(h interface{ Write([]byte) (int, error); Sum([]byte) []byte }, s string) string {
  176. h.Write([]byte(s))
  177. return hex.EncodeToString(h.Sum(nil))
  178. }
  179. // hexEqFold 忽略大小写的 hex 串比较(先比长度;旧算法校验发生在"账号已存在"之后,
  180. // 登录链路统一文案已消除枚举风险,此处非常量时间可接受)。
  181. func hexEqFold(a, b string) bool {
  182. if len(a) != len(b) {
  183. return false
  184. }
  185. for i := range a {
  186. ca, cb := a[i], b[i]
  187. if ca >= 'A' && ca <= 'F' {
  188. ca += 'a' - 'A'
  189. }
  190. if cb >= 'A' && cb <= 'F' {
  191. cb += 'a' - 'A'
  192. }
  193. if ca != cb {
  194. return false
  195. }
  196. }
  197. return true
  198. }
  199. // splitPHC 按 '$' 拆分 PHC 编码串(不校验各段格式,由调用方逐段解析)。
  200. func splitPHC(s string) []string {
  201. var parts []string
  202. start := 0
  203. for i := 0; i < len(s); i++ {
  204. if s[i] == '$' {
  205. parts = append(parts, s[start:i])
  206. start = i + 1
  207. }
  208. }
  209. return append(parts, s[start:])
  210. }