NextMyDay/internal/core/server/enroll_login_test.go
Claus Lohmar d11e14295b feat(core): add the store, identity plumbing and the door (SPEC-003/004)
- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded
  migration runner; each module's migrations are name-prefixed (D-012)
- audit: the append-only audit log; the actor threads via context (R-1)
- users: the user_id/GUID/email anchors (D-013)
- config: the YAML settings file (D-035) + validation
- httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security
  headers · redaction
- middleware: correlation id · audit · rate limiting
- mail: the invite/recovery mailer seam
- server: the core DOOR — the resolution switch, the dispatch registry
  (RegisterModule), the role gates, /setup · enroll/login · recovery flows,
  the reserved `static` workflow, and the loopback-only bootstrap (D-025)
2026-10-14 10:53:51 +00:00

488 lines
18 KiB
Go

package server_test
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
pqotp "github.com/pquerna/otp/totp"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/flow"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/webauthn"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
// ── the WebAuthn fake (the ceremony is browser-bound) ────────────────────────
type fakeWebAuthn struct {
beginReg, finishReg int
beginLogin, finishLogin int
beginErr, finishErr error
}
func (f *fakeWebAuthn) BeginRegistration(context.Context, webauthn.User, []webauthn.Credential) (*webauthn.Begin, error) {
f.beginReg++
if f.beginErr != nil {
return nil, f.beginErr
}
return fakeBegin("reg-challenge"), nil
}
func (f *fakeWebAuthn) FinishRegistration(context.Context, webauthn.User, *webauthn.Begin, *http.Request) (webauthn.Credential, error) {
f.finishReg++
if f.finishErr != nil {
return webauthn.Credential{}, f.finishErr
}
return webauthn.Credential{
ID: []byte("passkey-1"), PublicKey: []byte("cose-pubkey-1"),
SignCount: 1, Transports: []string{"internal"}, AAGUID: []byte{1, 2, 3, 4},
}, nil
}
func (f *fakeWebAuthn) BeginLogin(context.Context, webauthn.User, []webauthn.Credential) (*webauthn.Begin, error) {
f.beginLogin++
if f.beginErr != nil {
return nil, f.beginErr
}
return fakeBegin("login-challenge"), nil
}
func (f *fakeWebAuthn) FinishLogin(context.Context, webauthn.User, *webauthn.Begin, *http.Request, []webauthn.Credential) (webauthn.Credential, error) {
f.finishLogin++
if f.finishErr != nil {
return webauthn.Credential{}, f.finishErr
}
return webauthn.Credential{
ID: []byte("passkey-1"), PublicKey: []byte("cose-pubkey-1"),
SignCount: 2, Transports: []string{"internal"}, AAGUID: []byte{1, 2, 3, 4},
}, nil
}
func fakeBegin(challenge string) *webauthn.Begin {
opts, _ := json.Marshal(map[string]any{
"publicKey": map[string]any{"challenge": challenge},
})
sd, _ := json.Marshal(map[string]any{
"challenge": challenge,
"expires": time.Now().Add(5 * time.Minute).UTC().Format(time.RFC3339),
})
return &webauthn.Begin{Options: opts, SessionData: sd}
}
// ── harness helpers ──────────────────────────────────────────────────────────
// pendingUser creates a team user under the admin and mints a pending identity.
func (h *harness) pendingUser(t *testing.T, email string) (userID, identityID int64, guid string) {
t.Helper()
userID, err := h.users.Create(h.ctx, h.adminID, email, "Pending", users.RoleTeam)
if err != nil {
t.Fatalf("create user: %v", err)
}
identityID, guid, err = h.ids.Mint(h.ctx, userID)
if err != nil {
t.Fatalf("Mint: %v", err)
}
return userID, identityID, guid
}
// action issues an anonymous flow action with a fresh pre-session CSRF token.
func (h *harness) action(action, guid string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
h.t.Helper()
cookie, token := h.preCSRF()
if form == nil {
form = url.Values{}
}
form.Set("csrf", token)
req := httptest.NewRequest("POST", "/"+guid+"?do=auth&action="+url.QueryEscape(action),
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h.srv.ServeHTTP(rec, req)
return rec
}
// sessionAction issues a session-gated action (logout) with the session-bound
// CSRF token.
func (h *harness) sessionAction(action, guid, sessionID string, extra ...*http.Cookie) *httptest.ResponseRecorder {
h.t.Helper()
form := url.Values{"csrf": {h.csrf.Token(sessionID)}}
req := httptest.NewRequest("POST", "/"+guid+"?do=auth&action="+url.QueryEscape(action),
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: httpx.SessionCookieName, Value: sessionID})
for _, c := range extra {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h.srv.ServeHTTP(rec, req)
return rec
}
// ceremonyCookie extracts the signed WebAuthn ceremony cookie from a response.
func ceremonyCookie(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie {
t.Helper()
for _, c := range rec.Result().Cookies() {
if c.Name == flow.CeremonyCookieName && c.Value != "" {
return c
}
}
t.Fatalf("response carried no %s cookie", flow.CeremonyCookieName)
return nil
}
// sessionCookie extracts the session cookie value from a response.
func sessionCookie(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
for _, c := range rec.Result().Cookies() {
if c.Name == httpx.SessionCookieName && c.Value != "" {
return c.Value
}
}
t.Fatalf("response carried no session cookie (status %d)", rec.Code)
return ""
}
// enrollFully runs the whole two-factor enrollment and returns the session id.
func (h *harness) enrollFully(t *testing.T, identityID int64, guid string) string {
t.Helper()
// Factor 1 — passkey.
begin := h.action("enroll.begin", guid, nil)
if begin.Code != http.StatusOK {
t.Fatalf("enroll.begin = %d, want 200", begin.Code)
}
finish := h.action("enroll.finish", guid, nil, ceremonyCookie(t, begin))
if finish.Code != http.StatusOK {
t.Fatalf("enroll.finish = %d, want 200", finish.Code)
}
// Factor 2 — TOTP.
if rec := h.action("totp.begin", guid, nil); rec.Code != http.StatusOK {
t.Fatalf("totp.begin = %d, want 200", rec.Code)
}
secret, err := h.ids.TOTPSecret(h.ctx, identityID)
if err != nil {
t.Fatalf("TOTPSecret: %v", err)
}
code, err := pqotp.GenerateCode(string(secret), time.Now())
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
done := h.action("totp.verify", guid, url.Values{"code": {code}})
if done.Code != http.StatusSeeOther {
t.Fatalf("totp.verify = %d, want 303 (enrollment complete)", done.Code)
}
return sessionCookie(t, done)
}
// ── AC-1 ─────────────────────────────────────────────────────────────────────
func TestSpec006AC1_PendingRoutesToEnrollNotLogin(t *testing.T) {
h := newHarness(t)
_, _, guid := h.pendingUser(t, "ac1@example.com")
rec := request(h.srv, "GET", "/"+guid)
if rec.Code != http.StatusOK {
t.Fatalf("GET /{pending-guid} = %d, want 200 (enroll)", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Enrollment") {
t.Error("a pending GUID did not render the enrollment screen (AC-1)")
}
if strings.Contains(body, "Sign in") {
t.Error("a pending GUID rendered the login screen (AC-1)")
}
// The anonymous screen issues the pre-session CSRF cookie (A15).
if !hasCookie(rec, httpx.PreSessionCookieName) {
t.Error("the enrollment screen issued no pre-session CSRF cookie (A15)")
}
}
// ── AC-2 ─────────────────────────────────────────────────────────────────────
func TestSpec006AC2_EnrollmentRequiresBothFactors(t *testing.T) {
h := newHarness(t)
_, id, guid := h.pendingUser(t, "ac2@example.com")
// TOTP first, no passkey yet -> refused; identity stays pending.
if rec := h.action("totp.begin", guid, nil); rec.Code != http.StatusOK {
t.Fatalf("totp.begin = %d, want 200", rec.Code)
}
secret, err := h.ids.TOTPSecret(h.ctx, id)
if err != nil {
t.Fatalf("TOTPSecret: %v", err)
}
code, err := pqotp.GenerateCode(string(secret), time.Now())
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
rec := h.action("totp.verify", guid, url.Values{"code": {code}})
if rec.Code != http.StatusBadRequest {
t.Errorf("totp.verify without a passkey = %d, want 400 (AC-2)", rec.Code)
}
if hasCookie(rec, httpx.SessionCookieName) {
t.Error("a session was minted without both factors (AC-2)")
}
if idn, err := h.ids.ByGUID(h.ctx, guid); err != nil || idn.Status != auth.StatusPendingEnrollment {
t.Errorf("identity after one factor = %+v, want pending_enrollment (AC-2)", idn)
}
// Passkey, no verified TOTP -> still pending, still no session.
begin := h.action("enroll.begin", guid, nil)
if begin.Code != http.StatusOK {
t.Fatalf("enroll.begin = %d, want 200", begin.Code)
}
finish := h.action("enroll.finish", guid, nil, ceremonyCookie(t, begin))
if finish.Code != http.StatusOK {
t.Fatalf("enroll.finish = %d, want 200", finish.Code)
}
if hasCookie(finish, httpx.SessionCookieName) {
t.Error("enroll.finish minted a session before TOTP (AC-2)")
}
if idn, err := h.ids.ByGUID(h.ctx, guid); err != nil || idn.Status != auth.StatusPendingEnrollment {
t.Errorf("identity after passkey only = %+v, want pending_enrollment (AC-2)", idn)
}
// Now both -> active + session.
code, err = pqotp.GenerateCode(string(secret), time.Now())
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
rec = h.action("totp.verify", guid, url.Values{"code": {code}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("totp.verify with both factors = %d, want 303 (AC-2)", rec.Code)
}
if !hasCookie(rec, httpx.SessionCookieName) {
t.Error("no session minted after both factors (AC-2)")
}
idn, err := h.ids.ByGUID(h.ctx, guid)
if err != nil {
t.Fatalf("ByGUID: %v", err)
}
if idn.Status != auth.StatusActive {
t.Errorf("identity after both factors = %s, want active (AC-2)", idn.Status)
}
}
// ── AC-3 ─────────────────────────────────────────────────────────────────────
func TestSpec006AC3_ActiveRoutesToLoginThenApp(t *testing.T) {
h := newHarness(t) // the admin is active
rec := request(h.srv, "GET", "/"+h.guid)
if rec.Code != http.StatusOK {
t.Errorf("GET /{active-guid} without a session = %d, want 200 (login screen, F-1)", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Sign in") {
t.Error("an active GUID without a session did not render the login screen (AC-3)")
}
rec = request(h.srv, "GET", "/"+h.guid, h.cookie())
if rec.Code != http.StatusOK {
t.Fatalf("GET /{active-guid} with a session = %d, want 200 (the app)", rec.Code)
}
if !strings.Contains(rec.Body.String(), "MyDay") {
t.Error("a valid session did not reach the app (AC-3)")
}
}
// ── AC-4 ─────────────────────────────────────────────────────────────────────
func TestSpec006AC4_WindowExpiryBlocksEnrollment(t *testing.T) {
h := newHarness(t)
_, id, guid := h.pendingUser(t, "ac4@example.com")
// Age the identity beyond the bounded window (A1).
past := store.FormatTime(time.Now().Add(-100 * time.Hour))
if _, err := h.db.Exec(`UPDATE identities SET created_at = ? WHERE identity_id = ?`, past, id); err != nil {
t.Fatalf("age identity: %v", err)
}
if rec := request(h.srv, "GET", "/"+guid); rec.Code != http.StatusNotFound {
t.Errorf("GET /{expired-guid} = %d, want 404 (A1)", rec.Code)
}
if rec := h.action("enroll.begin", guid, nil); rec.Code != http.StatusNotFound {
t.Errorf("enroll.begin past the window = %d, want 404 (A1)", rec.Code)
}
if rec := h.action("totp.begin", guid, nil); rec.Code != http.StatusNotFound {
t.Errorf("totp.begin past the window = %d, want 404 (A1)", rec.Code)
}
}
// ── AC-5 ─────────────────────────────────────────────────────────────────────
func TestSpec006AC5_LoginRotatesAndLogoutRevokes(t *testing.T) {
h := newHarness(t)
_, id, guid := h.pendingUser(t, "ac5@example.com")
s1 := h.enrollFully(t, id, guid)
if _, err := h.sessions.Validate(h.ctx, s1); err != nil {
t.Fatalf("the enrollment session is invalid: %v", err)
}
// A passkey login while S1 is live rotates it (fixation defence).
begin := h.action("login.begin", guid, nil, &http.Cookie{Name: httpx.SessionCookieName, Value: s1})
if begin.Code != http.StatusOK {
t.Fatalf("login.begin = %d, want 200", begin.Code)
}
finish := h.action("login.finish", guid, nil,
&http.Cookie{Name: httpx.SessionCookieName, Value: s1}, ceremonyCookie(t, begin))
if finish.Code != http.StatusSeeOther {
t.Fatalf("login.finish = %d, want 303", finish.Code)
}
s2 := sessionCookie(t, finish)
if s2 == s1 {
t.Error("login did not rotate the session id (AC-5)")
}
if _, err := h.sessions.Validate(h.ctx, s1); err == nil {
t.Error("the pre-login session survived rotation (AC-5)")
}
if _, err := h.sessions.Validate(h.ctx, s2); err != nil {
t.Errorf("the rotated session is invalid: %v", err)
}
// Logout revokes S2.
lo := h.sessionAction("logout", guid, s2)
if lo.Code != http.StatusSeeOther {
t.Fatalf("logout = %d, want 303", lo.Code)
}
if _, err := h.sessions.Validate(h.ctx, s2); err == nil {
t.Error("logout did not revoke the session (AC-5)")
}
}
// ── AC-6 ─────────────────────────────────────────────────────────────────────
func TestSpec006AC6_TOTPSecretEncryptedAndNeverLogged(t *testing.T) {
var logs bytes.Buffer
h := newHarness(t, func(d *server.Deps) {
d.Logger = slog.New(slog.NewTextHandler(&logs, nil))
})
_, id, guid := h.pendingUser(t, "ac6@example.com")
rec := h.action("totp.begin", guid, nil)
if rec.Code != http.StatusOK {
t.Fatalf("totp.begin = %d, want 200", rec.Code)
}
secret, err := h.ids.TOTPSecret(h.ctx, id)
if err != nil {
t.Fatalf("TOTPSecret: %v", err)
}
if len(secret) == 0 {
t.Fatal("the issued secret is empty")
}
// 1) The stored blob is ciphertext, not the secret (A9).
var raw []byte
if err := h.db.QueryRow(`SELECT secret_encrypted FROM totp_secrets WHERE identity_id = ?`, id).Scan(&raw); err != nil {
t.Fatalf("read secret_encrypted: %v", err)
}
if bytes.Contains(raw, secret) {
t.Error("AC-6: secret_encrypted holds the plaintext secret (A9)")
}
// 2) The otpauth URL is shown to the user (the one display) — but the
// secret never reached the log (A9).
if !strings.Contains(rec.Body.String(), "otpauth://") {
t.Error("totp.begin did not return an otpauth URL")
}
if strings.Contains(logs.String(), string(secret)) {
t.Error("AC-6: the TOTP secret reached the log (A9)")
}
}
// ── AC-7 ─────────────────────────────────────────────────────────────────────
func TestSpec006AC7_RateLimitAndGenericErrors(t *testing.T) {
// Rate limit (A3): the enroll/login routes return 429 past the limit.
h := newHarness(t, func(d *server.Deps) {
d.RateLimit = 1
d.RateWindow = time.Minute
})
_, _, guid := h.pendingUser(t, "ac7@example.com")
if rec := request(h.srv, "GET", "/"+guid); rec.Code != http.StatusOK {
t.Fatalf("first request = %d, want 200", rec.Code)
}
rec := request(h.srv, "GET", "/"+guid)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("second request = %d, want 429 (A3/AC-7)", rec.Code)
}
if rec.Header().Get("Retry-After") == "" {
t.Error("429 is missing a Retry-After header")
}
// Generic error text: failures never reveal which factor or why.
h2 := newHarness(t)
_, id2, guid2 := h2.pendingUser(t, "ac7b@example.com")
h2.enrollFully(t, id2, guid2)
rec = h2.action("totp.verify", guid2, url.Values{"code": {"000000"}})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("bad TOTP code = %d, want 401", rec.Code)
}
if got := strings.TrimSpace(rec.Body.String()); got != "unauthorized" {
t.Errorf("failed login body = %q, want a generic %q", got, "unauthorized")
}
// A missing/forged pre-session token is rejected generically.
req := httptest.NewRequest("POST", "/"+guid2+"?do=auth&action=enroll.begin", nil)
req.AddCookie(&http.Cookie{Name: httpx.PreSessionCookieName, Value: "forged"})
rec2 := httptest.NewRecorder()
h2.srv.ServeHTTP(rec2, req)
if rec2.Code != http.StatusForbidden {
t.Errorf("forged pre-session token = %d, want 403 (A15)", rec2.Code)
}
if got := strings.TrimSpace(rec2.Body.String()); got != "forbidden" {
t.Errorf("CSRF failure body = %q, want a generic %q", got, "forbidden")
}
}
// TestF2_NoStoreOnEnrollAndTOTPResponses: the enrollment screen and the TOTP
// issue response (which embeds the otpauth URL — the secret) are never cached.
func TestF2_NoStoreOnEnrollAndTOTPResponses(t *testing.T) {
h := newHarness(t)
_, _, guid := h.pendingUser(t, "f2@example.com")
rec := request(h.srv, "GET", "/"+guid)
if rec.Code != http.StatusOK {
t.Fatalf("enrollment screen = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("enrollment screen Cache-Control = %q, want no-store (F-2)", got)
}
rec = h.action("totp.begin", guid, nil)
if rec.Code != http.StatusOK {
t.Fatalf("totp.begin = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("totp.begin Cache-Control = %q, want no-store (F-2)", got)
}
}
// hasCookie reports whether the response set a non-empty cookie of that name.
func hasCookie(rec *httptest.ResponseRecorder, name string) bool {
for _, c := range rec.Result().Cookies() {
if c.Name == name && c.Value != "" {
return true
}
}
return false
}