secret.go 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. package auth
  2. import (
  3. "crypto/hmac"
  4. "crypto/rand"
  5. "crypto/sha1"
  6. "encoding/base32"
  7. "encoding/binary"
  8. "errors"
  9. "fmt"
  10. "net/url"
  11. "strings"
  12. "time"
  13. "golang.org/x/crypto/bcrypt"
  14. )
  15. // 密码与 TOTP 的编解码与校验。依据 docs/backend/auth.md 第八节。
  16. //
  17. // - 密码用 bcrypt,cost 固定 12;
  18. // - TOTP 按 RFC 6238(HMAC-SHA1、6 位、30 秒步长),secret 明文存储(auth.md 8.2);
  19. // - 校验允许前后各一个时间窗,容忍轻微时钟漂移;这不改变 30 秒的有效期语义。
  20. const (
  21. totpDigits = 6
  22. totpPeriod = 30 * time.Second
  23. // totpSkew 允许的时间窗漂移,单位:步长。1 表示接受上一个和下一个 30 秒窗口。
  24. totpSkew = 1
  25. )
  26. // MinPasswordLen 见 docs/api.md 2.7(已定义在 consts.go)。
  27. // 审计用的登录途径(audit.md 第三节 login_success 的 metadata.via)。
  28. const (
  29. auditViaTotp = "password+totp"
  30. auditViaTrustedDevice = "password+trusted_device"
  31. )
  32. // HashPassword 用 bcrypt 生成密码哈希,cost 固定 12(auth.md 8.1)。
  33. func HashPassword(password string) (string, error) {
  34. hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
  35. if err != nil {
  36. return "", fmt.Errorf("生成密码哈希失败: %w", err)
  37. }
  38. return string(hash), nil
  39. }
  40. // passwordMatches 用恒定时间比较校验密码。
  41. func passwordMatches(hash, password string) bool {
  42. return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
  43. }
  44. // ErrPasswordTooShort / ErrPasswordSameAsOld 是密码策略错误,
  45. // handler 层据此返回 422 validation_failed(api.md 2.7)。
  46. var (
  47. ErrPasswordTooShort = fmt.Errorf("新密码至少 %d 位", MinPasswordLen)
  48. ErrPasswordSameAsOld = errors.New("新密码不能与旧密码相同")
  49. )
  50. // ValidatePassword 校验新密码:至少 MinPasswordLen 位,且不能与旧密码相同(api.md 2.7)。
  51. func ValidatePassword(newPassword, oldPassword string) error {
  52. if len([]rune(newPassword)) < MinPasswordLen {
  53. return ErrPasswordTooShort
  54. }
  55. if newPassword == oldPassword {
  56. return ErrPasswordSameAsOld
  57. }
  58. return nil
  59. }
  60. // IsPasswordPolicyError 判断错误是否属于密码策略问题(应返回 422)。
  61. func IsPasswordPolicyError(err error) bool {
  62. return errors.Is(err, ErrPasswordTooShort) || errors.Is(err, ErrPasswordSameAsOld)
  63. }
  64. // NewTotpSecret 生成新的 TOTP 密钥(base32 无填充,20 字节 = 160 位,RFC 4226 推荐长度),
  65. // 并返回可被验证器扫描的 otpauth URI(api.md 2.4)。
  66. func NewTotpSecret(issuer, username string) (secret, uri string, err error) {
  67. buf := make([]byte, 20)
  68. if _, err := rand.Read(buf); err != nil {
  69. return "", "", fmt.Errorf("生成 TOTP 密钥失败: %w", err)
  70. }
  71. secret = base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)
  72. // otpauth://totp/<issuer>:<username>?secret=...&issuer=<issuer>
  73. label := url.PathEscape(issuer + ":" + username)
  74. q := url.Values{}
  75. q.Set("secret", secret)
  76. q.Set("issuer", issuer)
  77. q.Set("algorithm", "SHA1")
  78. q.Set("digits", fmt.Sprint(totpDigits))
  79. q.Set("period", fmt.Sprint(int(totpPeriod.Seconds())))
  80. return secret, "otpauth://totp/" + label + "?" + q.Encode(), nil
  81. }
  82. // TotpValid 校验 6 位动态码,允许 totpSkew 个步长的时间漂移。
  83. func TotpValid(secret, code string, now time.Time) bool {
  84. code = strings.TrimSpace(code)
  85. if len(code) != totpDigits {
  86. return false
  87. }
  88. if secret == "" {
  89. return false
  90. }
  91. key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret))
  92. if err != nil {
  93. return false
  94. }
  95. counter := now.Unix() / int64(totpPeriod.Seconds())
  96. for offset := -int64(totpSkew); offset <= int64(totpSkew); offset++ {
  97. want, err := hotp(key, uint64(counter+offset))
  98. if err != nil {
  99. return false
  100. }
  101. // 恒定时间比较,避免通过耗时差异逐位猜码
  102. if secureEqual(want, code) {
  103. return true
  104. }
  105. }
  106. return false
  107. }
  108. // hotp 按 RFC 4226 计算 HOTP 值,格式化为 6 位十进制字符串。
  109. func hotp(key []byte, counter uint64) (string, error) {
  110. var buf [8]byte
  111. binary.BigEndian.PutUint64(buf[:], counter)
  112. mac := hmac.New(sha1.New, key)
  113. if _, err := mac.Write(buf[:]); err != nil {
  114. return "", fmt.Errorf("计算 TOTP 失败: %w", err)
  115. }
  116. sum := mac.Sum(nil)
  117. // 动态截断(RFC 4226 5.3)
  118. offset := sum[len(sum)-1] & 0x0f
  119. value := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff
  120. return fmt.Sprintf("%0*d", totpDigits, value%1_000_000), nil
  121. }
  122. // GenerateTotpCode 生成指定时刻的动态码,仅供测试与本地调试使用。
  123. func GenerateTotpCode(secret string, at time.Time) (string, error) {
  124. key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret))
  125. if err != nil {
  126. return "", fmt.Errorf("解码 TOTP 密钥失败: %w", err)
  127. }
  128. return hotp(key, uint64(at.Unix()/int64(totpPeriod.Seconds())))
  129. }
苏ICP备2026082406号
苏公网安备 待审核