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:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"log"
|
||||
@@ -235,6 +236,18 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
authHandler = auth.NewHandler(authSvc)
|
||||
}
|
||||
|
||||
// ── User TOTP (web 用户中心 2FA) ─────────────────────────────────────────────
|
||||
// Only enabled when a valid 32-byte key is configured (USER_TOTP_ENC_KEY,
|
||||
// raw 32 bytes or 64 hex chars); secrets are encrypted at rest under it.
|
||||
var totpHandler *auth.TOTPHandler
|
||||
if tm != nil {
|
||||
if key := parseTOTPKey(os.Getenv("USER_TOTP_ENC_KEY")); key != nil {
|
||||
totpHandler = auth.NewTOTPHandler(sqlDB, key, tm, rdb)
|
||||
} else if os.Getenv("USER_TOTP_ENC_KEY") != "" {
|
||||
log.Printf("USER_TOTP_ENC_KEY invalid (need 32 bytes or 64 hex chars); user TOTP disabled")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Codes ────────────────────────────────────────────────────────────────
|
||||
codesStore := codes.NewStore(sqlDB)
|
||||
codesSvc := codes.NewService(codesStore, rdb, 5, time.Hour)
|
||||
@@ -282,6 +295,9 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
v1.Post("/auth/login", authHandler.Login)
|
||||
v1.Post("/auth/refresh", authHandler.Refresh)
|
||||
v1.Post("/auth/logout", authHandler.Logout)
|
||||
if totpHandler != nil {
|
||||
v1.Post("/auth/login/totp", totpHandler.LoginTOTP)
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook: HMAC-authenticated, no JWT.
|
||||
@@ -299,6 +315,11 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
me.Post("/redeem", redeemHandler.ServeHTTP)
|
||||
me.Get("/subscription", subAPI.GetSubscription)
|
||||
me.Post("/subscription/reset", subAPI.ResetSubscription)
|
||||
if totpHandler != nil {
|
||||
me.Post("/totp/setup", totpHandler.Setup)
|
||||
me.Post("/totp/verify", totpHandler.Verify)
|
||||
me.Post("/totp/disable", totpHandler.Disable)
|
||||
}
|
||||
})
|
||||
protected.Post("/redeem", redeemHandler.ServeHTTP)
|
||||
protected.Get("/usage", usageHandler.ServeHTTP)
|
||||
@@ -409,6 +430,21 @@ func getenvDefault(key, def string) string {
|
||||
return def
|
||||
}
|
||||
|
||||
// parseTOTPKey returns a 32-byte AES key from USER_TOTP_ENC_KEY, accepting either
|
||||
// 64 hex chars or a raw 32-byte string. Returns nil when unset/invalid.
|
||||
func parseTOTPKey(raw string) []byte {
|
||||
if len(raw) == 64 {
|
||||
if b, err := hex.DecodeString(raw); err == nil && len(b) == 32 {
|
||||
return b
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(raw) == 32 {
|
||||
return []byte(raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func intEnvDefault(key string, def int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
|
||||
@@ -101,12 +101,24 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
pair, retryAfter, apiErr := h.svc.Login(r.Context(), req.Email, req.Password, clientIP(r))
|
||||
out, retryAfter, apiErr := h.svc.Login(r.Context(), req.Email, req.Password, clientIP(r))
|
||||
if apiErr != nil {
|
||||
writeAPIErr(w, apiErr, retryAfter)
|
||||
return
|
||||
}
|
||||
writeTokenPair(w, pair)
|
||||
// TOTP-enabled accounts get a pending token instead of tokens; the web
|
||||
// user-center then completes login at /auth/login/totp. The app (no TOTP)
|
||||
// always receives the flat token pair below.
|
||||
if out.PendingToken != "" {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"totp_required": true,
|
||||
"pending_token": out.PendingToken,
|
||||
})
|
||||
return
|
||||
}
|
||||
writeTokenPair(w, out.Tokens)
|
||||
}
|
||||
|
||||
// Refresh handles POST /v1/auth/refresh.
|
||||
|
||||
@@ -215,7 +215,21 @@ func (s *Service) verifyCode(ctx context.Context, email, code string) *apierr.Er
|
||||
|
||||
// Login authenticates email+password with constant-time behaviour and a
|
||||
// failure-count lock. retryAfter is non-zero only when the account is locked.
|
||||
func (s *Service) Login(ctx context.Context, rawEmail, password, ip string) (*TokenPair, time.Duration, *apierr.Error) {
|
||||
// LoginOutcome is the result of a password login: either issued tokens, or a
|
||||
// short-lived PendingToken when the account has TOTP enabled and must complete
|
||||
// the second step at /auth/login/totp.
|
||||
type LoginOutcome struct {
|
||||
Tokens *TokenPair
|
||||
PendingToken string
|
||||
}
|
||||
|
||||
// TOTP pending-login token (Redis): maps a one-time pending token → user id.
|
||||
const (
|
||||
totpPendingPrefix = "totp:pending:"
|
||||
totpPendingTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
func (s *Service) Login(ctx context.Context, rawEmail, password, ip string) (*LoginOutcome, time.Duration, *apierr.Error) {
|
||||
_ = ip // IP reserved for future per-IP login throttling; not logged.
|
||||
email := NormalizeEmail(rawEmail)
|
||||
if email == "" || password == "" {
|
||||
@@ -252,13 +266,27 @@ func (s *Service) Login(ctx context.Context, rawEmail, password, ip string) (*To
|
||||
return nil, 0, ErrAccountBanned
|
||||
}
|
||||
|
||||
// Success: clear the failure counter and issue tokens.
|
||||
// Success: clear the failure counter.
|
||||
_ = s.rl.ClearFailures(ctx, scopeLogin, email)
|
||||
|
||||
// Two-factor gate: when enabled, don't issue tokens yet — return a short-lived
|
||||
// pending token the client exchanges with a TOTP code at /auth/login/totp.
|
||||
if user.TOTPEnabled {
|
||||
pending, perr := newToken16()
|
||||
if perr != nil {
|
||||
return nil, 0, ErrInternal
|
||||
}
|
||||
if err := s.rdb.Set(ctx, totpPendingPrefix+pending, user.ID, totpPendingTTL).Err(); err != nil {
|
||||
return nil, 0, ErrInternal
|
||||
}
|
||||
return &LoginOutcome{PendingToken: pending}, 0, nil
|
||||
}
|
||||
|
||||
pair, err := s.tokens.Issue(ctx, user.ID, user.UUID)
|
||||
if err != nil {
|
||||
return nil, 0, ErrInternal
|
||||
}
|
||||
return pair, 0, nil
|
||||
return &LoginOutcome{Tokens: pair}, 0, nil
|
||||
}
|
||||
|
||||
// Logout revokes the given refresh token. Idempotent: an empty, unknown, or
|
||||
|
||||
@@ -13,12 +13,13 @@ import (
|
||||
|
||||
// User is the subset of the users row the auth module needs.
|
||||
type User struct {
|
||||
ID int64
|
||||
UUID string
|
||||
Email string
|
||||
PwHash string
|
||||
DpUUID string
|
||||
Status string // "active" | "banned"
|
||||
ID int64
|
||||
UUID string
|
||||
Email string
|
||||
PwHash string
|
||||
DpUUID string
|
||||
Status string // "active" | "banned"
|
||||
TOTPEnabled bool // two-factor enabled → login requires a second TOTP step
|
||||
}
|
||||
|
||||
// Sentinel store errors. Service maps these to API errors.
|
||||
@@ -112,8 +113,8 @@ func (s *SQLStore) CreateUserWithTrial(ctx context.Context, email, pwHash string
|
||||
func (s *SQLStore) GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||
var u User
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, uuid, email, pw_hash, dp_uuid, status FROM users WHERE email = ?`,
|
||||
email).Scan(&u.ID, &u.UUID, &u.Email, &u.PwHash, &u.DpUUID, &u.Status)
|
||||
`SELECT id, uuid, email, pw_hash, dp_uuid, status, totp_enabled FROM users WHERE email = ?`,
|
||||
email).Scan(&u.ID, &u.UUID, &u.Email, &u.PwHash, &u.DpUUID, &u.Status, &u.TOTPEnabled)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/apierr"
|
||||
"github.com/wangjia/pangolin/server/internal/totp"
|
||||
)
|
||||
|
||||
const totpIssuer = "Pangolin"
|
||||
|
||||
// errTOTPInvalid mirrors the web user-center's expected error code.
|
||||
var errTOTPInvalid = apierr.New("totp_invalid", "动态码不正确,请重试", "Invalid code, try again")
|
||||
|
||||
// TOTPHandler serves the user two-factor endpoints (/v1/me/totp/*) and the
|
||||
// second login step (/v1/auth/login/totp). Secrets are stored AES-256-GCM
|
||||
// encrypted under encKey; pending login tokens live in Redis.
|
||||
type TOTPHandler struct {
|
||||
db *sql.DB
|
||||
encKey []byte // 32 bytes (AES-256)
|
||||
tokens *TokenManager
|
||||
rdb *redis.Client
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewTOTPHandler wires the handler. encKey must be 32 bytes.
|
||||
func NewTOTPHandler(db *sql.DB, encKey []byte, tokens *TokenManager, rdb *redis.Client) *TOTPHandler {
|
||||
return &TOTPHandler{db: db, encKey: encKey, tokens: tokens, rdb: rdb, now: time.Now}
|
||||
}
|
||||
|
||||
type totpCodeRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// Setup handles POST /v1/me/totp/setup — generates and stores a secret (not yet
|
||||
// enabled) and returns it plus an otpauth:// URI for QR rendering.
|
||||
func (h *TOTPHandler) Setup(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
var email string
|
||||
if err := h.db.QueryRowContext(r.Context(),
|
||||
`SELECT email FROM users WHERE id = ? AND status = 'active'`, uid).Scan(&email); err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
secret, err := totp.GenerateSecret()
|
||||
if err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
enc, err := sealAES(h.encKey, secret)
|
||||
if err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
if _, err := h.db.ExecContext(r.Context(),
|
||||
`UPDATE users SET totp_secret_enc = ?, totp_enabled = FALSE WHERE id = ?`, enc, uid); err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"secret": secret,
|
||||
"otpauth_uri": totp.ProvisioningURI(secret, email, totpIssuer),
|
||||
})
|
||||
}
|
||||
|
||||
// Verify handles POST /v1/me/totp/verify {code} — enables TOTP after confirming
|
||||
// the user can produce a valid code for the secret created by Setup.
|
||||
func (h *TOTPHandler) Verify(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
var req totpCodeRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
secret, _, err := h.loadSecret(r.Context(), uid)
|
||||
if err != nil || secret == "" || !totp.Validate(secret, req.Code, h.now().UTC(), 1) {
|
||||
apierr.WriteJSON(w, http.StatusUnauthorized, errTOTPInvalid)
|
||||
return
|
||||
}
|
||||
if _, err := h.db.ExecContext(r.Context(),
|
||||
`UPDATE users SET totp_enabled = TRUE WHERE id = ?`, uid); err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Disable handles POST /v1/me/totp/disable {code} — turns off TOTP after a valid
|
||||
// code and clears the stored secret.
|
||||
func (h *TOTPHandler) Disable(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
var req totpCodeRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
secret, _, err := h.loadSecret(r.Context(), uid)
|
||||
if err != nil || secret == "" || !totp.Validate(secret, req.Code, h.now().UTC(), 1) {
|
||||
apierr.WriteJSON(w, http.StatusUnauthorized, errTOTPInvalid)
|
||||
return
|
||||
}
|
||||
if _, err := h.db.ExecContext(r.Context(),
|
||||
`UPDATE users SET totp_secret_enc = NULL, totp_enabled = FALSE WHERE id = ?`, uid); err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type loginTOTPRequest struct {
|
||||
PendingToken string `json:"pending_token"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// LoginTOTP handles POST /v1/auth/login/totp — second login step. Consumes the
|
||||
// one-time pending token from Login, validates the TOTP code, then issues tokens.
|
||||
func (h *TOTPHandler) LoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginTOTPRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.PendingToken == "" {
|
||||
apierr.WriteJSON(w, http.StatusUnauthorized, errTOTPInvalid)
|
||||
return
|
||||
}
|
||||
// One-time consume of the pending token.
|
||||
uidStr, err := h.rdb.GetDel(r.Context(), totpPendingPrefix+req.PendingToken).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
apierr.WriteJSON(w, http.StatusUnauthorized, errTOTPInvalid)
|
||||
return
|
||||
} else if err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
uid, err := strconv.ParseInt(uidStr, 10, 64)
|
||||
if err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
secret, enabled, err := h.loadSecret(r.Context(), uid)
|
||||
if err != nil || !enabled || secret == "" || !totp.Validate(secret, req.Code, h.now().UTC(), 1) {
|
||||
apierr.WriteJSON(w, http.StatusUnauthorized, errTOTPInvalid)
|
||||
return
|
||||
}
|
||||
|
||||
var uuid string
|
||||
if err := h.db.QueryRowContext(r.Context(),
|
||||
`SELECT uuid FROM users WHERE id = ? AND status = 'active'`, uid).Scan(&uuid); err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
pair, err := h.tokens.Issue(r.Context(), uid, uuid)
|
||||
if err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
writeTokenPair(w, pair)
|
||||
}
|
||||
|
||||
// loadSecret returns the user's decrypted TOTP secret and enabled flag. A NULL
|
||||
// secret yields ("", enabled, nil).
|
||||
func (h *TOTPHandler) loadSecret(ctx context.Context, uid int64) (string, bool, error) {
|
||||
var enc []byte
|
||||
var enabled bool
|
||||
if err := h.db.QueryRowContext(ctx,
|
||||
`SELECT totp_secret_enc, totp_enabled FROM users WHERE id = ?`, uid).Scan(&enc, &enabled); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if len(enc) == 0 {
|
||||
return "", enabled, nil
|
||||
}
|
||||
secret, err := openAES(h.encKey, enc)
|
||||
if err != nil {
|
||||
return "", enabled, err
|
||||
}
|
||||
return secret, enabled, nil
|
||||
}
|
||||
|
||||
// ── crypto / token helpers ───────────────────────────────────────────────────
|
||||
|
||||
// sealAES encrypts plaintext with AES-256-GCM under key (32 bytes); the nonce is
|
||||
// prepended to the ciphertext.
|
||||
func sealAES(key []byte, plaintext string) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gcm.Seal(nonce, nonce, []byte(plaintext), nil), nil
|
||||
}
|
||||
|
||||
// openAES reverses sealAES.
|
||||
func openAES(key, blob []byte) (string, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(blob) < gcm.NonceSize() {
|
||||
return "", errors.New("auth: totp ciphertext too short")
|
||||
}
|
||||
nonce, ct := blob[:gcm.NonceSize()], blob[gcm.NonceSize():]
|
||||
plaintext, err := gcm.Open(nil, nonce, ct, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// newToken16 returns a 32-char hex random token (used for TOTP pending logins).
|
||||
func newToken16() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
@@ -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