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:
flyemoji committed 2026-06-27 04:22:45 +09:00
1 parent 58fa4b0af8
commit af14f02f38
17 files changed
+1370 -17

No files matched your search

+18 -4
View File
@@ -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
View File
@@ -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'
+26 -4
View File
@@ -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
+50 -4
View File
@@ -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
View File
@@ -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))
+106
View File
@@ -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
View File
@@ -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.
+13
View File
@@ -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.
+219
View File
@@ -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
}
+275
View File
@@ -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)
}
})
}
+4
View File
@@ -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,
})
}
+17
View File
@@ -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.
+147
View File
@@ -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
}
+72
View File
@@ -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
}
+180
View File
@@ -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) }
+21
View File
@@ -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()
);