authenticate() cannot tell "no such session" from "the session lookup failed" on its own — both arrive as an error. Answering 401 for both meant a disk or file-permission problem presented 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. Now only a missing or unknown credential is 401. Anything else is a 500 and is logged as what it is. Found while writing the smoke tests, where a deleted database file produced SQLITE_CANTOPEN on the connections that had not been opened yet — so most requests succeeded and a few reported the user as signed out, which reads exactly like a flaky auth bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
764 lines
24 KiB
Go
764 lines
24 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
|
|
}
|
|
|
|
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{
|
|
BaseURL: "http://127.0.0.1",
|
|
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))
|
|
|
|
srv := httptest.NewServer(New(cfg, db, log, nil))
|
|
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},
|
|
}
|
|
}
|
|
|
|
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.
|
|
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
|
|
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 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", nil)
|
|
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)
|
|
}
|
|
}
|