handleChangePassword revoked other sessions but never cleared webauthn_credentials, and enrollment needs no step-up. A passkey planted through a transiently-hijacked session needs no password, so it survived the reset + session-revoke as a standing login foothold. Add DeleteAllPasskeyCredentialsForUser and call it in the change-password remediation so every passkey is unbound alongside the session revoke. Removing zero rows is a successful no-op. Email-OTP remains the fallback factor, so this never locks anyone out; the user re-enrolls a passkey afterward if they want one.
322 lines
13 KiB
Go
322 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// Local-password auth handler tests (spec §B). These exercise the three-route
|
|
// surface — login, logout, change-password — against the in-memory fakeRepo, which
|
|
// mirrors the PG fail-closed contract. The load-bearing cases are the anti-
|
|
// enumeration uniformity (an unknown user and a wrong password are indistinguishable)
|
|
// and the requireJSONContentType guard that closes the cross-site login-forgery
|
|
// vector: a forged HTML-form POST cannot set application/json, so it is rejected
|
|
// before any credential check.
|
|
|
|
// seedAuthAPI returns an API whose repo has local auth enabled and a single admin
|
|
// "owner" (id u1) whose password is the given plaintext. Login is Public, so these
|
|
// tests need no External wiring.
|
|
func seedAuthAPI(t *testing.T, password string, mustChange bool) (*API, *fakeRepo) {
|
|
t.Helper()
|
|
repo := newFakeRepo()
|
|
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
|
if err != nil {
|
|
t.Fatalf("hash seed password: %v", err)
|
|
}
|
|
repo.staff["owner"] = &StaffUser{
|
|
ID: "u1", Username: "owner", Email: "owner@" + testRoot,
|
|
Role: "admin", PasswordHash: string(hash), MustChangePassword: mustChange,
|
|
}
|
|
return newTestAPI(repo, newFakeCluster()), repo
|
|
}
|
|
|
|
// seedAuthedAPI extends seedAuthAPI with an injected session principal so the
|
|
// authenticated change-password route resolves a caller. change-password opts out of
|
|
// the first-login lockdown (AllowDuringPasswordChange), so a must-change principal
|
|
// still reaches the handler.
|
|
func seedAuthedAPI(t *testing.T, password string, mustChange bool) (*API, *fakeRepo) {
|
|
t.Helper()
|
|
api, repo := seedAuthAPI(t, password, mustChange)
|
|
api.External = staticExternal{p: &Principal{
|
|
UserID: "u1", Email: "owner@" + testRoot, Role: "admin", MustChangePassword: mustChange,
|
|
}}
|
|
return api, repo
|
|
}
|
|
|
|
// ctHeader builds a headers map carrying the given Content-Type, or nil for the
|
|
// absent-header case (do() then sets no Content-Type at all).
|
|
func ctHeader(ct string) map[string]string {
|
|
if ct == "" {
|
|
return nil
|
|
}
|
|
return map[string]string{"Content-Type": ct}
|
|
}
|
|
|
|
var jsonHeader = map[string]string{"Content-Type": "application/json"}
|
|
|
|
func TestHandleLoginSuccess(t *testing.T) {
|
|
api, _ := seedAuthAPI(t, "correct-horse-battery", true)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/login",
|
|
`{"username":"owner","password":"correct-horse-battery"}`, jsonHeader)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
|
|
// The HttpOnly session cookie is the login's whole point — the panel never reads
|
|
// it, the browser just carries it back.
|
|
cookies := w.Result().Cookies()
|
|
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
|
|
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
|
|
}
|
|
if !cookies[0].HttpOnly {
|
|
t.Fatalf("session cookie must be HttpOnly")
|
|
}
|
|
|
|
var got map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
|
|
}
|
|
if got["user_id"] != "u1" || got["role"] != "admin" || got["must_change_password"] != true {
|
|
t.Fatalf("got %v, want user_id=u1 role=admin must_change_password=true", got)
|
|
}
|
|
}
|
|
|
|
// TestHandleLoginContentTypeGuard pins the confirmed login-CSRF fix: a body whose
|
|
// Content-Type is anything an HTML form (or a default cross-site fetch) can emit is
|
|
// rejected 415 BEFORE the credential check, so a forged off-origin login never even
|
|
// reaches bcrypt. Local auth is enabled and the credentials are valid here, proving
|
|
// the rejection is the content-type, not a bad password.
|
|
func TestHandleLoginContentTypeGuard(t *testing.T) {
|
|
api, _ := seedAuthAPI(t, "correct-horse-battery", false)
|
|
h := api.ExternalHandler()
|
|
body := `{"username":"owner","password":"correct-horse-battery"}`
|
|
|
|
for _, ct := range []string{
|
|
"application/x-www-form-urlencoded",
|
|
"multipart/form-data; boundary=x",
|
|
"text/plain;charset=UTF-8",
|
|
"", // header absent entirely
|
|
} {
|
|
w := do(h, "POST", "/api/v1/auth/login", body, ctHeader(ct))
|
|
if w.Code != http.StatusUnsupportedMediaType {
|
|
t.Fatalf("Content-Type %q: code = %d, want 415", ct, w.Code)
|
|
}
|
|
if code := decodeErr(t, w); code != "unsupported_media_type" {
|
|
t.Fatalf("Content-Type %q: error code = %q, want unsupported_media_type", ct, code)
|
|
}
|
|
if len(w.Result().Cookies()) != 0 {
|
|
t.Fatalf("Content-Type %q: no session cookie may be set on a rejected login", ct)
|
|
}
|
|
}
|
|
|
|
// A JSON content-type with a charset parameter is still JSON and must pass.
|
|
if w := do(h, "POST", "/api/v1/auth/login", body,
|
|
map[string]string{"Content-Type": "application/json; charset=utf-8"}); w.Code != http.StatusOK {
|
|
t.Fatalf("application/json; charset=utf-8: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestHandleLoginConcurrencyCap pins the audit-hardening bound on the public login
|
|
// route: bcrypt is CPU-costly and runs on every request (the anti-enumeration dummy
|
|
// included), so at most MaxConcurrentLogins compares may be in flight at once and the
|
|
// excess is shed with a 429 rather than piling more onto every core. Holding the sole
|
|
// slot makes the next login — with otherwise-valid credentials — return 429 auth_busy
|
|
// with no cookie BEFORE any credential check; releasing it lets the identical request
|
|
// succeed, proving the 429 was the cap, not the password. A concurrency cap, not a
|
|
// per-account lockout: the same account gets in the moment the burst clears.
|
|
func TestHandleLoginConcurrencyCap(t *testing.T) {
|
|
api, _ := seedAuthAPI(t, "correct-horse-battery", false)
|
|
api.MaxConcurrentLogins = 1
|
|
h := api.ExternalHandler()
|
|
body := `{"username":"owner","password":"correct-horse-battery"}`
|
|
|
|
// Occupy the one compare slot so the handler finds the cap full. loginLimiter is
|
|
// lazily built from MaxConcurrentLogins (set just above), so this and the handler
|
|
// share the same one-token limiter.
|
|
release, ok := api.loginLimiter().acquire()
|
|
if !ok {
|
|
t.Fatal("could not acquire the sole login slot in test setup")
|
|
}
|
|
w := do(h, "POST", "/api/v1/auth/login", body, jsonHeader)
|
|
if w.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("with the slot held: code = %d, want 429 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if code := decodeErr(t, w); code != "auth_busy" {
|
|
t.Fatalf("error code = %q, want auth_busy", code)
|
|
}
|
|
if len(w.Result().Cookies()) != 0 {
|
|
t.Fatal("no session cookie may be set on a shed login")
|
|
}
|
|
|
|
// Release the slot: the identical request now runs the compare and succeeds.
|
|
release()
|
|
if w := do(h, "POST", "/api/v1/auth/login", body, jsonHeader); w.Code != http.StatusOK {
|
|
t.Fatalf("after releasing the slot: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestHandleLoginInvalidCredentials proves the anti-enumeration uniformity: a wrong
|
|
// password and an unknown username return the SAME 401 invalid_credentials with no
|
|
// cookie, so a caller cannot learn which usernames carry a password.
|
|
func TestHandleLoginInvalidCredentials(t *testing.T) {
|
|
api, _ := seedAuthAPI(t, "correct-horse-battery", false)
|
|
h := api.ExternalHandler()
|
|
|
|
for _, tc := range []struct{ name, body string }{
|
|
{"wrong password", `{"username":"owner","password":"wrong"}`},
|
|
{"unknown user", `{"username":"ghost","password":"whatever"}`},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
w := do(h, "POST", "/api/v1/auth/login", tc.body, jsonHeader)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("code = %d, want 401 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if code := decodeErr(t, w); code != "invalid_credentials" {
|
|
t.Fatalf("error code = %q, want invalid_credentials", code)
|
|
}
|
|
if len(w.Result().Cookies()) != 0 {
|
|
t.Fatalf("no session cookie may be set on a failed login")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleLoginLocalAuthDisabled proves a deployment with no local_auth_enabled
|
|
// setting refuses every local login (403), so a Zero-Trust-only console never
|
|
// accepts a password.
|
|
func TestHandleLoginLocalAuthDisabled(t *testing.T) {
|
|
repo := newFakeRepo() // local_auth_enabled never set → fail closed
|
|
api := newTestAPI(repo, newFakeCluster())
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/login",
|
|
`{"username":"owner","password":"x"}`, jsonHeader)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if code := decodeErr(t, w); code != "local_auth_disabled" {
|
|
t.Fatalf("error code = %q, want local_auth_disabled", code)
|
|
}
|
|
}
|
|
|
|
func TestHandleLoginMissingFields(t *testing.T) {
|
|
api, _ := seedAuthAPI(t, "correct-horse-battery", false)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/login",
|
|
`{"username":"","password":""}`, jsonHeader)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestHandleLogout is idempotent: it clears the cookie and returns 200 even with no
|
|
// live session, and revokes the presented one when there is.
|
|
func TestHandleLogout(t *testing.T) {
|
|
api, repo := seedAuthAPI(t, "correct-horse-battery", false)
|
|
h := api.ExternalHandler()
|
|
|
|
// No cookie: still 200, still clears.
|
|
if w := do(h, "POST", "/api/v1/auth/logout", "", nil); w.Code != http.StatusOK {
|
|
t.Fatalf("logout without session: code = %d, want 200", w.Code)
|
|
}
|
|
|
|
// With a live session cookie: the matching session is revoked.
|
|
token, err := newSessionToken()
|
|
if err != nil {
|
|
t.Fatalf("token: %v", err)
|
|
}
|
|
repo.sessions[hashCookie(token)] = &fakeSession{userID: "u1", expiresAt: api.now().Add(time.Hour)}
|
|
w := do(h, "POST", "/api/v1/auth/logout", "",
|
|
map[string]string{"Cookie": sessionCookieName + "=" + token})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("logout with session: code = %d, want 200", w.Code)
|
|
}
|
|
if !repo.sessions[hashCookie(token)].revoked {
|
|
t.Fatalf("presented session should be revoked")
|
|
}
|
|
}
|
|
|
|
func TestHandleChangePasswordSuccess(t *testing.T) {
|
|
api, repo := seedAuthedAPI(t, "old-password", true)
|
|
// A second live session for u1: the change must revoke it. This request carries
|
|
// no felis_session cookie, so keep="" and every session of u1 is revoked — the
|
|
// safe direction the handler documents.
|
|
repo.sessions["other-device"] = &fakeSession{userID: "u1", expiresAt: api.now().Add(time.Hour)}
|
|
// A bound passkey for u1: the change must unbind it too. A passkey planted through a
|
|
// hijacked session needs no password, so it would otherwise survive the reset as a
|
|
// standing login foothold.
|
|
repo.passkeyCreds["pk1"] = PasskeyCredential{ID: "pk1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k"}
|
|
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
|
|
`{"current_password":"old-password","new_password":"brand-new-password"}`, jsonHeader)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
|
|
u := repo.staff["owner"]
|
|
if u.MustChangePassword {
|
|
t.Fatalf("must_change_password should be cleared after a change")
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte("brand-new-password")) != nil {
|
|
t.Fatalf("the new password does not verify against the stored hash")
|
|
}
|
|
if !repo.sessions["other-device"].revoked {
|
|
t.Fatalf("other sessions should be revoked on a password change")
|
|
}
|
|
if len(repo.passkeyCreds) != 0 {
|
|
t.Fatalf("password change left %d passkeys, want 0 — a planted passkey must not survive remediation", len(repo.passkeyCreds))
|
|
}
|
|
}
|
|
|
|
// TestHandleChangePasswordContentTypeGuard pins the defense-in-depth guard on the
|
|
// authenticated change-password route.
|
|
func TestHandleChangePasswordContentTypeGuard(t *testing.T) {
|
|
api, _ := seedAuthedAPI(t, "old-password", false)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
|
|
`{"current_password":"old-password","new_password":"brand-new-password"}`,
|
|
map[string]string{"Content-Type": "text/plain"})
|
|
if w.Code != http.StatusUnsupportedMediaType {
|
|
t.Fatalf("code = %d, want 415 (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleChangePasswordRejections(t *testing.T) {
|
|
t.Run("weak new password", func(t *testing.T) {
|
|
api, _ := seedAuthedAPI(t, "old-password", false)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
|
|
`{"current_password":"old-password","new_password":"short"}`, jsonHeader)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("code = %d, want 400", w.Code)
|
|
}
|
|
if code := decodeErr(t, w); code != "weak_password" {
|
|
t.Fatalf("error code = %q, want weak_password", code)
|
|
}
|
|
})
|
|
|
|
t.Run("unchanged password", func(t *testing.T) {
|
|
api, _ := seedAuthedAPI(t, "old-password", false)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
|
|
`{"current_password":"old-password","new_password":"old-password"}`, jsonHeader)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("code = %d, want 400", w.Code)
|
|
}
|
|
if code := decodeErr(t, w); code != "password_unchanged" {
|
|
t.Fatalf("error code = %q, want password_unchanged", code)
|
|
}
|
|
})
|
|
|
|
t.Run("wrong current password", func(t *testing.T) {
|
|
api, _ := seedAuthedAPI(t, "old-password", false)
|
|
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
|
|
`{"current_password":"wrong","new_password":"brand-new-password"}`, jsonHeader)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("code = %d, want 401", w.Code)
|
|
}
|
|
if code := decodeErr(t, w); code != "invalid_credentials" {
|
|
t.Fatalf("error code = %q, want invalid_credentials", code)
|
|
}
|
|
})
|
|
}
|