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 应用迁移并烟测。
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user