Files
verdande/internal/httpapi/auth_test.go
T
kristianwindandClaude Opus 5 77994eff09 feat(security): a TOTP code can be used once
RFC 6238 says the verifier must reject a code it has already accepted: a code
stays valid for its whole thirty-second window, so one seen in flight — a
phishing page, a proxy, over the shoulder — could be replayed until it expired.

Tasks now record the counter of the last accepted step (totp_last_step), and a
login accepts a code only when its step is strictly greater. The guard is in the
UPDATE, not a read-then-write, so two logins racing with the same stolen code
cannot both find the old value and both proceed — exactly one moves the row.

auth.TOTPStep is VerifyTOTP that also reports which step matched, walking the
same one-step window; the code entered a little early or late spends the step it
was issued at, not the step it was checked at, so a late-but-valid code is still
single-use. Verified end to end: the same code presented to two logins in one
window is accepted once and refused the second time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:34:34 +02:00

1017 lines
34 KiB
Go

package httpapi
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/store"
)
// testServer runs the real router against a real database over a real listener.
// Auth is mostly cookies, status codes and middleware ordering — none of which a
// unit test of the handler functions would exercise.
type testServer struct {
*httptest.Server
db *store.DB
client *http.Client
// api er den samme server, routeren hænger på. Et par prøver har brug for at
// nå ind i den — se `icsFetch` — og uden den her er der kun håndtaget udefra.
api *Server
}
func newTestServer(t *testing.T) *testServer {
t.Helper()
return newTestServerWith(t, nil)
}
// newTestServerWith lets a test vary the configuration — a registered OAuth client,
// update checking on — without every other test paying for it.
func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
cfg := &config.Config{
// `localhost`, not a loopback IP: passkeys refuse an IP address as a relying
// party id, and every browser makes the same exception for localhost.
BaseURL: "http://localhost",
DataDir: t.TempDir(),
SessionTTL: 24 * time.Hour,
InviteTTL: 7 * 24 * time.Hour,
ResetTTL: time.Hour,
Dev: true,
}
if configure != nil {
configure(cfg)
}
// Discard: a passing test should print nothing, and these handlers log freely.
log := slog.New(slog.NewTextHandler(io.Discard, nil))
api := New(cfg, db, log, nil)
srv := httptest.NewServer(api)
t.Cleanup(srv.Close)
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
return &testServer{
Server: srv,
db: db,
// The cookie jar is what makes this a session test rather than a
// sequence of unrelated requests.
client: &http.Client{Jar: jar, Timeout: 10 * time.Second},
api: api,
}
}
func (ts *testServer) do(t *testing.T, method, path string, body any) (*http.Response, map[string]any) {
t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, ts.URL+path, reader)
if err != nil {
t.Fatalf("build request: %v", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
// Same-origin, as a browser would report for the app's own fetches.
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := ts.client.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
t.Cleanup(func() { resp.Body.Close() })
var decoded map[string]any
raw, _ := io.ReadAll(resp.Body)
if len(raw) > 0 {
_ = json.Unmarshal(raw, &decoded)
}
return resp, decoded
}
// bootstrap creates the first admin and leaves the client signed in.
func (ts *testServer) bootstrap(t *testing.T) {
t.Helper()
resp, _ := ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{
"email": "kristian@example.dk", "name": "Kristian", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("bootstrap: status %d", resp.StatusCode)
}
}
func TestSetupCreatesTheFirstAdminOnlyOnce(t *testing.T) {
ts := newTestServer(t)
resp, body := ts.do(t, "GET", "/api/v1/auth/setup", nil)
if resp.StatusCode != http.StatusOK || body["needs_setup"] != true {
t.Fatalf("a fresh instance should need setup: %d %v", resp.StatusCode, body)
}
resp, body = ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{
"email": "kristian@example.dk", "name": "Kristian", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("setup: status %d, body %v", resp.StatusCode, body)
}
user, _ := body["user"].(map[string]any)
if user == nil || user["is_admin"] != true {
t.Errorf("the first account is not an admin: %v", body)
}
// The password hash must never appear in a response.
if _, leaked := user["password_hash"]; leaked {
t.Error("the response carries the password hash")
}
// A second attempt must be refused, or anyone could add themselves as admin.
resp, _ = ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{
"email": "someone@else.dk", "name": "Someone", "password": "et andet kodeord",
})
if resp.StatusCode != http.StatusConflict {
t.Errorf("second setup: status %d, want 409", resp.StatusCode)
}
resp, body = ts.do(t, "GET", "/api/v1/auth/setup", nil)
if body["needs_setup"] != false {
t.Errorf("instance still reports needing setup: %v", body)
}
}
func TestSetupCreatesAnInbox(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
if _, err := ts.db.InboxID(t.Context(), user.ID); err != nil {
t.Errorf("no Inbox was created for the first user: %v", err)
}
}
func TestLoginAndLogout(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// Signed in from bootstrap.
resp, body := ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusOK || body["email"] != "kristian@example.dk" {
t.Fatalf("me: %d %v", resp.StatusCode, body)
}
resp, _ = ts.do(t, "POST", "/api/v1/auth/logout", nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("logout: status %d", resp.StatusCode)
}
resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("me after logout: status %d, want 401", resp.StatusCode)
}
resp, body = ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("login: %d %v", resp.StatusCode, body)
}
if body["totp_required"] != false {
t.Errorf("login asked for a code with 2FA off: %v", body)
}
resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusOK {
t.Errorf("me after login: status %d", resp.StatusCode)
}
}
// The address is normalised, so one account cannot be reached under two spellings
// and, worse, a second account cannot be created that shadows the first.
func TestLoginIgnoresEmailCaseAndSpace(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/auth/logout", nil)
for _, email := range []string{
"kristian@example.dk", "KRISTIAN@example.dk", " Kristian@Example.DK ",
} {
resp, _ := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": email, "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK {
t.Errorf("login as %q: status %d", email, resp.StatusCode)
}
ts.do(t, "POST", "/api/v1/auth/logout", nil)
}
}
// A wrong password and an address with no account must be indistinguishable, or the
// login form becomes a way to find out who has an account here.
func TestLoginDoesNotRevealWhichAccountsExist(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/auth/logout", nil)
respWrong, bodyWrong := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "forkert kodeord",
})
respMissing, bodyMissing := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "findes-ikke@example.dk", "password": "forkert kodeord",
})
if respWrong.StatusCode != respMissing.StatusCode {
t.Errorf("status differs: %d vs %d", respWrong.StatusCode, respMissing.StatusCode)
}
if bodyWrong["error"] != bodyMissing["error"] || bodyWrong["code"] != bodyMissing["code"] {
t.Errorf("message differs:\n wrong password: %v\n no account: %v", bodyWrong, bodyMissing)
}
}
func TestSessionCookieIsHardened(t *testing.T) {
ts := newTestServer(t)
// Read the cookie off the response, not out of the jar: Go's cookiejar keeps
// only name and value, so every attribute checked here would come back unset
// regardless of what the server actually sent.
resp, _ := ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{
"email": "kristian@example.dk", "name": "Kristian", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("setup: status %d", resp.StatusCode)
}
var session *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == sessionCookieInsecure || c.Name == sessionCookieSecure {
session = c
}
}
if session == nil {
t.Fatal("no session cookie was set")
}
if !session.HttpOnly {
t.Error("session cookie is readable from JavaScript")
}
if session.SameSite != http.SameSiteLaxMode {
t.Errorf("SameSite = %v, want Lax", session.SameSite)
}
if session.Path != "/" {
t.Errorf("Path = %q, want /", session.Path)
}
// The stored value must be a hash, never the cookie itself: a database dump
// must not contain anything that can be pasted into a browser.
var stored string
err := ts.db.QueryRow(`SELECT id FROM sessions LIMIT 1`).Scan(&stored)
if err != nil {
t.Fatal(err)
}
if stored == session.Value {
t.Error("the session token is stored verbatim; it must be hashed")
}
}
func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// Begin: a secret exists, but 2FA is not on yet — closing the tab here must
// not lock the account.
// Med adgangskoden: at slå andenfaktoren til er mindst lige så indgribende som
// at slå den fra, og den har altid spurgt.
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
"password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
}
secret, _ := body["secret"].(string)
if secret == "" {
t.Fatal("no secret returned")
}
_, me := ts.do(t, "GET", "/api/v1/auth/me", nil)
if me["totp_enabled"] != false {
t.Error("2FA switched on before the code was confirmed")
}
// Confirm with a real code.
resp, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
"code": codeFor(t, secret),
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
}
codes, _ := body["recovery_codes"].([]any)
if len(codes) == 0 {
t.Fatal("no recovery codes were issued")
}
// Now a login is two steps.
ts.do(t, "POST", "/api/v1/auth/logout", nil)
resp, body = ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK || body["totp_required"] != true {
t.Fatalf("login should ask for a code: %d %v", resp.StatusCode, body)
}
// The half-finished session must not reach anything.
resp, body = ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a pending session reached /me: %d", resp.StatusCode)
}
if body["code"] != CodeTOTPRequired {
t.Errorf("code = %v, want %s", body["code"], CodeTOTPRequired)
}
// A wrong code does not finish the login.
resp, _ = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": "000000"})
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a wrong code was accepted: %d", resp.StatusCode)
}
// The right one does.
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{
"code": codeFor(t, secret),
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp login: %d %v", resp.StatusCode, body)
}
resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusOK {
t.Errorf("me after completing 2FA: %d", resp.StatusCode)
}
}
// A TOTP code works once. A code seen in flight and replayed inside its window
// must be refused the second time, or the second factor is only as good as the
// thirty seconds an attacker has to reuse what they captured.
func TestTOTPCodeIsSingleUse(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
"password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
}
secret, _ := body["secret"].(string)
resp, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
"code": codeFor(t, secret),
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
}
// One code, captured once and presented twice.
code := codeFor(t, secret)
ts.do(t, "POST", "/api/v1/auth/logout", nil)
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
if resp.StatusCode != http.StatusOK {
t.Fatalf("first use of the code failed: %d %v", resp.StatusCode, body)
}
// A second login, same code, same window.
ts.do(t, "POST", "/api/v1/auth/logout", nil)
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("the same code was accepted twice: %d %v", resp.StatusCode, body)
}
}
// A recovery code is accepted in the same field as a TOTP code, and works once.
func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
"password": "et langt kodeord",
})
secret := body["secret"].(string)
_, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
"code": codeFor(t, secret),
})
raw := body["recovery_codes"].([]any)
recovery := raw[0].(string)
ts.do(t, "POST", "/api/v1/auth/logout", nil)
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
resp, _ := ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": recovery})
if resp.StatusCode != http.StatusOK {
t.Fatalf("recovery code rejected: %d", resp.StatusCode)
}
// The same code must not work a second time.
ts.do(t, "POST", "/api/v1/auth/logout", nil)
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
resp, _ = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": recovery})
if resp.StatusCode == http.StatusOK {
t.Error("a recovery code worked twice")
}
}
func TestChangePasswordRequiresTheCurrentOne(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, _ := ts.do(t, "POST", "/api/v1/auth/password/change", map[string]string{
"current_password": "det forkerte", "new_password": "et helt nyt kodeord",
})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("wrong current password: status %d, want 422", resp.StatusCode)
}
resp, _ = ts.do(t, "POST", "/api/v1/auth/password/change", map[string]string{
"current_password": "et langt kodeord", "new_password": "et helt nyt kodeord",
})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("change password: status %d", resp.StatusCode)
}
// The session that made the change survives; the old password does not.
resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusOK {
t.Error("changing the password logged out the session that did it")
}
ts.do(t, "POST", "/api/v1/auth/logout", nil)
resp, _ = ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
if resp.StatusCode == http.StatusOK {
t.Error("the old password still works")
}
}
func TestPasswordRulesRejectShortOnesOnly(t *testing.T) {
ts := newTestServer(t)
resp, body := ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{
"email": "k@example.dk", "name": "K", "password": "kort",
})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("short password: status %d", resp.StatusCode)
}
fields, _ := body["fields"].(map[string]any)
if fields["password"] == nil {
t.Errorf("the error does not name the password field: %v", body)
}
// A long passphrase with no digits or symbols must be accepted: composition
// rules push people towards worse passwords, not better ones.
resp, _ = ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{
"email": "k@example.dk", "name": "K", "password": "syv heste løber over marken",
})
if resp.StatusCode != http.StatusCreated {
t.Errorf("a long passphrase was rejected: status %d", resp.StatusCode)
}
}
// Asking to reset a password must not reveal whether the address has an account.
func TestForgotPasswordAnswersTheSameEitherWay(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
respKnown, bodyKnown := ts.do(t, "POST", "/api/v1/auth/password/forgot", map[string]string{
"email": "kristian@example.dk",
})
respUnknown, bodyUnknown := ts.do(t, "POST", "/api/v1/auth/password/forgot", map[string]string{
"email": "findes-ikke@example.dk",
})
if respKnown.StatusCode != respUnknown.StatusCode {
t.Errorf("status differs: %d vs %d", respKnown.StatusCode, respUnknown.StatusCode)
}
if bodyKnown["status"] != bodyUnknown["status"] {
t.Errorf("message differs: %v vs %v", bodyKnown, bodyUnknown)
}
}
func TestPasswordResetEndsEverySession(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
token, err := ts.db.CreatePasswordReset(t.Context(), user.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
resp, _ := ts.do(t, "POST", "/api/v1/auth/password/reset", map[string]string{
"token": token, "password": "et helt nyt kodeord",
})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("reset: status %d", resp.StatusCode)
}
// Everything is signed out — a reset is what you do when you think somebody
// else has been in your account.
resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a session survived a password reset: %d", resp.StatusCode)
}
// The link works exactly once.
resp, _ = ts.do(t, "POST", "/api/v1/auth/password/reset", map[string]string{
"token": token, "password": "endnu et kodeord",
})
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("a reset link worked twice: %d", resp.StatusCode)
}
}
func TestSignupRequiresAnInvite(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, _ := ts.do(t, "POST", "/api/v1/auth/signup", map[string]string{
"token": "opfundet", "name": "Ubuden", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("signup without a valid invite: status %d, want 400", resp.StatusCode)
}
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
token, _, err := ts.db.CreateInvite(t.Context(), "ny@example.dk", "", store.RoleEditor, admin.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
// The address comes from the invite, not from the form — so an invite cannot
// be redirected to somebody else by editing the request.
resp, body := ts.do(t, "POST", "/api/v1/auth/signup", map[string]string{
"token": token, "name": "Ny Bruger", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("signup: %d %v", resp.StatusCode, body)
}
user, _ := body["user"].(map[string]any)
if user["email"] != "ny@example.dk" {
t.Errorf("email = %v, want the invited address", user["email"])
}
if user["is_admin"] != false {
t.Error("an invited user was made an admin")
}
// And the invite is spent.
resp, _ = ts.do(t, "POST", "/api/v1/auth/signup", map[string]string{
"token": token, "name": "Endnu En", "password": "et langt kodeord",
})
if resp.StatusCode == http.StatusCreated {
t.Error("an invite link was used twice")
}
}
func TestUnauthenticatedRequestsAreRefused(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/auth/logout", nil)
for _, tc := range []struct{ method, path string }{
{"GET", "/api/v1/auth/me"},
{"POST", "/api/v1/auth/totp/setup"},
{"POST", "/api/v1/auth/password/change"},
{"GET", "/api/v1/recovery-codes"},
} {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
resp, _ := ts.do(t, tc.method, tc.path, map[string]string{})
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 401", resp.StatusCode)
}
})
}
}
// A cross-site POST must not be able to act on somebody's session.
func TestCrossSiteWritesAreRefused(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
req, err := http.NewRequest("POST", ts.URL+"/api/v1/auth/logout", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Sec-Fetch-Site", "cross-site")
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("cross-site POST: status %d, want 403", resp.StatusCode)
}
// And the session survived it.
if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK {
t.Error("the cross-site request logged the user out anyway")
}
}
func TestMalformedRequestBodies(t *testing.T) {
ts := newTestServer(t)
cases := []struct {
name, body string
contentType string
wantStatus int
}{
{"empty", "", "application/json", http.StatusBadRequest},
{"not json", "hejsa", "application/json", http.StatusBadRequest},
{"wrong field type", `{"email": 42}`, "application/json", http.StatusBadRequest},
{"unknown field", `{"email":"a@b.dk","sneaky":1}`, "application/json", http.StatusBadRequest},
{"two objects", `{"email":"a@b.dk"}{"email":"c@d.dk"}`, "application/json", http.StatusBadRequest},
{"wrong content type", `{"email":"a@b.dk"}`, "text/plain", http.StatusUnsupportedMediaType},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req, err := http.NewRequest("POST", ts.URL+"/api/v1/auth/login", bytes.NewReader([]byte(tc.body)))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", tc.contentType)
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != tc.wantStatus {
t.Errorf("status %d, want %d", resp.StatusCode, tc.wantStatus)
}
})
}
}
// Repeated password guesses must stop being answered.
func TestLoginIsRateLimited(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/auth/logout", nil)
var limited bool
for i := 0; i < 30; i++ {
resp, _ := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "gæt",
})
if resp.StatusCode == http.StatusTooManyRequests {
if resp.Header.Get("Retry-After") == "" {
t.Error("a rate-limited response does not say when to retry")
}
limited = true
break
}
}
if !limited {
t.Error("thirty wrong passwords in a row were all answered")
}
}
func codeFor(t *testing.T, secret string) string {
t.Helper()
code, err := totp.GenerateCodeCustom(secret, time.Now(), totp.ValidateOpts{
Period: 30, Skew: 1, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
t.Fatalf("generate code: %v", err)
}
return code
}
// newJarClient gives a test its own cookie jar, which is what makes two clients
// against one server two different browsers rather than the same session twice.
func newJarClient(t *testing.T) *http.Client {
t.Helper()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
return &http.Client{Jar: jar, Timeout: 10 * time.Second}
}
func mustHash(t *testing.T, password string) string {
t.Helper()
hash, err := auth.HashPassword(password)
if err != nil {
t.Fatalf("hash password: %v", err)
}
return hash
}
// A database that cannot be read must not look like a failed login.
//
// authenticate() cannot tell the two apart on its own: both come back as an error
// from the session lookup. Answering 401 for both means a disk problem presents as
// every session ending at once — the frontend clears its state and shows the
// sign-in screen to everybody — and the logs record a wave of failed logins rather
// than the fault. Found by the end-to-end suite, where a deleted database file
// turned into exactly that.
func TestDatabaseFailureIsNotALogout(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// Still signed in, and the session cookie is in the jar.
resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("me: status %d", resp.StatusCode)
}
// Pull the database out from under the server. Every query now fails with
// something that is not "no such session".
if err := ts.db.Close(); err != nil {
t.Fatalf("close db: %v", err)
}
resp, body := ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode == http.StatusUnauthorized {
t.Fatal("a broken database signed the user out instead of reporting a fault")
}
if resp.StatusCode != http.StatusInternalServerError {
t.Errorf("status %d, want 500", resp.StatusCode)
}
if body["code"] != CodeInternal {
t.Errorf("code = %v, want %q", body["code"], CodeInternal)
}
}
// The other half of the same contract: no cookie at all is still a plain 401.
func TestMissingCredentialsAreStillUnauthorized(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
anonymous := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
resp, body := anonymous.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("status %d, want 401", resp.StatusCode)
}
if body["code"] != CodeUnauthorized {
t.Errorf("code = %v, want %q", body["code"], CodeUnauthorized)
}
}
// And a cookie that is not a session is a 401 too, not a 500 — an unknown token is
// a wrong credential, which is the client's problem and not the server's.
func TestUnknownSessionTokenIsUnauthorized(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
req, err := http.NewRequest("GET", ts.URL+"/api/v1/auth/me", nil)
if err != nil {
t.Fatal(err)
}
req.AddCookie(&http.Cookie{Name: "verdande_session", Value: "ikke-en-rigtig-token"})
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("status %d, want 401", resp.StatusCode)
}
}
// last_seen_at has been written on every request since sessions existed, for
// exactly this list, and nothing read it. A session you cannot see is a session
// you cannot end.
func TestSessionsCanBeListedAndEnded(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// A second sign-in from "another device": same account, its own cookie jar.
other := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
resp, _ := other.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("second login: status %d", resp.StatusCode)
}
resp, body := ts.do(t, "GET", "/api/v1/auth/sessions", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list sessions: %d %v", resp.StatusCode, body)
}
sessions, _ := body["sessions"].([]any)
if len(sessions) != 2 {
t.Fatalf("want two sessions, got %v", sessions)
}
// Exactly one is this one, or the interface cannot tell somebody which row is
// the browser they are reading it in.
current := ""
for _, raw := range sessions {
s := raw.(map[string]any)
if s["current"] == true {
if current != "" {
t.Fatal("two sessions both claim to be the current one")
}
current = s["id"].(string)
}
if s["device"] == "" {
t.Errorf("no device summary: %v", s)
}
if s["last_seen_at"] == nil {
t.Errorf("no last_seen_at, which is the whole reason the column is written: %v", s)
}
}
if current == "" {
t.Fatal("none of the sessions is marked as the current one")
}
// End the other one.
var otherID string
for _, raw := range sessions {
if s := raw.(map[string]any); s["id"] != current {
otherID = s["id"].(string)
}
}
if resp, _ := ts.do(t, "DELETE", "/api/v1/auth/sessions/"+otherID, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete session: status %d", resp.StatusCode)
}
// That device is signed out, and this one is not.
if r, _ := other.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusUnauthorized {
t.Errorf("the ended session still works: status %d", r.StatusCode)
}
if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK {
t.Errorf("ending another session logged this one out: status %d", r.StatusCode)
}
}
// Somebody else's session is not yours to end, even with its id in hand.
func TestASessionCanOnlyBeEndedByItsOwner(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
_, body := ts.do(t, "GET", "/api/v1/auth/sessions", nil)
mine := body["sessions"].([]any)[0].(map[string]any)["id"].(string)
if resp, _ := other.do(t, "DELETE", "/api/v1/auth/sessions/"+mine, nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
// And it still works.
if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK {
t.Errorf("somebody else ended my session: status %d", r.StatusCode)
}
}
// An API token is accepted almost everywhere, and must not be accepted here: a
// leaked token that could end its owner's sessions, or even list their devices,
// turns a theft into a lockout.
func TestSessionsAreNotReachableWithAToken(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, user.ID)
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/auth/sessions", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("status %d, want 403", resp.StatusCode)
}
}
func TestUserAgentsAreSummarisedForAPerson(t *testing.T) {
cases := []struct {
ua string
want string
}{
{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36", "Chrome på macOS"},
// An iPhone's user agent says Mac OS X too, so the order of the tests is
// the whole correctness of this function.
{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", "Safari på iPhone"},
// And Edge claims to be Chrome, which claims to be Safari.
{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36 Edg/131.0", "Edge på Windows"},
{"Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0", "Firefox på Linux"},
{"", "Ukendt enhed"},
// Not a browser at all. Its own name beats a word that says the server
// gave up: a CalDAV client is a real session.
{"curl/8.4.0", "curl/8.4.0"},
}
for _, c := range cases {
if got := describeUserAgent(c.ua); got != c.want {
t.Errorf("describeUserAgent(%q) = %q, want %q", c.ua, got, c.want)
}
}
}
// Andenfaktoren kan ikke slås til uden adgangskoden.
//
// Døren åbnede før med et token og lukkede kun med et kodeord: `disable` har
// altid spurgt, `setup` spurgte om ingenting. Et stjålet token kunne derfor hente
// hemmeligheden, lægge den i sin egen godkender, bekræfte den og beholde den
// eneste kopi af gendannelseskoderne — hvorefter ejerens næste login stoppede ved
// en kode, de ikke havde, og de ikke kunne slå den fra uden at logge ind først.
func TestTOTPCannotBeSwitchedOnWithoutThePassword(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, _ := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
"password": "det forkerte kodeord",
})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("status %d, ventede at en forkert adgangskode blev afvist", resp.StatusCode)
}
resp, _ = ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{})
if resp.StatusCode == http.StatusOK {
t.Fatal("en tom adgangskode blev accepteret")
}
}
// Ratebegrænseren må ikke nulstilles af adgangskoden alene.
//
// Den blev det, og det gav én, der havde kodeordet, ubegrænsede portioner på ti
// gæt: log ind, få en frisk spand og en frisk ventende session, brug ti gæt på
// koden, log ind igen. Andenfaktoren findes præcis til det tilfælde, hvor
// kodeordet allerede er tabt, så den portion skal betales for hver gang.
func TestAPasswordAloneDoesNotRefillTheCodeAttempts(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
"password": "et langt kodeord",
})
secret := body["secret"].(string)
ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{"code": codeFor(t, secret)})
ts.do(t, "POST", "/api/v1/auth/logout", nil)
// Kodeordet er rigtigt hver gang; kun koden er forkert. Uden loftet ville det
// her kunne blive ved i det uendelige.
refused := 0
for i := 0; i < 14; i++ {
resp, _ := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
if resp.StatusCode == http.StatusTooManyRequests {
refused++
continue
}
resp, _ = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": "000000"})
if resp.StatusCode == http.StatusTooManyRequests {
refused++
}
}
if refused == 0 {
t.Fatal("fjorten runder med forkerte koder blev aldrig afvist; spanden fyldes stadig af adgangskoden")
}
}