Gmail now connects. Authorisation-code flow with PKCE, the verifier kept server-side rather than in a cookie — the callback is a top-level navigation from Google, and a SameSite=Lax cookie is the kind of thing that works until a browser tightens its defaults. State is checked, the attempt expires after ten minutes, and the spent verifier is deleted rather than left at rest. Scope is gmail.readonly and the sync is one-way on purpose. Unstarring a message does not delete the task; completing the task does not touch the mailbox. Two-way would mean deciding what happens when both sides changed, and the only honest answer is a sync model far larger than the feature. The query carries newer_than:30d so connecting an account with a decade of starred mail does not create a decade of tasks. The update check is off unless the operator turns it on, asks GitHub at most every six hours, and sends nothing about the instance — no identifier, no version, no counts. A development build never reports an update: somebody running their own tree is ahead of every release, and telling them otherwise trains them to ignore the notice. Only administrators see it, because only they can act on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
689 lines
22 KiB
Go
689 lines
22 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
|
|
}
|