diff --git a/cmd/felis/api.go b/cmd/felis/api.go index 7a51dfe..3c4754f 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -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, } diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 29809de..5c33d42 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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' diff --git a/go.mod b/go.mod index 25f2de5..c02b97d 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index a7fed77..6186f96 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/api/api.go b/internal/api/api.go index 8a4376e..5b13242 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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)) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 5f063e1..56d82a9 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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). diff --git a/internal/api/auth.go b/internal/api/auth.go index c606294..f1b218a 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -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. diff --git a/internal/api/errors.go b/internal/api/errors.go index 5e64b11..aeaf974 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -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. diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go new file mode 100644 index 0000000..2080415 --- /dev/null +++ b/internal/api/handlers_auth.go @@ -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 +} diff --git a/internal/api/handlers_auth_test.go b/internal/api/handlers_auth_test.go new file mode 100644 index 0000000..88392ac --- /dev/null +++ b/internal/api/handlers_auth_test.go @@ -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) + } + }) +} diff --git a/internal/api/handlers_user.go b/internal/api/handlers_user.go index 06bd569..070bb11 100644 --- a/internal/api/handlers_user.go +++ b/internal/api/handlers_user.go @@ -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, }) } diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 6f41463..0512597 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -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. diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index 3b033f1..ba122a0 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -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 +} diff --git a/internal/api/repo.go b/internal/api/repo.go index ad63908..bb54adc 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -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 } diff --git a/internal/api/session.go b/internal/api/session.go new file mode 100644 index 0000000..243bed1 --- /dev/null +++ b/internal/api/session.go @@ -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.. 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) } diff --git a/internal/api/util.go b/internal/api/util.go index 6f57b5e..9a61de5 100644 --- a/internal/api/util.go +++ b/internal/api/util.go @@ -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 { diff --git a/internal/store/migrations/0003_local_auth.sql b/internal/store/migrations/0003_local_auth.sql new file mode 100644 index 0000000..829efd6 --- /dev/null +++ b/internal/store/migrations/0003_local_auth.sql @@ -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() +);