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
@@ -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) }
|
||||
Reference in new issue
Block a user