Files
GovAI/server/pkg/auth/twofa.go
T
freedakgmail c949204662 feat(govai): 0617 优化首批 — 安全/私有化/深度研究/服务层/可观测性
借鉴 odysseus 的能力设计,全程净室实现、零 AGPL 代码、不引入 AGPL 依赖。

T1 提示注入防护: pkg/promptguard 包裹外部/知识库内容为不可信数据,buildMessages 移出 system 指令区。 T2 安全 CI: .github/workflows(ci+security: govulncheck/gitleaks/actionlint/hadolint/trivy)+dependabot+.hadolint.yaml;go.mod 加 toolchain go1.25.11 修复 20 个 stdlib CVE。 T3 管理员 2FA: 迁移 000016 + RFC6238 TOTP/备份码(pkg/auth, 零依赖) + 登录流程集成(后端)。 T4 本地模型: LLM/embedding 支持本地 vLLM/Ollama(OpenAI 兼容, 鉴权头条件发送, NoAuth) + docs/local-deploy.md。 T6 深度研究: 迁移 000017 + Python research-worker(净室多步流水线, 检索避开 SearXNG) + Go research 服务/handler/路由。 T7 service 层: 新增 internal/service/{research,twofa}, 2FA 业务逻辑从胖 handler 下沉, 接口注入可单测。 T10 缓存/可观测性: internal/cache(Redis+内存, 优雅降级) 接入 store 热点列表; Prometheus 指标+/metrics; docs/openapi.yaml。 验证: go build/vet/test ./... 全绿(8 包); research-worker 12 单测过; 真实 PG 应用迁移并烟测。
2026-06-17 17:52:47 +08:00

140 lines
4.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,与主流认证器 AppGoogle 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)
}