package auth // 两步验证(2FA):基于 RFC 6238 (TOTP) / RFC 4226 (HOTP) 的独立实现。 // 全部使用 Go 标准库(crypto/hmac、crypto/sha1、encoding/base32),不引入第三方依赖, // 便于政务环境的供应链与安全审计。备份码复用本包既有的 bcrypt 哈希。 import ( "crypto/hmac" "crypto/rand" "crypto/sha1" "crypto/subtle" "encoding/base32" "encoding/binary" "fmt" "net/url" "strings" ) // 无填充、大写的 base32,与主流认证器 App(Google Authenticator 等)兼容。 var totpB32 = base32.StdEncoding.WithPadding(base32.NoPadding) const ( totpDigits = 6 totpPeriod = 30 // 时间步长(秒) ) // GenerateTOTPSecret 生成 160 位随机密钥并以 base32 字符串返回。 func GenerateTOTPSecret() (string, error) { buf := make([]byte, 20) if _, err := rand.Read(buf); err != nil { return "", err } return totpB32.EncodeToString(buf), nil } // hotp 按 RFC 4226 计算指定计数器对应的一次性口令。 func hotp(key []byte, counter uint64) string { var ctr [8]byte binary.BigEndian.PutUint64(ctr[:], counter) mac := hmac.New(sha1.New, key) mac.Write(ctr[:]) sum := mac.Sum(nil) offset := sum[len(sum)-1] & 0x0f truncated := (uint32(sum[offset]&0x7f) << 24) | (uint32(sum[offset+1]) << 16) | (uint32(sum[offset+2]) << 8) | uint32(sum[offset+3]) mod := uint32(1) for i := 0; i < totpDigits; i++ { mod *= 10 } return fmt.Sprintf("%0*d", totpDigits, truncated%mod) } // TOTPCodeAt 按 RFC 6238 计算给定 Unix 时间(秒)的 TOTP 口令。 func TOTPCodeAt(secret string, unixSeconds int64) (string, error) { key, err := totpB32.DecodeString(strings.ToUpper(strings.TrimSpace(secret))) if err != nil { return "", err } return hotp(key, uint64(unixSeconds/totpPeriod)), nil } // ValidateTOTP 校验口令,允许 ±1 个时间窗(±30s)容忍时钟漂移;使用常量时间比较。 func ValidateTOTP(secret, code string, nowUnix int64) bool { code = strings.TrimSpace(code) if len(code) != totpDigits { return false } for _, skew := range []int64{0, -totpPeriod, totpPeriod} { want, err := TOTPCodeAt(secret, nowUnix+skew) if err != nil { return false } if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 { return true } } return false } // TOTPProvisioningURI 生成 otpauth:// URI,供前端渲染二维码导入认证器 App。 func TOTPProvisioningURI(secret, account, issuer string) string { label := url.PathEscape(issuer + ":" + account) q := url.Values{} q.Set("secret", secret) q.Set("issuer", issuer) q.Set("algorithm", "SHA1") q.Set("digits", fmt.Sprintf("%d", totpDigits)) q.Set("period", fmt.Sprintf("%d", totpPeriod)) return "otpauth://totp/" + label + "?" + q.Encode() } // ---------------- 备份码 ---------------- // 备份码字符集:去掉易混字符(l/o/0/1)。 const backupCodeAlphabet = "abcdefghijkmnpqrstuvwxyz23456789" // normalizeBackupCode 归一化:去空白与连字符、转小写,保证生成与校验一致。 func normalizeBackupCode(code string) string { code = strings.ToLower(strings.TrimSpace(code)) code = strings.ReplaceAll(code, "-", "") code = strings.ReplaceAll(code, " ", "") return code } // GenerateBackupCodes 生成 n 个一次性备份码: // 返回明文(形如 xxxxx-xxxxx,仅展示一次)与对应的 bcrypt 哈希。 func GenerateBackupCodes(n int) (plain []string, hashes []string, err error) { for i := 0; i < n; i++ { raw := make([]byte, 10) if _, err = rand.Read(raw); err != nil { return nil, nil, err } var sb strings.Builder for j, b := range raw { if j == 5 { sb.WriteByte('-') } sb.WriteByte(backupCodeAlphabet[int(b)%len(backupCodeAlphabet)]) } display := sb.String() h, herr := HashPassword(normalizeBackupCode(display)) if herr != nil { return nil, nil, herr } plain = append(plain, display) hashes = append(hashes, h) } return plain, hashes, nil } // CheckBackupCode 校验明文备份码是否匹配给定哈希(bcrypt)。 func CheckBackupCode(code, hash string) bool { return CheckPassword(normalizeBackupCode(code), hash) }