feat(server): web 用户中心后端(3/3) — 用户 TOTP 2FA + 登录二段式
- 用户 TOTP(auth/totp_user.go,复用 internal/totp + AES-256-GCM 加密存密钥):
POST /v1/me/totp/setup(生成密钥+otpauth_uri)、/verify(校验码→启用)、
/disable(校验码→清空)。仅在 USER_TOTP_ENC_KEY(32B/64hex) 配置时挂载。
- 登录二段式:Login 在 totp_enabled 时不发 token,改发短期 pending token(Redis
5min)+ 返回 {totp_required, pending_token};POST /v1/auth/login/totp 消费
pending + 校验码 → 发 token。非 TOTP 用户仍走扁平 TokenPair,app 不受影响。
- User 结构 + GetUserByEmail 补 totp_enabled。
- 单测覆盖 AES seal/open 往返 + 篡改/错误密钥检测 + pending token 唯一性。
- 全量 server 23 包测试通过。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSealOpenAES_Roundtrip(t *testing.T) {
|
||||
var key [32]byte
|
||||
if _, err := rand.Read(key[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const secret = "JBSWY3DPEHPK3PXP" // sample base32 TOTP secret
|
||||
|
||||
blob, err := sealAES(key[:], secret)
|
||||
if err != nil {
|
||||
t.Fatalf("sealAES: %v", err)
|
||||
}
|
||||
if string(blob) == secret {
|
||||
t.Fatal("ciphertext equals plaintext — not encrypted")
|
||||
}
|
||||
|
||||
got, err := openAES(key[:], blob)
|
||||
if err != nil {
|
||||
t.Fatalf("openAES: %v", err)
|
||||
}
|
||||
if got != secret {
|
||||
t.Fatalf("roundtrip mismatch: got %q want %q", got, secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAES_TamperAndWrongKey(t *testing.T) {
|
||||
var key, other [32]byte
|
||||
_, _ = rand.Read(key[:])
|
||||
_, _ = rand.Read(other[:])
|
||||
|
||||
blob, err := sealAES(key[:], "secret-value")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Wrong key must fail (GCM auth).
|
||||
if _, err := openAES(other[:], blob); err == nil {
|
||||
t.Error("openAES with wrong key should fail")
|
||||
}
|
||||
|
||||
// Tampered ciphertext must fail.
|
||||
bad := append([]byte(nil), blob...)
|
||||
bad[len(bad)-1] ^= 0xff
|
||||
if _, err := openAES(key[:], bad); err == nil {
|
||||
t.Error("openAES with tampered ciphertext should fail")
|
||||
}
|
||||
|
||||
// Too-short blob must fail, not panic.
|
||||
if _, err := openAES(key[:], []byte{1, 2, 3}); err == nil {
|
||||
t.Error("openAES with short blob should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewToken16_UniqueHex(t *testing.T) {
|
||||
seen := make(map[string]bool, 100)
|
||||
for i := 0; i < 100; i++ {
|
||||
tok, err := newToken16()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tok) != 32 {
|
||||
t.Fatalf("token length = %d, want 32 hex chars", len(tok))
|
||||
}
|
||||
if seen[tok] {
|
||||
t.Fatalf("duplicate token: %s", tok)
|
||||
}
|
||||
seen[tok] = true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user