246 lines
9.1 KiB
Go
246 lines
9.1 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base32"
|
|
"encoding/json"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"vdi-cloud/platform/internal/store"
|
|
)
|
|
|
|
func TestTOTPRFC6238VectorsAndReplay(t *testing.T) {
|
|
secret := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte("12345678901234567890"))
|
|
for _, tc := range []struct {
|
|
unix int64
|
|
want string
|
|
}{{59, "94287082"}, {1111111109, "07081804"}, {1111111111, "14050471"}, {1234567890, "89005924"}, {2000000000, "69279037"}, {20000000000, "65353130"}} {
|
|
got, err := totpCode(secret, tc.unix/30, 8)
|
|
if err != nil || got != tc.want {
|
|
t.Fatal(tc.unix, got, err)
|
|
}
|
|
}
|
|
now := time.Unix(1234567890, 0)
|
|
for delta := int64(-2); delta <= 2; delta++ {
|
|
code, _ := totpCode(secret, now.Unix()/30+delta, 6)
|
|
counter, ok := matchTOTPCounter(secret, code, now, -1)
|
|
if ok != (delta >= -1 && delta <= 1) {
|
|
t.Fatal("wrong time window", delta)
|
|
}
|
|
if ok {
|
|
if _, replay := matchTOTPCounter(secret, code, now, counter); replay {
|
|
t.Fatal("replay accepted")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func setupTestMFA(t *testing.T, f *fixture, token, password string) (string, []string) {
|
|
t.Helper()
|
|
code, out := f.request("POST", "/auth/mfa/setup", token, map[string]any{"password": password}, "")
|
|
expect(t, 200, code, out)
|
|
secret := out["secret"].(string)
|
|
if !strings.HasPrefix(out["provisioningUri"].(string), "otpauth://totp/") {
|
|
t.Fatal("invalid provisioning URI")
|
|
}
|
|
value, _ := totpCode(secret, time.Now().Unix()/30, 6)
|
|
code, out = f.request("POST", "/auth/mfa/confirm", token, map[string]any{"code": value}, "")
|
|
expect(t, 200, code, out)
|
|
items := out["recoveryCodes"].([]any)
|
|
if len(items) != 8 {
|
|
t.Fatal("missing recovery codes")
|
|
}
|
|
codes := make([]string, len(items))
|
|
for i, v := range items {
|
|
codes[i] = v.(string)
|
|
}
|
|
return secret, codes
|
|
}
|
|
|
|
func TestMFALifecycleLoginRecoveryRotationAndDisable(t *testing.T) {
|
|
f := newFixtureWorker(t, false)
|
|
owner := f.login("admin")
|
|
id := createTestAccount(t, f, owner, "mfa-user", "viewer")
|
|
user := accountLogin(t, f, "mfa-user", testAccountPassword)
|
|
old := accountLogin(t, f, "mfa-user", testAccountPassword)
|
|
secret, codes := setupTestMFA(t, f, user, testAccountPassword)
|
|
code, out := f.request("GET", "/auth/me", old, nil, "")
|
|
expect(t, 401, code, out)
|
|
code, out = f.request("GET", "/auth/mfa", user, nil, "")
|
|
expect(t, 200, code, out)
|
|
if out["enabled"] != true || out["recoveryRemaining"] != float64(8) {
|
|
t.Fatal(out)
|
|
}
|
|
encoded, _ := json.Marshal(out)
|
|
if strings.Contains(string(encoded), secret) {
|
|
t.Fatal("status leaks secret")
|
|
}
|
|
var record mfaRecord
|
|
if err := f.db.Get(&record, "SELECT * FROM admin_mfa WHERE admin_id=$1", id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(record.Secret, secret) || record.Pending != "" {
|
|
t.Fatal("secret not encrypted or pending not cleared")
|
|
}
|
|
if _, err := f.server.cryptMFA("wrong-admin", record.Secret, false); err == nil {
|
|
t.Fatal("secret not bound to account")
|
|
}
|
|
code, out = f.request("POST", "/auth/login", "", map[string]any{"username": "mfa-user", "password": testAccountPassword}, "")
|
|
expect(t, 401, code, out)
|
|
if out["code"] != "mfa_required" || out["accessToken"] != nil {
|
|
t.Fatal(out)
|
|
}
|
|
// Failed password must not reveal the MFA requirement or consume a recovery code.
|
|
code, out = f.request("POST", "/auth/login", "", map[string]any{"username": "mfa-user", "password": "incorrect", "code": codes[0]}, "")
|
|
expect(t, 401, code, out)
|
|
if out["code"] != "invalid_credentials" {
|
|
t.Fatal(out)
|
|
}
|
|
code, out = f.request("POST", "/auth/login", "", map[string]any{"username": "mfa-user", "password": testAccountPassword, "code": strings.ToLower(codes[0])}, "")
|
|
expect(t, 200, code, out)
|
|
newToken := out["accessToken"].(string)
|
|
code, out = f.request("POST", "/auth/login", "", map[string]any{"username": "mfa-user", "password": testAccountPassword, "code": codes[0]}, "")
|
|
expect(t, 401, code, out)
|
|
// Rotate consumes one code, invalidates all old recovery codes and other sessions.
|
|
code, out = f.request("POST", "/auth/mfa/recovery", newToken, map[string]any{"password": testAccountPassword, "code": codes[1]}, "")
|
|
expect(t, 200, code, out)
|
|
newCodes := out["recoveryCodes"].([]any)
|
|
code, out = f.request("GET", "/auth/me", user, nil, "")
|
|
expect(t, 401, code, out)
|
|
code, out = f.request("DELETE", "/auth/mfa", newToken, map[string]any{"password": testAccountPassword, "code": codes[2]}, "")
|
|
expect(t, 400, code, out)
|
|
code, out = f.request("DELETE", "/auth/mfa", newToken, map[string]any{"password": testAccountPassword, "code": newCodes[0]}, "")
|
|
expect(t, 204, code, out)
|
|
code, out = f.request("GET", "/auth/mfa", newToken, nil, "")
|
|
expect(t, 200, code, out)
|
|
if out["enabled"] != false || out["recoveryRemaining"] != float64(0) {
|
|
t.Fatal(out)
|
|
}
|
|
_ = accountLogin(t, f, "mfa-user", testAccountPassword)
|
|
var actions []string
|
|
f.db.Select(&actions, "SELECT action FROM audit_logs WHERE actor='mfa-user' AND action IN ('enable_mfa','disable_mfa','rotate_mfa_recovery') ORDER BY created_at,id")
|
|
if len(actions) != 3 {
|
|
t.Fatal("MFA mutations not audited", actions)
|
|
}
|
|
}
|
|
|
|
func TestMFASetupExpirySessionBindingValidationAndLimits(t *testing.T) {
|
|
f := newFixtureWorker(t, false)
|
|
user := f.login("viewer")
|
|
other := f.login("viewer")
|
|
for _, path := range []string{"/auth/mfa", "/auth/mfa/setup", "/auth/mfa/confirm", "/auth/mfa/recovery"} {
|
|
method := "POST"
|
|
if path == "/auth/mfa" {
|
|
method = "GET"
|
|
}
|
|
code, out := f.request(method, path, "", map[string]any{}, "")
|
|
expect(t, 401, code, out)
|
|
}
|
|
code, out := f.request("POST", "/auth/mfa/setup", user, map[string]any{"password": "incorrect"}, "")
|
|
expect(t, 400, code, out)
|
|
code, out = f.request("POST", "/auth/mfa/setup", user, map[string]any{"password": "Demo@2026", "adminId": "other"}, "")
|
|
expect(t, 400, code, out)
|
|
code, out = f.request("POST", "/auth/mfa/setup", user, map[string]any{"password": "Demo@2026"}, "")
|
|
expect(t, 200, code, out)
|
|
secret := out["secret"].(string)
|
|
value, _ := totpCode(secret, time.Now().Unix()/30, 6)
|
|
code, out = f.request("POST", "/auth/mfa/confirm", other, map[string]any{"code": value}, "")
|
|
expect(t, 409, code, out)
|
|
f.db.MustExec("UPDATE admin_mfa SET pending_expires=now()-interval '1 second'")
|
|
code, out = f.request("POST", "/auth/mfa/confirm", user, map[string]any{"code": value}, "")
|
|
expect(t, 409, code, out)
|
|
code, out = f.request("GET", "/auth/mfa", user, nil, "")
|
|
expect(t, 200, code, out)
|
|
if out["enabled"] != false {
|
|
t.Fatal(out)
|
|
}
|
|
f.db.MustExec("TRUNCATE login_limits")
|
|
for i := 0; i < 11; i++ {
|
|
code, out = f.request("POST", "/auth/mfa/setup", user, map[string]any{"password": "incorrect"}, "")
|
|
want := 400
|
|
if i == 10 {
|
|
want = 429
|
|
}
|
|
expect(t, want, code, out)
|
|
}
|
|
// MFA attempt budget does not block ordinary login for an unbound account.
|
|
_ = f.login("viewer")
|
|
}
|
|
|
|
func TestMFAConcurrentCodeConsumptionAndTOTPLogin(t *testing.T) {
|
|
f := newFixtureWorker(t, false)
|
|
user := f.login("operator")
|
|
secret, codes := setupTestMFA(t, f, user, "Demo@2026")
|
|
var id string
|
|
f.db.Get(&id, "SELECT id FROM administrators WHERE username='operator'")
|
|
// Clear only the test row's counter to exercise a fresh current-time code without a 30-second sleep.
|
|
f.db.MustExec("UPDATE admin_mfa SET last_counter=-1 WHERE admin_id=$1", id)
|
|
value, _ := totpCode(secret, time.Now().Unix()/30, 6)
|
|
var wg sync.WaitGroup
|
|
results := make(chan int, 2)
|
|
for i := 0; i < 2; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
code, _ := f.request("POST", "/auth/login", "", map[string]any{"username": "operator", "password": "Demo@2026", "code": value}, "")
|
|
results <- code
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(results)
|
|
sum := 0
|
|
for code := range results {
|
|
sum += code
|
|
}
|
|
if sum != 601 {
|
|
t.Fatal("TOTP replay/concurrency", sum)
|
|
}
|
|
results = make(chan int, 2)
|
|
for i := 0; i < 2; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
code, _ := f.request("POST", "/auth/login", "", map[string]any{"username": "operator", "password": "Demo@2026", "code": codes[0]}, "")
|
|
results <- code
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(results)
|
|
sum = 0
|
|
for code := range results {
|
|
sum += code
|
|
}
|
|
if sum != 601 {
|
|
t.Fatal("recovery code replay/concurrency", sum)
|
|
}
|
|
}
|
|
|
|
func TestMFASuperAdminRestartAndPasswordChangePreserveSecondFactor(t *testing.T) {
|
|
f := newFixtureWorker(t, false)
|
|
owner := f.login("admin")
|
|
_, codes := setupTestMFA(t, f, owner, "Demo@2026")
|
|
db, err := store.Open(context.Background(), os.Getenv("VDI_TEST_DATABASE_URL"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.Close()
|
|
code, out := f.request("GET", "/auth/mfa", owner, nil, "")
|
|
expect(t, 200, code, out)
|
|
if out["enabled"] != true || out["recoveryRemaining"] != float64(8) {
|
|
t.Fatal("restart changed MFA", out)
|
|
}
|
|
code, out = f.request("POST", "/auth/password", owner, map[string]any{"currentPassword": "Demo@2026", "newPassword": "Changed-mfa-2026"}, "")
|
|
expect(t, 204, code, out)
|
|
code, out = f.request("POST", "/auth/login", "", map[string]any{"username": "admin", "password": "Changed-mfa-2026"}, "")
|
|
expect(t, 401, code, out)
|
|
if out["code"] != "mfa_required" {
|
|
t.Fatal("password change bypassed MFA", out)
|
|
}
|
|
code, out = f.request("POST", "/auth/login", "", map[string]any{"username": "admin", "password": "Changed-mfa-2026", "code": codes[0]}, "")
|
|
expect(t, 200, code, out)
|
|
}
|