| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222 |
- // Package credential 密码凭证(argon2 统一 + 登录成功惰性升级,ADR-016)。
- //
- // 背景:两套旧系统密码算法不一致——教务 `md5(password + salt)`(salt 独立列)、
- // 教学 `sha1(password + salt)`,均为弱哈希且两侧 salt 语义不同。ADR-016 拍板:
- // - 目标算法统一 argon2id(golang.org/x/crypto/argon2,纯 Go 实现);
- // - **惰性升级**:登录时先按 hash_version 路由到旧算法比对,命中后立即用 argon2
- // 重哈希写回(调用方在同一事务内完成"验旧 + 写新");**不批量迁移、不强制重置**,
- // 避免割接窗口阻断老用户(风险登记册"割接期登录失败");
- // - hash_version 取值:md5_salt / sha_salt / argon2(迁移期新增列,DDL 见 unified/02-data-model)。
- //
- // 典型使用(identity 域 AuthService.Login):
- //
- // res, err := credential.Check(ctx, input, u.PasswordHash, u.Salt, u.HashVersion)
- // if err != nil { return nil, errors.WithCause(errors.ErrInternal, err) }
- // if !res.Match { return nil, errors.New(errors.CodeRuleConflict, "账号或密码错误") } // 统一文案,不区分"账号不存在/密码错"(防枚举)
- // if res.NeedsUpgrade {
- // // 后半段:argon2 重哈希 + hash_version='argon2' + salt 置 NULL(同事务)
- // enc, err := credential.HashArgon2(input)
- // // UPDATE t_user SET password_hash=?, salt=NULL, hash_version='argon2' WHERE canonical_user_id=?
- // // 升级成功后撤销该用户全部 token(对齐旧系统语义,见架构规格 §7.4 改密链路)
- // }
- // // 继续签发 access + refresh
- package credential
- import (
- "context"
- "crypto/md5"
- "crypto/rand"
- "crypto/sha1"
- "crypto/subtle"
- "encoding/base64"
- "encoding/hex"
- "fmt"
- "io"
- "golang.org/x/crypto/argon2"
- )
- // HashVersion 密码哈希版本(t_user.hash_version 列取值,ADR-016 路由依据)。
- //
- // 取值:
- // - HashVersionMD5Salt:"md5_salt"——教务旧算法 md5(password + salt),salt 存独立列;
- // - HashVersionShASalt:"sha_salt"——教学旧算法 sha1(password + salt),salt 存独立列;
- // - HashVersionArgon2:"argon2"——目标算法(argon2id,参数见参数常量;salt 内嵌于编码串,独立列置 NULL)。
- const (
- HashVersionMD5Salt = "md5_salt"
- HashVersionShASalt = "sha_salt"
- HashVersionArgon2 = "argon2"
- )
- // argon2id 参数(OWASP 2024 推荐下限;纯 CPU 哈希,与 GPU 显存无关,
- // 双实例同机部署无额外压力)。
- // 参数变更 = 全体老 argon2 用户需重登(编码串内嵌参数校验失败),
- // 仅在安全审计要求时调整,属重大变更。
- // 类型说明:argon2.IDKey 签名为 (password, salt []byte, time uint32, memory uint32, threads uint8, keyLen uint32)。
- const (
- argon2Time uint32 = 3 // 迭代轮数
- argon2Memory uint32 = 64 * 1024 // 64MB 内存
- argon2Threads uint8 = 4 // 并行度(API 要求 uint8)
- argon2KeyLen uint32 = 32 // 输出 32 字节(API 要求 uint32)
- argon2SaltLen int = 16 // 盐 16 字节
- )
- // ErrUnknownVersion hash_version 取值不在已知集合(迁移脚本漏跑/数据污染)。
- // 调用方按"拒绝登录 + 告警"处理(不是密码错误——该用户需运维介入)。
- var ErrUnknownVersion = fmt.Errorf("credential: 未知 hash_version(非 %s/%s/%s)",
- HashVersionMD5Salt, HashVersionShASalt, HashVersionArgon2)
- // Result 密码校验结果(Check 的出参结构)。
- //
- // 字段:
- // - Match bool:密码是否匹配(false 时调用方必须返回统一文案"账号或密码错误",
- // 禁止区分"账号不存在"与"密码错误"——防用户名枚举)。
- // - NeedsUpgrade bool:校验成功但仍是旧算法(md5_salt/sha_salt),调用方应紧接着
- // 执行升级(argon2 重哈希 + 写 hash_version + 撤销全部 token)。
- type Result struct {
- Match bool
- NeedsUpgrade bool
- }
- // HashArgon2 用 argon2id 生成密码哈希(新注册/改密/惰性升级统一入口)。
- //
- // 入参:
- // - password string:明文密码(仅存在于内存调用栈,禁止写日志/配置/DB 注释)。
- //
- // 出参:
- // - string:PHC 格式编码串 "argon2id$v=19$m=65536,t=3,p=4$<salt-nopad-b64>$<hash-nopad-b64>"
- // (单字段自包含 salt + 参数,存 t_user.password_hash,salt 列置 NULL)。
- // - error:仅当 crypto/rand 读取盐失败(熵源故障)时返回。
- //
- // 样例:
- //
- // enc, _ := credential.HashArgon2("X#9f2k")
- // // "argon2id$v=19$m=65536,t=3,p=4$BwJ3...$Qm2..."
- func HashArgon2(password string) (string, error) {
- salt := make([]byte, argon2SaltLen)
- if _, err := io.ReadFull(rand.Reader, salt); err != nil {
- return "", fmt.Errorf("credential: 生成 argon2 salt 失败: %w", err)
- }
- dst := argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
- saltB64 := base64.RawStdEncoding.EncodeToString(salt)
- hashB64 := base64.RawStdEncoding.EncodeToString(dst)
- return fmt.Sprintf("argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
- argon2Memory, argon2Time, argon2Threads, saltB64, hashB64), nil
- }
- // VerifyArgon2 校验明文密码是否匹配 argon2 编码串。
- //
- // 入参:
- // - encoded string:HashArgon2 产出的 PHC 编码串(含内嵌参数/salt)。
- // - password string:待校验明文密码。
- //
- // 出参:
- // - bool:true 匹配;false 不匹配**或编码串格式非法**(格式非法按不匹配处理——
- // 脏数据不产生 5xx,由数据质量报表另行修复)。
- //
- // 说明:恒定时间比较(subtle.ConstantTimeCompare),防时序侧信道。
- func VerifyArgon2(encoded, password string) bool {
- parts := splitPHC(encoded) // "argon2id" | "v=19" | "m=...,t=...,p=..." | saltB64 | hashB64
- if len(parts) != 5 || parts[0] != "argon2id" {
- return false
- }
- salt, err := base64.RawStdEncoding.DecodeString(parts[3])
- if err != nil {
- return false
- }
- expected, err := base64.RawStdEncoding.DecodeString(parts[4])
- if err != nil || uint32(len(expected)) != argon2KeyLen {
- return false
- }
- var m, t uint32
- var p uint8
- if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
- return false
- }
- actual := argon2.IDKey([]byte(password), salt, t, m, p, argon2KeyLen)
- return subtle.ConstantTimeCompare(actual, expected) == 1
- }
- // Check 按 hash_version 路由做密码校验(登录主链路入口,ADR-016 惰性升级的前半段)。
- //
- // 入参:
- // - ctx context.Context:请求上下文(预留:限流/审计钩子;当前未消费)。
- // - password string:用户输入的明文密码。
- // - storedHash string:t_user.password_hash 列当前值。
- // - salt string:t_user.salt 列当前值(argon2 版本下应为 NULL/空——盐已内嵌编码串)。
- // - version string:t_user.hash_version 列当前值(HashVersion* 常量之一)。
- //
- // 出参:
- // - Result:Match/NeedsUpgrade 见 Result 结构注释。
- // - error:仅以下场景非 nil:
- // - version 不在已知集合 → 包装 ErrUnknownVersion(拒绝登录 + 告警,非密码错误);
- // - storedHash 为空 → 该账号无凭证,需运维重建(同样走告警分支)。
- //
- // 路由逻辑(避免双算法误判,ADR-016 核心):
- // 1. md5_salt:md5(password + salt) hex 小写与 storedHash 比较(教务旧算法);
- // 2. sha_salt:sha1(password + salt) hex 小写与 storedHash 比较(教学旧算法,
- // 迁移前须抽样双跑确认教学确为 sha1 而非 sha256);
- // 3. argon2:VerifyArgon2(storedHash, password)。
- //
- // 命中旧算法时 NeedsUpgrade=true,调用方负责后半段(写回 + 撤 token,见包注释"典型使用")。
- //
- // 异常场景:
- // - 大小写差异:旧库 hex 可能存大写(历史脚本差异),比较前统一转小写——
- // 该归一属于"数据保真"例外,单测须覆盖大写 storedHash 用例。
- func Check(ctx context.Context, password, storedHash, salt, version string) (Result, error) {
- _ = ctx // 预留参数位
- if storedHash == "" {
- return Result{}, fmt.Errorf("credential: 账号无密码哈希(version=%q),需运维重建凭证", version)
- }
- switch version {
- case HashVersionMD5Salt:
- return Result{Match: hexEqFold(legacyDigest(md5.New(), password+salt), storedHash), NeedsUpgrade: true}, nil
- case HashVersionShASalt:
- return Result{Match: hexEqFold(legacyDigest(sha1.New(), password+salt), storedHash), NeedsUpgrade: true}, nil
- case HashVersionArgon2:
- return Result{Match: VerifyArgon2(storedHash, password)}, nil
- default:
- return Result{}, fmt.Errorf("%w(实际=%q)", ErrUnknownVersion, version)
- }
- }
- // legacyDigest 计算旧算法摘要的 hex 小写串(md5/sha1 仅用于旧数据兼容比对,
- // 禁止用于任何新数据路径)。
- func legacyDigest(h interface{ Write([]byte) (int, error); Sum([]byte) []byte }, s string) string {
- h.Write([]byte(s))
- return hex.EncodeToString(h.Sum(nil))
- }
- // hexEqFold 忽略大小写的 hex 串比较(先比长度;旧算法校验发生在"账号已存在"之后,
- // 登录链路统一文案已消除枚举风险,此处非常量时间可接受)。
- func hexEqFold(a, b string) bool {
- if len(a) != len(b) {
- return false
- }
- for i := range a {
- ca, cb := a[i], b[i]
- if ca >= 'A' && ca <= 'F' {
- ca += 'a' - 'A'
- }
- if cb >= 'A' && cb <= 'F' {
- cb += 'a' - 'A'
- }
- if ca != cb {
- return false
- }
- }
- return true
- }
- // splitPHC 按 '$' 拆分 PHC 编码串(不校验各段格式,由调用方逐段解析)。
- func splitPHC(s string) []string {
- var parts []string
- start := 0
- for i := 0; i < len(s); i++ {
- if s[i] == '$' {
- parts = append(parts, s[start:i])
- start = i + 1
- }
- }
- return append(parts, s[start:])
- }
|