- db/accounts.go: add StorePendingTOTP() which writes totp_secret_enc and totp_secret_nonce but leaves totp_required=0; add comment explaining two-phase flow - server.go (handleTOTPEnroll): switch from SetTOTP() to StorePendingTOTP() so the required flag is only set after the user confirms a valid TOTP code via handleTOTPConfirm, which still calls SetTOTP() - server_test.go: TestTOTPEnrollDoesNotRequireTOTP verifies that after POST /v1/auth/totp/enroll, TOTPRequired is false and the encrypted secret is present; confirms that a subsequent login without a TOTP code still succeeds (no lockout) - AUDIT.md: mark F-01 and F-11 as fixed Security: without this fix an admin who enrolls TOTP but abandons before confirmation is permanently locked out because totp_required=1 but no confirmed secret exists. StorePendingTOTP() keeps the secret pending until the user proves possession by confirming a valid code.
597 lines
18 KiB
Go
597 lines
18 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
|
"git.wntrmute.dev/kyle/mcias/internal/config"
|
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
|
)
|
|
|
|
const testIssuer = "https://auth.example.com"
|
|
|
|
func newTestServer(t *testing.T) (*Server, ed25519.PublicKey, ed25519.PrivateKey, *db.DB) {
|
|
t.Helper()
|
|
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generate key: %v", err)
|
|
}
|
|
|
|
database, err := db.Open(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
if err := db.Migrate(database); err != nil {
|
|
t.Fatalf("migrate db: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = database.Close() })
|
|
|
|
masterKey := make([]byte, 32)
|
|
if _, err := rand.Read(masterKey); err != nil {
|
|
t.Fatalf("generate master key: %v", err)
|
|
}
|
|
|
|
cfg := config.NewTestConfig(testIssuer)
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
srv := New(database, cfg, priv, pub, masterKey, logger)
|
|
return srv, pub, priv, database
|
|
}
|
|
|
|
// createTestHumanAccount creates a human account with password "testpass123".
|
|
func createTestHumanAccount(t *testing.T, srv *Server, username string) *model.Account {
|
|
t.Helper()
|
|
hash, err := auth.HashPassword("testpass123", auth.ArgonParams{Time: 3, Memory: 65536, Threads: 4})
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
acct, err := srv.db.CreateAccount(username, model.AccountTypeHuman, hash)
|
|
if err != nil {
|
|
t.Fatalf("create account: %v", err)
|
|
}
|
|
return acct
|
|
}
|
|
|
|
// issueAdminToken creates an account with admin role, issues a JWT, and tracks it.
|
|
func issueAdminToken(t *testing.T, srv *Server, priv ed25519.PrivateKey, username string) (string, *model.Account) {
|
|
t.Helper()
|
|
acct := createTestHumanAccount(t, srv, username)
|
|
if err := srv.db.GrantRole(acct.ID, "admin", nil); err != nil {
|
|
t.Fatalf("grant admin role: %v", err)
|
|
}
|
|
|
|
tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, []string{"admin"}, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("issue token: %v", err)
|
|
}
|
|
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
|
t.Fatalf("track token: %v", err)
|
|
}
|
|
return tokenStr, acct
|
|
}
|
|
|
|
func doRequest(t *testing.T, handler http.Handler, method, path string, body interface{}, authToken string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var bodyReader io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("marshal body: %v", err)
|
|
}
|
|
bodyReader = bytes.NewReader(b)
|
|
} else {
|
|
bodyReader = bytes.NewReader(nil)
|
|
}
|
|
|
|
req := httptest.NewRequest(method, path, bodyReader)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if authToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+authToken)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
func TestHealth(t *testing.T) {
|
|
srv, _, _, _ := newTestServer(t)
|
|
handler := srv.Handler()
|
|
|
|
rr := doRequest(t, handler, "GET", "/v1/health", nil, "")
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("health status = %d, want 200", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestPublicKey(t *testing.T) {
|
|
srv, _, _, _ := newTestServer(t)
|
|
handler := srv.Handler()
|
|
|
|
rr := doRequest(t, handler, "GET", "/v1/keys/public", nil, "")
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("public key status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
var jwk map[string]string
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &jwk); err != nil {
|
|
t.Fatalf("unmarshal JWK: %v", err)
|
|
}
|
|
if jwk["kty"] != "OKP" {
|
|
t.Errorf("kty = %q, want OKP", jwk["kty"])
|
|
}
|
|
if jwk["alg"] != "EdDSA" {
|
|
t.Errorf("alg = %q, want EdDSA", jwk["alg"])
|
|
}
|
|
}
|
|
|
|
func TestLoginSuccess(t *testing.T) {
|
|
srv, _, _, _ := newTestServer(t)
|
|
createTestHumanAccount(t, srv, "alice")
|
|
handler := srv.Handler()
|
|
|
|
rr := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{
|
|
"username": "alice",
|
|
"password": "testpass123",
|
|
}, "")
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("login status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp loginResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal login response: %v", err)
|
|
}
|
|
if resp.Token == "" {
|
|
t.Error("expected non-empty token in login response")
|
|
}
|
|
if resp.ExpiresAt == "" {
|
|
t.Error("expected non-empty expires_at in login response")
|
|
}
|
|
}
|
|
|
|
func TestLoginWrongPassword(t *testing.T) {
|
|
srv, _, _, _ := newTestServer(t)
|
|
createTestHumanAccount(t, srv, "bob")
|
|
handler := srv.Handler()
|
|
|
|
rr := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{
|
|
"username": "bob",
|
|
"password": "wrongpassword",
|
|
}, "")
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestLoginUnknownUser(t *testing.T) {
|
|
srv, _, _, _ := newTestServer(t)
|
|
handler := srv.Handler()
|
|
|
|
rr := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{
|
|
"username": "nobody",
|
|
"password": "password",
|
|
}, "")
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestLoginResponseDoesNotContainCredentials(t *testing.T) {
|
|
srv, _, _, _ := newTestServer(t)
|
|
createTestHumanAccount(t, srv, "charlie")
|
|
handler := srv.Handler()
|
|
|
|
rr := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{
|
|
"username": "charlie",
|
|
"password": "testpass123",
|
|
}, "")
|
|
|
|
body := rr.Body.String()
|
|
// Security: password hash must never appear in any API response.
|
|
if strings.Contains(body, "argon2id") || strings.Contains(body, "password_hash") {
|
|
t.Error("login response contains password hash material")
|
|
}
|
|
}
|
|
|
|
func TestTokenValidate(t *testing.T) {
|
|
srv, _, priv, _ := newTestServer(t)
|
|
acct := createTestHumanAccount(t, srv, "dave")
|
|
handler := srv.Handler()
|
|
|
|
// Issue and track a token.
|
|
tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("IssueToken: %v", err)
|
|
}
|
|
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
|
t.Fatalf("TrackToken: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("POST", "/v1/token/validate", nil)
|
|
req.Header.Set("Authorization", "Bearer "+tokenStr)
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("validate status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
var resp validateResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if !resp.Valid {
|
|
t.Error("expected valid=true for valid token")
|
|
}
|
|
}
|
|
|
|
func TestLogout(t *testing.T) {
|
|
srv, _, priv, _ := newTestServer(t)
|
|
acct := createTestHumanAccount(t, srv, "eve")
|
|
handler := srv.Handler()
|
|
|
|
tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("IssueToken: %v", err)
|
|
}
|
|
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
|
t.Fatalf("TrackToken: %v", err)
|
|
}
|
|
|
|
// Logout.
|
|
rr := doRequest(t, handler, "POST", "/v1/auth/logout", nil, tokenStr)
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Errorf("logout status = %d, want 204; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// Token should now be invalid on validate.
|
|
req := httptest.NewRequest("POST", "/v1/token/validate", nil)
|
|
req.Header.Set("Authorization", "Bearer "+tokenStr)
|
|
rr2 := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr2, req)
|
|
|
|
var resp validateResponse
|
|
_ = json.Unmarshal(rr2.Body.Bytes(), &resp)
|
|
if resp.Valid {
|
|
t.Error("expected valid=false after logout")
|
|
}
|
|
}
|
|
|
|
func TestCreateAccountAdmin(t *testing.T) {
|
|
srv, _, priv, _ := newTestServer(t)
|
|
adminToken, _ := issueAdminToken(t, srv, priv, "admin-user")
|
|
handler := srv.Handler()
|
|
|
|
rr := doRequest(t, handler, "POST", "/v1/accounts", map[string]string{
|
|
"username": "new-user",
|
|
"password": "newpassword123",
|
|
"account_type": "human",
|
|
}, adminToken)
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
t.Errorf("create account status = %d, want 201; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp accountResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if resp.Username != "new-user" {
|
|
t.Errorf("Username = %q, want %q", resp.Username, "new-user")
|
|
}
|
|
// Security: password hash must not appear in account response.
|
|
body := rr.Body.String()
|
|
if strings.Contains(body, "password_hash") || strings.Contains(body, "argon2id") {
|
|
t.Error("account creation response contains password hash")
|
|
}
|
|
}
|
|
|
|
func TestCreateAccountRequiresAdmin(t *testing.T) {
|
|
srv, _, priv, _ := newTestServer(t)
|
|
acct := createTestHumanAccount(t, srv, "regular-user")
|
|
tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, []string{"reader"}, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("IssueToken: %v", err)
|
|
}
|
|
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
|
t.Fatalf("TrackToken: %v", err)
|
|
}
|
|
handler := srv.Handler()
|
|
|
|
rr := doRequest(t, handler, "POST", "/v1/accounts", map[string]string{
|
|
"username": "other-user",
|
|
"password": "password",
|
|
"account_type": "human",
|
|
}, tokenStr)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestListAccounts(t *testing.T) {
|
|
srv, _, priv, _ := newTestServer(t)
|
|
adminToken, _ := issueAdminToken(t, srv, priv, "admin2")
|
|
createTestHumanAccount(t, srv, "user1")
|
|
createTestHumanAccount(t, srv, "user2")
|
|
handler := srv.Handler()
|
|
|
|
rr := doRequest(t, handler, "GET", "/v1/accounts", nil, adminToken)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("list accounts status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
var accounts []accountResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &accounts); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if len(accounts) < 3 { // admin + user1 + user2
|
|
t.Errorf("expected at least 3 accounts, got %d", len(accounts))
|
|
}
|
|
|
|
// Security: no credential fields in any response.
|
|
body := rr.Body.String()
|
|
for _, bad := range []string{"password_hash", "argon2id", "totp_secret", "PasswordHash"} {
|
|
if strings.Contains(body, bad) {
|
|
t.Errorf("account list response contains credential field %q", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDeleteAccount(t *testing.T) {
|
|
srv, _, priv, _ := newTestServer(t)
|
|
adminToken, _ := issueAdminToken(t, srv, priv, "admin3")
|
|
target := createTestHumanAccount(t, srv, "delete-me")
|
|
handler := srv.Handler()
|
|
|
|
rr := doRequest(t, handler, "DELETE", "/v1/accounts/"+target.UUID, nil, adminToken)
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Errorf("delete status = %d, want 204; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSetAndGetRoles(t *testing.T) {
|
|
srv, _, priv, _ := newTestServer(t)
|
|
adminToken, _ := issueAdminToken(t, srv, priv, "admin4")
|
|
target := createTestHumanAccount(t, srv, "role-target")
|
|
handler := srv.Handler()
|
|
|
|
// Set roles.
|
|
rr := doRequest(t, handler, "PUT", "/v1/accounts/"+target.UUID+"/roles", map[string][]string{
|
|
"roles": {"reader", "writer"},
|
|
}, adminToken)
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Errorf("set roles status = %d, want 204; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// Get roles.
|
|
rr2 := doRequest(t, handler, "GET", "/v1/accounts/"+target.UUID+"/roles", nil, adminToken)
|
|
if rr2.Code != http.StatusOK {
|
|
t.Errorf("get roles status = %d, want 200", rr2.Code)
|
|
}
|
|
|
|
var resp rolesResponse
|
|
if err := json.Unmarshal(rr2.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if len(resp.Roles) != 2 {
|
|
t.Errorf("expected 2 roles, got %d", len(resp.Roles))
|
|
}
|
|
}
|
|
|
|
func TestLoginRateLimited(t *testing.T) {
|
|
srv, _, _, _ := newTestServer(t)
|
|
handler := srv.Handler()
|
|
|
|
// The login endpoint uses RateLimit(10, 10): burst of 10 requests.
|
|
// We send all burst+1 requests concurrently so they all hit the rate
|
|
// limiter before any Argon2id hash can complete. This is necessary because
|
|
// Argon2id takes ~500ms per request; sequential requests refill the
|
|
// token bucket faster than they drain it at 10 req/s.
|
|
const burst = 10
|
|
bodyJSON := []byte(`{"username":"nobody","password":"wrong"}`)
|
|
|
|
type result struct {
|
|
hdr http.Header
|
|
code int
|
|
}
|
|
results := make([]result, burst+1)
|
|
var wg sync.WaitGroup
|
|
for i := range burst + 1 {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
req := httptest.NewRequest("POST", "/v1/auth/login",
|
|
bytes.NewReader(bodyJSON))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.RemoteAddr = "10.1.1.1:9999" // same IP for all
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
results[idx] = result{code: rr.Code, hdr: rr.Result().Header}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
// At least one of the burst+1 concurrent requests must have been
|
|
// rate-limited (429). Which one is non-deterministic.
|
|
var got429 bool
|
|
var retryAfterSet bool
|
|
for _, r := range results {
|
|
if r.code == http.StatusTooManyRequests {
|
|
got429 = true
|
|
retryAfterSet = r.hdr.Get("Retry-After") != ""
|
|
break
|
|
}
|
|
}
|
|
if !got429 {
|
|
t.Error("expected at least one 429 after burst+1 concurrent login requests")
|
|
}
|
|
if !retryAfterSet {
|
|
t.Error("expected Retry-After header on 429 response")
|
|
}
|
|
}
|
|
|
|
func TestTokenValidateRateLimited(t *testing.T) {
|
|
srv, _, _, _ := newTestServer(t)
|
|
handler := srv.Handler()
|
|
|
|
// The token/validate endpoint shares the same per-IP rate limiter as login.
|
|
// Use a distinct RemoteAddr so we get a fresh bucket.
|
|
body := map[string]string{"token": "not.a.valid.token"}
|
|
|
|
for i := range 10 {
|
|
b, _ := json.Marshal(body)
|
|
req := httptest.NewRequest("POST", "/v1/token/validate", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.RemoteAddr = "10.99.99.1:12345"
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
if rr.Code == http.StatusTooManyRequests {
|
|
t.Fatalf("request %d was rate-limited prematurely", i+1)
|
|
}
|
|
}
|
|
|
|
// 11th request should be rate-limited.
|
|
b, _ := json.Marshal(body)
|
|
req := httptest.NewRequest("POST", "/v1/token/validate", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.RemoteAddr = "10.99.99.1:12345"
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusTooManyRequests {
|
|
t.Errorf("expected 429 after exhausting burst, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestHealthNotRateLimited(t *testing.T) {
|
|
srv, _, _, _ := newTestServer(t)
|
|
handler := srv.Handler()
|
|
|
|
// Health endpoint should not be rate-limited — send 20 rapid requests.
|
|
for i := range 20 {
|
|
req := httptest.NewRequest("GET", "/v1/health", nil)
|
|
req.RemoteAddr = "10.88.88.1:12345"
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("health request %d: status = %d, want 200", i+1, rr.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTOTPEnrollDoesNotRequireTOTP verifies that initiating TOTP enrollment
|
|
// (POST /v1/auth/totp/enroll) stores the pending secret without setting
|
|
// totp_required=1. A user who starts but does not complete enrollment must
|
|
// still be able to log in with password alone — no lockout.
|
|
//
|
|
// Security: this guards against F-01 (enrollment sets the flag prematurely),
|
|
// which would let an attacker initiate enrollment for a victim account and
|
|
// then prevent that account from authenticating.
|
|
func TestTOTPEnrollDoesNotRequireTOTP(t *testing.T) {
|
|
srv, _, priv, _ := newTestServer(t)
|
|
acct := createTestHumanAccount(t, srv, "totp-enroll-user")
|
|
handler := srv.Handler()
|
|
|
|
// Issue a token for this user.
|
|
tokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("IssueToken: %v", err)
|
|
}
|
|
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
|
t.Fatalf("TrackToken: %v", err)
|
|
}
|
|
|
|
// Start enrollment.
|
|
rr := doRequest(t, handler, "POST", "/v1/auth/totp/enroll", nil, tokenStr)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("enroll status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var enrollResp totpEnrollResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &enrollResp); err != nil {
|
|
t.Fatalf("unmarshal enroll response: %v", err)
|
|
}
|
|
if enrollResp.Secret == "" {
|
|
t.Error("expected non-empty TOTP secret in enrollment response")
|
|
}
|
|
|
|
// Security: totp_required must still be false after enrollment start.
|
|
// If it were true the user would be locked out until they confirm.
|
|
freshAcct, err := srv.db.GetAccountByUUID(acct.UUID)
|
|
if err != nil {
|
|
t.Fatalf("GetAccountByUUID: %v", err)
|
|
}
|
|
if freshAcct.TOTPRequired {
|
|
t.Error("totp_required = true after enroll — lockout risk (F-01)")
|
|
}
|
|
// The pending secret should be stored (needed for confirm).
|
|
if freshAcct.TOTPSecretEnc == nil {
|
|
t.Error("totp_secret_enc is nil after enroll — confirm would fail")
|
|
}
|
|
|
|
// Login without TOTP code must still succeed (enrollment not confirmed).
|
|
rr2 := doRequest(t, handler, "POST", "/v1/auth/login", map[string]string{
|
|
"username": "totp-enroll-user",
|
|
"password": "testpass123",
|
|
}, "")
|
|
if rr2.Code != http.StatusOK {
|
|
t.Errorf("login without TOTP after incomplete enrollment: status = %d, want 200; body: %s",
|
|
rr2.Code, rr2.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRenewToken(t *testing.T) {
|
|
srv, _, priv, _ := newTestServer(t)
|
|
acct := createTestHumanAccount(t, srv, "renew-user")
|
|
handler := srv.Handler()
|
|
|
|
oldTokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("IssueToken: %v", err)
|
|
}
|
|
oldJTI := claims.JTI
|
|
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
|
t.Fatalf("TrackToken: %v", err)
|
|
}
|
|
|
|
rr := doRequest(t, handler, "POST", "/v1/auth/renew", nil, oldTokenStr)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("renew status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp loginResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal renew response: %v", err)
|
|
}
|
|
if resp.Token == "" || resp.Token == oldTokenStr {
|
|
t.Error("expected new, distinct token after renewal")
|
|
}
|
|
|
|
// Old token should be revoked in the database.
|
|
rec, err := srv.db.GetTokenRecord(oldJTI)
|
|
if err != nil {
|
|
t.Fatalf("GetTokenRecord: %v", err)
|
|
}
|
|
if !rec.IsRevoked() {
|
|
t.Error("old token should be revoked after renewal")
|
|
}
|
|
}
|