feat(api): local-password authentication backend
Add username+password login for Owner/Operator staff accounts on op.console, the primary web login when Zero Trust is not in front of the API. Three handlers form the whole surface: login mints a server-side session cookie, logout revokes it idempotently, and change-password re-verifies the current password before rotating the hash and clearing must_change_password. - Session cookies are HttpOnly+Secure+SameSite=Lax, host-only, stored server-side as a SHA-256 hash with a 12h TTL. - Login is anti-enumeration: every failure runs a uniform bcrypt compare against a dummy hash and returns the same vague error. - Credential-bearing writes require Content-Type: application/json, returning 415 otherwise, to close the cross-site form-POST forgery vector as a belt to the SameSite cookie. - Local auth fails closed: login is rejected unless local_auth_enabled is set, so a Zero-Trust-only deployment never accepts a local password. - Extend the users table with a nullable password_hash and must_change_password; staff are role=admin rows with a hash, players are role=user rows with hash NULL. - /me now reports must_change_password so the panel can force a first-login change. Covered by Go unit tests (handlers, content-type guard, anti-enumeration, forced-change lockdown) and the OpenAPI route-parity gate.
This commit is contained in:
17 files changed
+1370
-17
No files matched your search
+18
-4
@@ -122,8 +122,14 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintln(stderr, "felis api: restore executor disabled (needs FELIS_IMAGE and FELIS_BACKUP_PVC) — restore endpoint returns 503")
|
||||
}
|
||||
|
||||
// One PGRepo instance backs both the handlers and the session verifier: the
|
||||
// SessionAuth that fronts the external face reads sessions/users/settings from
|
||||
// the same store the auth handlers write to, so a login and the next request
|
||||
// agree on what local auth knows.
|
||||
repo := api.NewPGRepo(drv.DB())
|
||||
|
||||
a := &api.API{
|
||||
Repo: api.NewPGRepo(drv.DB()),
|
||||
Repo: repo,
|
||||
Cluster: api.NewK8sCluster(cl, cfg.K8s.Namespace),
|
||||
Console: api.NewK8sConsole(cl, cfg.K8s.Namespace),
|
||||
Logs: api.NewK8sLogStreamer(clientset, cfg.K8s.Namespace),
|
||||
@@ -134,9 +140,17 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
||||
Builder: builder,
|
||||
Restorer: restorer,
|
||||
Submissions: submissions,
|
||||
// Keyfunc is intentionally nil: the external face fails closed until a
|
||||
// JWKS-backed key function is wired (deployment integration point).
|
||||
External: api.AccessVerifier{Audience: cfg.Auth.AccessJWTAud},
|
||||
// The external face is fronted by SessionAuth: it prefers a local-password
|
||||
// session cookie and otherwise delegates to the Cloudflare-Access JWT verifier,
|
||||
// so both auth models coexist on one face. The delegate's Keyfunc is
|
||||
// intentionally nil — the JWT path fails closed until a JWKS-backed key function
|
||||
// is wired (deployment integration point) — while the local-password path is
|
||||
// live the moment `felis breakGlass` flips local_auth_enabled on.
|
||||
External: api.SessionAuth{
|
||||
Repo: repo,
|
||||
Delegate: api.AccessVerifier{Audience: cfg.Auth.AccessJWTAud},
|
||||
RootDomain: cfg.Server.RootDomain,
|
||||
},
|
||||
RootDomain: cfg.Server.RootDomain,
|
||||
WakeCooldown: 30 * time.Second,
|
||||
}
|
||||
|
||||
+152
-1
@@ -83,6 +83,17 @@ components:
|
||||
Cloudflare Access JWT (external face). Admin-tier operations require the
|
||||
token to have traversed the admin Access path; the handler additionally
|
||||
asserts Principal.IsAdmin().
|
||||
sessionCookie:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: felis_session
|
||||
description: >-
|
||||
Opaque local-password session cookie (external face). Minted by
|
||||
POST /api/v1/auth/login when local auth is enabled, HttpOnly+Secure+
|
||||
SameSite=Lax and host-only, so an op.console session never reaches the
|
||||
player console. Only its sha-256 is persisted. SessionAuth prefers this
|
||||
cookie and otherwise delegates to accessJWT, so the two models coexist on
|
||||
one face.
|
||||
|
||||
responses:
|
||||
NoContent:
|
||||
@@ -1066,6 +1077,137 @@ paths:
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
# -------------------------------------------------- external: local auth ---
|
||||
/api/v1/auth/login:
|
||||
post:
|
||||
tags: [auth]
|
||||
operationId: login
|
||||
summary: Log in with a local username + password (op.console).
|
||||
description: >-
|
||||
Verifies a username+password against the users row and, on success, mints
|
||||
a host-only session cookie (spec §B). Mounted Public — there is no prior
|
||||
principal — but local auth must be enabled (local_auth_enabled), so a
|
||||
deployment fronted entirely by Zero Trust never accepts a local password.
|
||||
Every failure returns the same vague invalid_credentials after a uniform
|
||||
bcrypt compare, so usernames cannot be enumerated by response or timing.
|
||||
x-felis-face: [external]
|
||||
x-felis-tier: public
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [username, password]
|
||||
properties:
|
||||
username: { type: string }
|
||||
password: { type: string, format: password }
|
||||
responses:
|
||||
'200':
|
||||
description: Session established; the cookie is set on the response.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [user_id, role, must_change_password]
|
||||
properties:
|
||||
user_id: { type: string }
|
||||
role:
|
||||
type: string
|
||||
enum: [user, admin]
|
||||
must_change_password:
|
||||
type: boolean
|
||||
description: >-
|
||||
True when this account still owes its first-login password
|
||||
change; the panel routes straight to the change-password card.
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
description: Invalid username or password (vague by design).
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
'403':
|
||||
description: Local password login is disabled on this deployment.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
|
||||
/api/v1/auth/logout:
|
||||
post:
|
||||
tags: [auth]
|
||||
operationId: logout
|
||||
summary: Revoke the current local session and clear the cookie.
|
||||
description: >-
|
||||
Revokes the presented session and clears the cookie (spec §B). Mounted
|
||||
Public and idempotent: it reads the cookie directly, so it works even when
|
||||
the session has already expired and never errors on a missing one.
|
||||
x-felis-face: [external]
|
||||
x-felis-tier: public
|
||||
security: []
|
||||
responses:
|
||||
'200':
|
||||
description: Logged out (idempotent).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [ok]
|
||||
properties:
|
||||
ok: { type: boolean, const: true }
|
||||
|
||||
/api/v1/auth/change-password:
|
||||
post:
|
||||
tags: [auth]
|
||||
operationId: changePassword
|
||||
summary: Change the caller's local password (forced first-login or rotation).
|
||||
description: >-
|
||||
Re-verifies the caller's current password, stores a new bcrypt hash, clears
|
||||
must_change_password, and revokes the account's OTHER sessions while keeping
|
||||
the current one (spec §B). Reachable while must_change_password is set, so a
|
||||
forced first-login change can complete — the rest of the API is fenced off
|
||||
until it does. The session authenticates the caller; re-asking the current
|
||||
password additionally blocks a hijacked session from silently rotating the
|
||||
credential.
|
||||
x-felis-face: [external]
|
||||
x-felis-tier: app
|
||||
security: [{ sessionCookie: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [current_password, new_password]
|
||||
properties:
|
||||
current_password: { type: string, format: password }
|
||||
new_password:
|
||||
type: string
|
||||
format: password
|
||||
minLength: 8
|
||||
maxLength: 72
|
||||
description: 8–72 bytes; 72 is bcrypt's hard input limit.
|
||||
responses:
|
||||
'200':
|
||||
description: Password changed; other sessions revoked.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [ok]
|
||||
properties:
|
||||
ok: { type: boolean, const: true }
|
||||
'400':
|
||||
description: Weak password, or the new password equals the current one.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
|
||||
/api/v1/me:
|
||||
get:
|
||||
tags: [servers]
|
||||
@@ -1088,7 +1230,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [user_id, email, role, is_admin]
|
||||
required: [user_id, email, role, is_admin, must_change_password]
|
||||
properties:
|
||||
user_id: { type: string }
|
||||
email: { type: string, format: email }
|
||||
@@ -1101,6 +1243,15 @@ paths:
|
||||
description: >-
|
||||
True only when role is admin AND the request arrived via the
|
||||
admin Access path (Principal.IsAdmin()).
|
||||
must_change_password:
|
||||
type: boolean
|
||||
description: >-
|
||||
True when a local-password staff account still owes its
|
||||
first-login password change. Meaningful only on the
|
||||
local-password path (false on the JWT path). The panel routes
|
||||
such an account straight to the change-password card. Reachable
|
||||
while set, alongside change-password and logout, because the
|
||||
rest of the API is fenced off until the change completes.
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ go 1.26
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.4.0
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/jackc/pgx/v5 v5.7.1
|
||||
golang.org/x/crypto v0.27.0
|
||||
k8s.io/api v0.31.3
|
||||
k8s.io/apimachinery v0.31.3
|
||||
k8s.io/client-go v0.31.0
|
||||
@@ -13,10 +18,20 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/evanphx/json-patch/v5 v5.9.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
@@ -24,7 +39,6 @@ require (
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
github.com/go-openapi/swag v0.22.4 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/gnostic-models v0.6.8 // indirect
|
||||
@@ -37,23 +51,31 @@ require (
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_golang v1.19.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.55.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/crypto v0.27.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||
golang.org/x/net v0.26.0 // indirect
|
||||
golang.org/x/oauth2 v0.21.0 // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sys v0.25.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.24.0 // indirect
|
||||
golang.org/x/text v0.18.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
||||
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -11,6 +35,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
|
||||
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
|
||||
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
|
||||
github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg=
|
||||
@@ -73,13 +99,27 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA=
|
||||
@@ -99,6 +139,8 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
@@ -115,6 +157,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
@@ -128,8 +172,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
|
||||
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
@@ -148,8 +192,10 @@ golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
|
||||
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
|
||||
+26
-1
@@ -126,6 +126,14 @@ type apiRoute struct {
|
||||
// handler). Internal-face routes never set it.
|
||||
Admin bool
|
||||
|
||||
// AllowDuringPasswordChange opts a route OUT of the must_change_password
|
||||
// lockdown (spec §B). The lockdown is default-deny: every authenticated route is
|
||||
// fenced off for a staff principal that still owes a first-login password change
|
||||
// EXCEPT the few that let it escape the state — change-password, logout, and the
|
||||
// self-identity read /me. A new authenticated route is locked down unless it
|
||||
// sets this, so forgetting the flag fails safe (closed), never open.
|
||||
AllowDuringPasswordChange bool
|
||||
|
||||
h http.HandlerFunc
|
||||
}
|
||||
|
||||
@@ -168,6 +176,15 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
return []apiRoute{
|
||||
{Method: "GET", Pattern: "/healthz", Public: true, h: a.handleHealthz},
|
||||
|
||||
// Local-password auth (spec §B), the op.console login surface. login/logout
|
||||
// are Public (pre-session: a caller has no principal yet, and logout reads the
|
||||
// cookie directly so it works even after expiry). change-password requires a
|
||||
// live session and stays reachable while must_change_password is set
|
||||
// (AllowDuringPasswordChange) so a forced first-login change can complete.
|
||||
{Method: "POST", Pattern: "/api/v1/auth/login", Public: true, h: a.handleLogin},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/logout", Public: true, h: a.handleLogout},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/change-password", AllowDuringPasswordChange: true, h: a.handleChangePassword},
|
||||
|
||||
// App-auth tier: operations on your own servers (spec §14).
|
||||
{Method: "POST", Pattern: "/api/v1/servers/{name}/wake", h: a.handleWake},
|
||||
{Method: "POST", Pattern: "/api/v1/servers/{name}/stop", h: a.handleStop},
|
||||
@@ -195,7 +212,9 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
// every authenticated principal may read its OWN identity. is_admin is the
|
||||
// server-computed Principal.IsAdmin() (Role + admin Access path), so the client
|
||||
// never re-derives the graded-ZT rule; it remains UX truth, not enforcement.
|
||||
{Method: "GET", Pattern: "/api/v1/me", h: a.handleMe},
|
||||
// /me is exempt from the first-login lockdown so the panel can read its own
|
||||
// identity (including must_change_password) to render the change-password card.
|
||||
{Method: "GET", Pattern: "/api/v1/me", AllowDuringPasswordChange: true, h: a.handleMe},
|
||||
{Method: "GET", Pattern: "/api/v1/me/servers", h: a.handleMyServers},
|
||||
// World backups (spec §7, §466). Both are app-tier: GET /backups is scoped
|
||||
// inside the handler (admin sees all; a user sees only worlds they formerly
|
||||
@@ -279,6 +298,12 @@ func (a *API) buildFace(routes []apiRoute, guard func(http.Handler) http.Handler
|
||||
if rt.Admin {
|
||||
h = a.adminOnly(rt.h)
|
||||
}
|
||||
// Default-deny first-login lockdown (spec §B): wrap every authenticated route
|
||||
// unless it explicitly opts out. The wrapper is nil-principal safe, so it is
|
||||
// inert on the internal face (service-token callers carry no Principal).
|
||||
if !rt.AllowDuringPasswordChange {
|
||||
h = a.lockdownDuringPasswordChange(h)
|
||||
}
|
||||
auth.HandleFunc(pattern, h)
|
||||
}
|
||||
mux.Handle("/api/v1/", guard(auth))
|
||||
|
||||
@@ -41,6 +41,21 @@ type fakeRepo struct {
|
||||
links map[string]string // mc_uuid -> user_id (mirrors UNIQUE(mc_uuid))
|
||||
// world backups (spec §7, §22). A nil slice lists empty.
|
||||
backups []fakeBackup
|
||||
// local-password auth (spec §B). staff is keyed by username (the login key);
|
||||
// sessions by token_hash; settings by key. They mirror the PG contract so the
|
||||
// hermetic tests exercise the same fail-closed semantics the integration impl
|
||||
// honors.
|
||||
staff map[string]*StaffUser // username -> staff login row
|
||||
sessions map[string]*fakeSession // token_hash -> session
|
||||
settings map[string][]byte // key -> jsonb value
|
||||
}
|
||||
|
||||
// fakeSession mirrors a sessions row: its owner, its expiry, and whether it has
|
||||
// been revoked.
|
||||
type fakeSession struct {
|
||||
userID string
|
||||
expiresAt time.Time
|
||||
revoked bool
|
||||
}
|
||||
|
||||
// fakeBackup mirrors a world_backups row: the client-facing view plus the
|
||||
@@ -65,6 +80,9 @@ func newFakeRepo() *fakeRepo {
|
||||
claimOK: map[string]bool{},
|
||||
seeded: map[string]bool{}, aliases: map[string]string{},
|
||||
linkCodes: map[string]fakeLinkCode{}, links: map[string]string{},
|
||||
staff: map[string]*StaffUser{},
|
||||
sessions: map[string]*fakeSession{},
|
||||
settings: map[string][]byte{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +210,94 @@ func (f *fakeRepo) LatestBackup(_ context.Context, serverName string) (*BackupRe
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---- local-password auth fakes (spec §B) ----
|
||||
// Each method mirrors the PGRepo contract: a returned StaffUser is copied so a
|
||||
// test cannot mutate the stored row by reference, SessionUser re-reads the
|
||||
// CURRENT staff flags (so a password change clears must_change_password for live
|
||||
// sessions just as the PG JOIN does), and the settings/sessions semantics match.
|
||||
|
||||
func (f *fakeRepo) UserByUsername(_ context.Context, username string) (*StaffUser, error) {
|
||||
if u, ok := f.staff[username]; ok {
|
||||
cp := *u
|
||||
return &cp, nil
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
func (f *fakeRepo) UserByID(_ context.Context, id string) (*StaffUser, error) {
|
||||
for _, u := range f.staff {
|
||||
if u.ID == id {
|
||||
cp := *u
|
||||
return &cp, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
func (f *fakeRepo) UpsertOwner(_ context.Context, id, username, email, passwordHash string, mustChange bool) error {
|
||||
// Mirror PG ON CONFLICT (username): preserve the existing id so live sessions
|
||||
// survive a password reset.
|
||||
if existing, ok := f.staff[username]; ok {
|
||||
id = existing.ID
|
||||
}
|
||||
f.staff[username] = &StaffUser{
|
||||
ID: id, Username: username, Email: email, Role: "admin",
|
||||
PasswordHash: passwordHash, MustChangePassword: mustChange,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepo) SetPassword(_ context.Context, userID, passwordHash string) error {
|
||||
for _, u := range f.staff {
|
||||
if u.ID == userID {
|
||||
u.PasswordHash = passwordHash
|
||||
u.MustChangePassword = false
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ErrNotFound
|
||||
}
|
||||
func (f *fakeRepo) CreateSession(_ context.Context, tokenHash, userID string, expiresAt time.Time) error {
|
||||
f.sessions[tokenHash] = &fakeSession{userID: userID, expiresAt: expiresAt}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepo) SessionUser(_ context.Context, tokenHash string, now time.Time) (*SessionedUser, error) {
|
||||
s, ok := f.sessions[tokenHash]
|
||||
if !ok || s.revoked || !s.expiresAt.After(now) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
for _, u := range f.staff {
|
||||
if u.ID == s.userID {
|
||||
return &SessionedUser{
|
||||
ID: u.ID, Email: u.Email, Role: u.Role,
|
||||
MustChangePassword: u.MustChangePassword,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
func (f *fakeRepo) RevokeSession(_ context.Context, tokenHash string) error {
|
||||
if s, ok := f.sessions[tokenHash]; ok {
|
||||
s.revoked = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepo) RevokeUserSessionsExcept(_ context.Context, userID, keepTokenHash string) error {
|
||||
for h, s := range f.sessions {
|
||||
if s.userID == userID && h != keepTokenHash {
|
||||
s.revoked = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepo) GetSetting(_ context.Context, key string) ([]byte, error) {
|
||||
if v, ok := f.settings[key]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
func (f *fakeRepo) SetSetting(_ context.Context, key string, value []byte) error {
|
||||
f.settings[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeRestorer records the restore it was asked to start and returns a canned
|
||||
// error, mirroring the Restorer kick-off contract. The real restore Job is
|
||||
// integration-only, so the handler is tested against this fake (spec §466).
|
||||
|
||||
+11
-3
@@ -19,10 +19,18 @@ type Principal struct {
|
||||
Email string
|
||||
// Role is "admin" or "user" (mirrors users.role).
|
||||
Role string
|
||||
// ViaAdminAccess is true only when the request arrived through the admin.*
|
||||
// Zero-Trust hostname (Cloudflare Access). Admin-tier operations require it
|
||||
// in addition to Role=="admin" (spec §14: ZT is graded by operation).
|
||||
// ViaAdminAccess is true only when the request arrived through an admin-graded
|
||||
// path: the admin.* Zero-Trust hostname (Cloudflare Access, the remote face) OR
|
||||
// a local-password session presented on the op.console host (SessionAuth, the
|
||||
// break-glass-enabled face). Admin-tier operations require it in addition to
|
||||
// Role=="admin" (spec §14: ZT is graded by operation). A role=admin session
|
||||
// arriving on the player console (console.*) never sets it.
|
||||
ViaAdminAccess bool
|
||||
// MustChangePassword is set only on the local-password (SessionAuth) path when
|
||||
// the staff account still owes a first-login change. The JWT path leaves it
|
||||
// false. The lockdown middleware fences such a principal to the change-password
|
||||
// and logout surface until it is cleared.
|
||||
MustChangePassword bool
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the principal may perform admin-tier operations.
|
||||
|
||||
@@ -49,6 +49,19 @@ var (
|
||||
errUnauthorized = newError(http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
errForbidden = newError(http.StatusForbidden, "forbidden", "not permitted")
|
||||
errBadRequest = newError(http.StatusBadRequest, "bad_request", "invalid request")
|
||||
// errInvalidCredentials is the single, deliberately vague answer to any failed
|
||||
// local-password login (spec §B): unknown username, player row, or wrong
|
||||
// password all collapse to it so the response never reveals which usernames
|
||||
// carry a password. The anti-enumeration dummy-hash compare keeps the timing
|
||||
// uniform alongside it (handlers_auth.go).
|
||||
errInvalidCredentials = newError(http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
|
||||
// errPasswordChangeRequired fences a staff principal that still owes a
|
||||
// first-login password change to the change-password surface. The lockdown
|
||||
// middleware returns it from every authenticated route except the opt-out set
|
||||
// (change-password / logout / me), so a half-onboarded account cannot act until
|
||||
// it sets its own password.
|
||||
errPasswordChangeRequired = newError(http.StatusForbidden, "password_change_required",
|
||||
"change your password before continuing")
|
||||
)
|
||||
|
||||
// writeJSON writes v as an indented JSON body with the given status.
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Local-password auth handlers (spec §B). Owner/Operator log in to op.console with
|
||||
// username+password when Zero Trust is not in front of the API (the demo's primary
|
||||
// web login, and the always-available break-glass-enabled path). These three
|
||||
// handlers are the whole surface: log in, log out, change password. `felis
|
||||
// breakGlass` mints/resets the credentials direct-to-Postgres; the panel never
|
||||
// creates a staff account.
|
||||
|
||||
// bcryptCost is the work factor for every password hash we write. It is read back
|
||||
// from each stored hash on compare, so raising it later re-hashes lazily on the
|
||||
// next change without invalidating existing hashes.
|
||||
const bcryptCost = bcrypt.DefaultCost
|
||||
|
||||
// dummyPasswordHash is a real bcrypt hash, at bcryptCost, of a throwaway value. A
|
||||
// failed login (unknown username, or a player row with no password) compares the
|
||||
// supplied password against it anyway, so the response time matches a real
|
||||
// password check and cannot be used to enumerate which usernames carry a password.
|
||||
// It is computed once at init — real and same-cost, never a short-circuit — and
|
||||
// the throwaway value is never a valid credential because the surrounding logic
|
||||
// rejects any login whose user has no stored hash regardless of the compare.
|
||||
var dummyPasswordHash = mustDummyHash()
|
||||
|
||||
func mustDummyHash() []byte {
|
||||
h, err := bcrypt.GenerateFromPassword([]byte("felis-anti-enumeration-placeholder"), bcryptCost)
|
||||
if err != nil {
|
||||
panic("bcrypt dummy hash: " + err.Error())
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// loginRequest is the op.console login form.
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// handleLogin verifies a username+password against the users row and, on success,
|
||||
// mints a server-side session cookie (spec §B). It is mounted Public — there is no
|
||||
// prior principal — but still requires local auth to be enabled, so a deployment
|
||||
// fronted entirely by Zero Trust never accepts a local password. Every failure
|
||||
// returns the same vague errInvalidCredentials after a uniform bcrypt compare.
|
||||
func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !localAuthEnabled(r.Context(), a.Repo) {
|
||||
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
|
||||
"local password login is disabled"))
|
||||
return
|
||||
}
|
||||
// Reject a non-JSON body before decoding: this is the public, credential-minting
|
||||
// route, so it is the cross-site-forgery surface requireJSONContentType closes.
|
||||
if err := requireJSONContentType(r); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var body loginRequest
|
||||
if err := decodeJSON(w, r, &body); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if body.Username == "" || body.Password == "" {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "username and password are required"))
|
||||
return
|
||||
}
|
||||
|
||||
u, err := a.Repo.UserByUsername(r.Context(), body.Username)
|
||||
if err != nil && !errIsNotFound(err) {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Anti-enumeration: always run a bcrypt compare, even on a missing user or a
|
||||
// player row (empty hash), against the dummy hash. The trailing guard makes the
|
||||
// missing-hash cases fail closed even if a caller supplied the dummy's plaintext.
|
||||
hash := dummyPasswordHash
|
||||
if u != nil && u.PasswordHash != "" {
|
||||
hash = []byte(u.PasswordHash)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword(hash, []byte(body.Password)) != nil || u == nil || u.PasswordHash == "" {
|
||||
writeError(w, r, errInvalidCredentials)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := newSessionToken()
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
expires := a.now().Add(sessionTTL)
|
||||
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), u.ID, expires); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, token, expires)
|
||||
a.audit(r, u.Username, "auth.login", "")
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"user_id": u.ID,
|
||||
"role": u.Role,
|
||||
"must_change_password": u.MustChangePassword,
|
||||
})
|
||||
}
|
||||
|
||||
// handleLogout revokes the presented session and clears the cookie (spec §B). It
|
||||
// is mounted Public and idempotent: it reads the cookie directly, so it works even
|
||||
// when the session has already expired and never errors on a missing one.
|
||||
func (a *API) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" {
|
||||
_ = a.Repo.RevokeSession(r.Context(), hashCookie(c.Value))
|
||||
}
|
||||
clearSessionCookie(w)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// changePasswordRequest is the change-password form.
|
||||
type changePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// handleChangePassword re-verifies the caller's current password, stores a new
|
||||
// bcrypt hash, clears must_change_password, and revokes the account's OTHER
|
||||
// sessions while keeping the current one (spec §B). It is reachable while
|
||||
// must_change_password is set (AllowDuringPasswordChange) so a forced first-login
|
||||
// change can complete. The session itself authenticates the caller; re-asking the
|
||||
// current password additionally blocks a hijacked session from silently rotating
|
||||
// the credential.
|
||||
func (a *API) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
p := principalFromContext(r.Context())
|
||||
|
||||
// Defense-in-depth: this route is already CSRF-safe (a session is required, the
|
||||
// cookie is SameSite=Lax, and the current password is re-verified below), but the
|
||||
// same content-type guard keeps every local-auth JSON write uniform.
|
||||
if err := requireJSONContentType(r); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var body changePasswordRequest
|
||||
if err := decodeJSON(w, r, &body); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := validateNewPassword(body.NewPassword); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
u, err := a.Repo.UserByID(r.Context(), p.UserID)
|
||||
switch {
|
||||
case errIsNotFound(err):
|
||||
// The session resolved a moment ago but the user is gone: treat as unauthenticated.
|
||||
writeError(w, r, errUnauthorized)
|
||||
return
|
||||
case err != nil:
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if u.PasswordHash == "" {
|
||||
// A link-only account has no password to change — it never reaches this path
|
||||
// in practice, but fail closed rather than set a first password here.
|
||||
writeError(w, r, errForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.CurrentPassword)) != nil {
|
||||
writeError(w, r, newError(http.StatusUnauthorized, "invalid_credentials", "current password is incorrect"))
|
||||
return
|
||||
}
|
||||
// The new password must actually differ from the current one.
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.NewPassword)) == nil {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "password_unchanged",
|
||||
"new password must differ from the current one"))
|
||||
return
|
||||
}
|
||||
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcryptCost)
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := a.Repo.SetPassword(r.Context(), u.ID, string(newHash)); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Log out the account's other devices, keeping the current session. The current
|
||||
// session is identified by the cookie hash; with no cookie (no live session to
|
||||
// keep) every session of the user is revoked, which is the safe direction.
|
||||
keep := ""
|
||||
if c, cerr := r.Cookie(sessionCookieName); cerr == nil {
|
||||
keep = hashCookie(c.Value)
|
||||
}
|
||||
if err := a.Repo.RevokeUserSessionsExcept(r.Context(), u.ID, keep); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.audit(r, u.Username, "auth.password_change", "")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// validateNewPassword enforces the minimal password policy: 8–72 bytes. The upper
|
||||
// bound is bcrypt's hard limit (it errors past 72 bytes), surfaced here as a clean
|
||||
// 400 rather than an opaque 500 from GenerateFromPassword.
|
||||
func validateNewPassword(pw string) error {
|
||||
if len(pw) < 8 {
|
||||
return newError(http.StatusBadRequest, "weak_password", "password must be at least 8 characters")
|
||||
}
|
||||
if len(pw) > 72 {
|
||||
return newError(http.StatusBadRequest, "weak_password", "password must be at most 72 bytes")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
// 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)}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -173,6 +173,10 @@ func (a *API) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
"email": p.Email,
|
||||
"role": p.Role,
|
||||
"is_admin": p.IsAdmin(),
|
||||
// must_change_password is meaningful only on the local-password path; the JWT
|
||||
// path leaves it false. The panel uses it to route a freshly-provisioned staff
|
||||
// account straight to the change-password card before any other surface.
|
||||
"must_change_password": p.MustChangePassword,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,23 @@ func (a *API) adminOnly(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// lockdownDuringPasswordChange fences a staff principal that still owes a
|
||||
// first-login password change to the change-password surface (spec §B). It is the
|
||||
// default-deny half of the lockdown: buildFace wraps every authenticated route
|
||||
// with it except the AllowDuringPasswordChange opt-outs, so a half-onboarded
|
||||
// account can do nothing but change its password, log out, or read /me. It is
|
||||
// nil-principal safe (the internal face sets no Principal), so it passes such
|
||||
// requests straight through and only ever acts on the external face.
|
||||
func (a *API) lockdownDuringPasswordChange(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if p := principalFromContext(r.Context()); p != nil && p.MustChangePassword {
|
||||
writeError(w, r, errPasswordChangeRequired)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// newRequestID returns a short random hex id. crypto/rand never fails on the
|
||||
// platforms we target; on the impossible error path we fall back to a constant
|
||||
// so a request still gets a (non-unique) id rather than crashing.
|
||||
|
||||
@@ -357,3 +357,150 @@ func (p *PGRepo) Audit(ctx context.Context, e AuditEntry) error {
|
||||
e.Actor, e.Source, e.Action, e.ServerName, e.RequestID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- local-password auth (spec §B) ----
|
||||
|
||||
// UserByUsername loads a staff login projection by username, or ErrNotFound. A
|
||||
// player row (NULL password_hash) is returned with an empty PasswordHash, never
|
||||
// hidden — the caller rejects it by the hash compare, so login cannot be used to
|
||||
// enumerate which usernames carry a password.
|
||||
func (p *PGRepo) UserByUsername(ctx context.Context, username string) (*StaffUser, error) {
|
||||
const q = `SELECT id, username, COALESCE(email, ''), role::text,
|
||||
COALESCE(password_hash, ''), must_change_password
|
||||
FROM users WHERE username = $1`
|
||||
var u StaffUser
|
||||
switch err := p.db.QueryRowContext(ctx, q, username).Scan(
|
||||
&u.ID, &u.Username, &u.Email, &u.Role, &u.PasswordHash, &u.MustChangePassword); {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, ErrNotFound
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// UserByID loads the same staff projection by id, or ErrNotFound. The
|
||||
// change-password flow re-verifies the caller's current password with it: the
|
||||
// session yields a user id, not a username.
|
||||
func (p *PGRepo) UserByID(ctx context.Context, id string) (*StaffUser, error) {
|
||||
const q = `SELECT id, username, COALESCE(email, ''), role::text,
|
||||
COALESCE(password_hash, ''), must_change_password
|
||||
FROM users WHERE id = $1`
|
||||
var u StaffUser
|
||||
switch err := p.db.QueryRowContext(ctx, q, id).Scan(
|
||||
&u.ID, &u.Username, &u.Email, &u.Role, &u.PasswordHash, &u.MustChangePassword); {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, ErrNotFound
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// UpsertOwner creates or resets the Owner account direct-to-Postgres (the
|
||||
// break-glass first-run / reset-password path). role is forced to 'admin'; on a
|
||||
// username conflict the email, hash and must_change_password flag are overwritten
|
||||
// while the existing id is preserved, so live sessions referencing it survive a
|
||||
// password reset. The empty email is stored as NULL (users.email is nullable).
|
||||
func (p *PGRepo) UpsertOwner(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error {
|
||||
_, err := p.db.ExecContext(ctx,
|
||||
`INSERT INTO users (id, username, email, role, password_hash, must_change_password)
|
||||
VALUES ($1, $2, NULLIF($3, ''), 'admin', $4, $5)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = NULLIF($3, ''), role = 'admin',
|
||||
password_hash = $4, must_change_password = $5`,
|
||||
id, username, email, passwordHash, mustChange)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetPassword stores a new hash and clears must_change_password (the panel
|
||||
// change-password flow). ErrNotFound when no row matches so a stale session
|
||||
// cannot silently no-op the change.
|
||||
func (p *PGRepo) SetPassword(ctx context.Context, userID, passwordHash string) error {
|
||||
res, err := p.db.ExecContext(ctx,
|
||||
`UPDATE users SET password_hash = $2, must_change_password = false WHERE id = $1`,
|
||||
userID, passwordHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateSession records a minted session by the sha-256 of its cookie value
|
||||
// (spec §B). Only the hash is stored, mirroring tokens.
|
||||
func (p *PGRepo) CreateSession(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error {
|
||||
_, err := p.db.ExecContext(ctx,
|
||||
`INSERT INTO sessions (token_hash, user_id, expires_at) VALUES ($1, $2, $3)`,
|
||||
tokenHash, userID, expiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// SessionUser resolves a live (unrevoked, unexpired at now) session hash to its
|
||||
// user, or ErrNotFound.
|
||||
func (p *PGRepo) SessionUser(ctx context.Context, tokenHash string, now time.Time) (*SessionedUser, error) {
|
||||
const q = `SELECT u.id, COALESCE(u.email, ''), u.role::text, u.must_change_password
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > $2`
|
||||
var u SessionedUser
|
||||
switch err := p.db.QueryRowContext(ctx, q, tokenHash, now).Scan(
|
||||
&u.ID, &u.Email, &u.Role, &u.MustChangePassword); {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, ErrNotFound
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// RevokeSession marks a session revoked (logout). Idempotent: a missing or
|
||||
// already-revoked session is not an error.
|
||||
func (p *PGRepo) RevokeSession(ctx context.Context, tokenHash string) error {
|
||||
_, err := p.db.ExecContext(ctx,
|
||||
`UPDATE sessions SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`,
|
||||
tokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokeUserSessionsExcept revokes every live session of a user except
|
||||
// keepTokenHash — the change-password flow logs out the account's other devices
|
||||
// while keeping the current one.
|
||||
func (p *PGRepo) RevokeUserSessionsExcept(ctx context.Context, userID, keepTokenHash string) error {
|
||||
_, err := p.db.ExecContext(ctx,
|
||||
`UPDATE sessions SET revoked_at = now()
|
||||
WHERE user_id = $1 AND token_hash <> $2 AND revoked_at IS NULL`,
|
||||
userID, keepTokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- runtime platform settings (spec §B platform_settings) ----
|
||||
|
||||
// GetSetting reads a setting's raw jsonb value as bytes, or ErrNotFound.
|
||||
func (p *PGRepo) GetSetting(ctx context.Context, key string) ([]byte, error) {
|
||||
var value []byte
|
||||
switch err := p.db.QueryRowContext(ctx,
|
||||
`SELECT value FROM platform_settings WHERE key = $1`, key).Scan(&value); {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, ErrNotFound
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// SetSetting upserts a setting's raw jsonb value by key. value is cast to jsonb
|
||||
// so a []byte argument lands in the jsonb column without a driver round-trip
|
||||
// guessing the type.
|
||||
func (p *PGRepo) SetSetting(ctx context.Context, key string, value []byte) error {
|
||||
_, err := p.db.ExecContext(ctx,
|
||||
`INSERT INTO platform_settings (key, value) VALUES ($1, $2::jsonb)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
||||
key, string(value))
|
||||
return err
|
||||
}
|
||||
@@ -65,6 +65,32 @@ type BackupRecord struct {
|
||||
SizeBytes int64
|
||||
}
|
||||
|
||||
// StaffUser is the login-side projection of a users row that carries a password
|
||||
// (spec §B local-auth). Owner/Operator are role=admin rows WITH a bcrypt hash,
|
||||
// minted by `felis breakGlass`; players are role=user rows whose PasswordHash is
|
||||
// empty. It is loaded by username at login to verify the password and learn
|
||||
// whether a first-login change is still pending.
|
||||
type StaffUser struct {
|
||||
ID string
|
||||
Username string
|
||||
Email string
|
||||
Role string
|
||||
PasswordHash string
|
||||
MustChangePassword bool
|
||||
}
|
||||
|
||||
// SessionedUser is the projection resolved from a live session cookie: the
|
||||
// identity SessionAuth needs to build a Principal. It omits the password hash —
|
||||
// the session has already authenticated the caller — but carries the pending
|
||||
// first-login change flag so the lockdown middleware can fence a half-onboarded
|
||||
// staff account to the change-password surface.
|
||||
type SessionedUser struct {
|
||||
ID string
|
||||
Email string
|
||||
Role string
|
||||
MustChangePassword bool
|
||||
}
|
||||
|
||||
// Repo is the business-layer data access the API depends on. It is an interface
|
||||
// so handlers are tested against an in-memory fake; the Postgres implementation
|
||||
// (pgRepo) is integration-tested only — it requires a live database.
|
||||
@@ -139,4 +165,50 @@ type Repo interface {
|
||||
SeedServer(ctx context.Context, name, subdomain string) error
|
||||
// Audit appends one audit row.
|
||||
Audit(ctx context.Context, e AuditEntry) error
|
||||
|
||||
// ---- local-password auth (spec §B) ----
|
||||
|
||||
// UserByUsername loads the login projection of a staff account by its unique
|
||||
// username, or ErrNotFound. The caller compares PasswordHash itself so the
|
||||
// anti-enumeration dummy-hash compare runs even on a miss; a player row (NULL
|
||||
// password_hash → empty PasswordHash) is returned too and is rejected by the
|
||||
// caller's hash compare, never by leaking "no such user".
|
||||
UserByUsername(ctx context.Context, username string) (*StaffUser, error)
|
||||
// UserByID loads the same staff projection by user id, or ErrNotFound. The
|
||||
// change-password flow uses it to re-verify the caller's current password: the
|
||||
// session yields a user id, not a username, so this is the id-keyed counterpart
|
||||
// of UserByUsername.
|
||||
UserByID(ctx context.Context, id string) (*StaffUser, error)
|
||||
// UpsertOwner creates or resets the single Owner account direct-to-Postgres
|
||||
// (the `felis breakGlass` first-run / reset-password path). role is forced to
|
||||
// 'admin' and must_change_password to mustChange; on a username conflict the
|
||||
// existing row's email, hash and flag are overwritten so a reset is idempotent.
|
||||
UpsertOwner(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error
|
||||
// SetPassword stores a new bcrypt hash for a user and clears
|
||||
// must_change_password (the panel change-password flow). ErrNotFound when no
|
||||
// row matches, so a stale session cannot silently no-op a password change.
|
||||
SetPassword(ctx context.Context, userID, passwordHash string) error
|
||||
// CreateSession records a minted session: the sha-256 of the opaque cookie
|
||||
// value, its owner, and its expiry (spec §B sessions). Only the hash is stored,
|
||||
// mirroring tokens, so a database read never yields a usable cookie.
|
||||
CreateSession(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error
|
||||
// SessionUser resolves a live (unrevoked, unexpired at now) session hash to its
|
||||
// user, or ErrNotFound. It is the cookie half of SessionAuth.
|
||||
SessionUser(ctx context.Context, tokenHash string, now time.Time) (*SessionedUser, error)
|
||||
// RevokeSession marks a session revoked (logout). It is idempotent: revoking an
|
||||
// absent or already-revoked session is not an error.
|
||||
RevokeSession(ctx context.Context, tokenHash string) error
|
||||
// RevokeUserSessionsExcept revokes every live session of a user except the one
|
||||
// whose hash is keepTokenHash. The change-password flow calls it so a successful
|
||||
// password change logs out the account's other devices but not the current one.
|
||||
RevokeUserSessionsExcept(ctx context.Context, userID, keepTokenHash string) error
|
||||
|
||||
// ---- runtime platform settings (spec §B platform_settings) ----
|
||||
|
||||
// GetSetting reads a runtime setting's raw jsonb value, or ErrNotFound when the
|
||||
// key is absent. The live API reads these per-request so the break-glass TUI can
|
||||
// flip toggles (e.g. local_auth_enabled) direct-to-DB without rolling the pod.
|
||||
GetSetting(ctx context.Context, key string) ([]byte, error)
|
||||
// SetSetting upserts a runtime setting's raw jsonb value by key.
|
||||
SetSetting(ctx context.Context, key string, value []byte) error
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Local-password sessions (spec §B). The remote face authenticates statelessly
|
||||
// with a Cloudflare-Access JWT and sets no cookie; local-password auth, used on
|
||||
// op.console when Zero Trust is not configured (and as the demo's primary web
|
||||
// login), needs a server-minted session. We store only the sha-256 of the opaque
|
||||
// cookie value, mirroring how service tokens are stored, so a database read never
|
||||
// yields a usable cookie.
|
||||
|
||||
const (
|
||||
// sessionCookieName is the host-only session cookie. It carries no Domain
|
||||
// attribute, so an op.console session is never sent to the player console.
|
||||
sessionCookieName = "felis_session"
|
||||
// sessionTTL bounds a local-password session. Staff re-authenticate after it.
|
||||
sessionTTL = 12 * time.Hour
|
||||
// localAuthEnabledKey gates whether local-password sessions are honored. It is
|
||||
// flipped on by `felis breakGlass` direct-to-Postgres at first-run and read
|
||||
// live per-request, so enabling local auth needs no pod roll.
|
||||
localAuthEnabledKey = "local_auth_enabled"
|
||||
)
|
||||
|
||||
// newSessionToken returns a fresh opaque session value (256 bits, URL-safe). It
|
||||
// is the cookie value; only its hash is persisted.
|
||||
func newSessionToken() (string, error) {
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("generate session token: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
// hashCookie maps a cookie value to its storage key (sha-256 hex), so the raw
|
||||
// cookie is never written to the database.
|
||||
func hashCookie(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// setSessionCookie writes the session cookie: HttpOnly + Secure + SameSite=Lax,
|
||||
// host-only (no Domain), rooted at "/". Secure means the console must be served
|
||||
// over HTTPS — already a hard requirement, since WebAuthn and Zero Trust both
|
||||
// demand a secure context.
|
||||
func setSessionCookie(w http.ResponseWriter, value string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
Expires: expires,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// clearSessionCookie expires the session cookie (logout). The attributes must
|
||||
// match setSessionCookie for the browser to overwrite it.
|
||||
func clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// hostIsAdminConsole reports whether the request arrived on the operator console
|
||||
// host, op.console.<root_domain>. The session cookie is host-only, so a session
|
||||
// minted on op.console is structurally unable to reach the player console; this
|
||||
// is the local-auth analogue of the admin Access path. The Host the API sees must
|
||||
// be the real client Host (the ingress must forward it), which the VM check
|
||||
// verifies.
|
||||
func hostIsAdminConsole(r *http.Request, rootDomain string) bool {
|
||||
if rootDomain == "" {
|
||||
return false
|
||||
}
|
||||
host := r.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
want := "op.console." + rootDomain
|
||||
return strings.EqualFold(strings.TrimSuffix(host, "."), want)
|
||||
}
|
||||
|
||||
// SessionAuth is the composite ExternalAuth for the web face. It prefers a
|
||||
// local-password session cookie and otherwise delegates to the remote JWT
|
||||
// verifier, so both auth models coexist on one face:
|
||||
//
|
||||
// - No cookie → delegate to Delegate (the Cloudflare-Access JWT path).
|
||||
// - Cookie set → local auth MUST be enabled (a missing or non-true
|
||||
// local_auth_enabled setting is treated as disabled — fail closed); the
|
||||
// session hash must resolve to a live user. On any failure the request is
|
||||
// rejected and does NOT fall through to the JWT delegate, so a stale or
|
||||
// forged cookie can never be laundered into a JWT attempt.
|
||||
type SessionAuth struct {
|
||||
Repo Repo
|
||||
Delegate ExternalAuth
|
||||
RootDomain string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (s SessionAuth) now() time.Time {
|
||||
if s.Now != nil {
|
||||
return s.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// Authenticate resolves the caller from a session cookie or delegates to the JWT
|
||||
// verifier (see the type comment for the fail-closed rules).
|
||||
func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
// No usable session cookie: this is the remote JWT path.
|
||||
if s.Delegate == nil {
|
||||
return nil, fmt.Errorf("external auth not configured")
|
||||
}
|
||||
return s.Delegate.Authenticate(r)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if !localAuthEnabled(ctx, s.Repo) {
|
||||
// A cookie was presented but local auth is off: reject, never fall through.
|
||||
return nil, fmt.Errorf("local auth disabled")
|
||||
}
|
||||
|
||||
u, err := s.Repo.SessionUser(ctx, hashCookie(cookie.Value), s.now())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid session: %w", err)
|
||||
}
|
||||
return &Principal{
|
||||
UserID: u.ID,
|
||||
Email: u.Email,
|
||||
Role: u.Role,
|
||||
ViaAdminAccess: u.Role == "admin" && hostIsAdminConsole(r, s.RootDomain),
|
||||
MustChangePassword: u.MustChangePassword,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// localAuthEnabled reports whether the runtime local_auth_enabled toggle is true.
|
||||
// A missing setting, a read error, or a non-true value all read as disabled — the
|
||||
// gate fails closed so local sessions are honored, and new ones minted, only on an
|
||||
// explicit opt-in. Both SessionAuth (honoring a cookie) and the login handler
|
||||
// (minting one) consult it, so the two never disagree about whether local auth is
|
||||
// live.
|
||||
func localAuthEnabled(ctx context.Context, repo Repo) bool {
|
||||
raw, err := repo.GetSetting(ctx, localAuthEnabledKey)
|
||||
if err != nil {
|
||||
return false // ErrNotFound (never enabled) or a transient read error → closed
|
||||
}
|
||||
var enabled bool
|
||||
if err := json.Unmarshal(raw, &enabled); err != nil {
|
||||
return false
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
|
||||
// ensure SessionAuth satisfies ExternalAuth at compile time.
|
||||
var _ ExternalAuth = SessionAuth{}
|
||||
|
||||
// errIsNotFound is a small helper so handlers can branch on the repo's sentinel
|
||||
// without importing errors at every call site.
|
||||
func errIsNotFound(err error) bool { return errors.Is(err, ErrNotFound) }
|
||||
@@ -2,12 +2,33 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxBodyBytes caps request bodies; the API only accepts small JSON documents.
|
||||
const maxBodyBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// requireJSONContentType rejects a request whose body is not declared
|
||||
// application/json, returning 415 before any decode. It guards the credential-bearing
|
||||
// auth writes (login, change-password) against a cross-site forgery: an HTML form can
|
||||
// only POST as application/x-www-form-urlencoded, multipart/form-data, or text/plain
|
||||
// — never JSON — and a cross-site fetch that forces application/json triggers a CORS
|
||||
// preflight this API never answers, so neither form can be forged off-origin. The
|
||||
// session cookie's SameSite=Lax already blocks the bearing of credentials cross-site;
|
||||
// this is the belt to that suspenders, and it costs a legitimate same-origin caller
|
||||
// nothing (the panel always sends application/json on a bodied request). Media-type
|
||||
// parameters (e.g. "; charset=utf-8") are ignored — only the type/subtype must match.
|
||||
func requireJSONContentType(r *http.Request) error {
|
||||
mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || !strings.EqualFold(mt, "application/json") {
|
||||
return newError(http.StatusUnsupportedMediaType, "unsupported_media_type",
|
||||
"Content-Type must be application/json")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeJSON strictly decodes a small request body into v, rejecting unknown
|
||||
// fields and trailing data so malformed callers fail fast with 400.
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) error {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Phase B local-password auth + sessions + runtime settings.
|
||||
-- The web (op.console) authenticates Owner/Operator via username+password;
|
||||
-- `felis breakGlass` (the sudo-only emergency TUI) mints/resets these directly
|
||||
-- against Postgres so recovery works even when the API is down. Players keep
|
||||
-- password_hash NULL (account-link identity only — see account_links).
|
||||
|
||||
-- Owner/Operator credentials live on the existing users row, not a separate
|
||||
-- table: role=admin WITH a hash is staff; role=user with NULL hash is a player.
|
||||
ALTER TABLE users
|
||||
ADD COLUMN password_hash text, -- bcrypt; NULL for link-only players
|
||||
ADD COLUMN must_change_password boolean NOT NULL DEFAULT false; -- force change-on-first-login
|
||||
|
||||
-- Server-set httpOnly session cookies. The remote path authenticates with a
|
||||
-- stateless Cloudflare-Access JWT (no cookie); local-password auth needs its
|
||||
-- own session. Store only the hash of the opaque cookie value, mirroring tokens.
|
||||
CREATE TABLE sessions (
|
||||
token_hash text PRIMARY KEY, -- sha-256(cookie value)
|
||||
user_id text NOT NULL REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
revoked_at timestamptz -- non-NULL once invalidated
|
||||
);
|
||||
CREATE INDEX sessions_user_id_idx ON sessions (user_id);
|
||||
|
||||
-- Runtime security/platform settings as jsonb. The live API reads these from
|
||||
-- Postgres per-request (NOT the read-only felis-config Secret), so the
|
||||
-- break-glass TUI can flip toggles (e.g. local_auth_enabled) direct-to-DB
|
||||
-- without patching the Secret and rolling the pod.
|
||||
CREATE TABLE platform_settings (
|
||||
key text PRIMARY KEY, -- e.g. 'local_auth_enabled'
|
||||
value jsonb NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
Reference in new issue
Block a user