feat(api): local-password authentication backend
Add username+password login for Owner/Operator staff accounts on op.console, the primary web login when Zero Trust is not in front of the API. Three handlers form the whole surface: login mints a server-side session cookie, logout revokes it idempotently, and change-password re-verifies the current password before rotating the hash and clearing must_change_password. - Session cookies are HttpOnly+Secure+SameSite=Lax, host-only, stored server-side as a SHA-256 hash with a 12h TTL. - Login is anti-enumeration: every failure runs a uniform bcrypt compare against a dummy hash and returns the same vague error. - Credential-bearing writes require Content-Type: application/json, returning 415 otherwise, to close the cross-site form-POST forgery vector as a belt to the SameSite cookie. - Local auth fails closed: login is rejected unless local_auth_enabled is set, so a Zero-Trust-only deployment never accepts a local password. - Extend the users table with a nullable password_hash and must_change_password; staff are role=admin rows with a hash, players are role=user rows with hash NULL. - /me now reports must_change_password so the panel can force a first-login change. Covered by Go unit tests (handlers, content-type guard, anti-enumeration, forced-change lockdown) and the OpenAPI route-parity gate.
This commit is contained in:
17 files changed
+1370
-17
No files matched your search
+18
-4
@@ -122,8 +122,14 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintln(stderr, "felis api: restore executor disabled (needs FELIS_IMAGE and FELIS_BACKUP_PVC) — restore endpoint returns 503")
|
||||
}
|
||||
|
||||
// One PGRepo instance backs both the handlers and the session verifier: the
|
||||
// SessionAuth that fronts the external face reads sessions/users/settings from
|
||||
// the same store the auth handlers write to, so a login and the next request
|
||||
// agree on what local auth knows.
|
||||
repo := api.NewPGRepo(drv.DB())
|
||||
|
||||
a := &api.API{
|
||||
Repo: api.NewPGRepo(drv.DB()),
|
||||
Repo: repo,
|
||||
Cluster: api.NewK8sCluster(cl, cfg.K8s.Namespace),
|
||||
Console: api.NewK8sConsole(cl, cfg.K8s.Namespace),
|
||||
Logs: api.NewK8sLogStreamer(clientset, cfg.K8s.Namespace),
|
||||
@@ -134,9 +140,17 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
||||
Builder: builder,
|
||||
Restorer: restorer,
|
||||
Submissions: submissions,
|
||||
// Keyfunc is intentionally nil: the external face fails closed until a
|
||||
// JWKS-backed key function is wired (deployment integration point).
|
||||
External: api.AccessVerifier{Audience: cfg.Auth.AccessJWTAud},
|
||||
// The external face is fronted by SessionAuth: it prefers a local-password
|
||||
// session cookie and otherwise delegates to the Cloudflare-Access JWT verifier,
|
||||
// so both auth models coexist on one face. The delegate's Keyfunc is
|
||||
// intentionally nil — the JWT path fails closed until a JWKS-backed key function
|
||||
// is wired (deployment integration point) — while the local-password path is
|
||||
// live the moment `felis breakGlass` flips local_auth_enabled on.
|
||||
External: api.SessionAuth{
|
||||
Repo: repo,
|
||||
Delegate: api.AccessVerifier{Audience: cfg.Auth.AccessJWTAud},
|
||||
RootDomain: cfg.Server.RootDomain,
|
||||
},
|
||||
RootDomain: cfg.Server.RootDomain,
|
||||
WakeCooldown: 30 * time.Second,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user