package api import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "log" "net" "net/http" "net/netip" "strings" "time" ) // Local sessions (spec §B, passwordless). Every sign-in door (email-OTP, passkey, // bind code, op-login, setup redeem) ends in a server-minted session, the external // face's only credential. // 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 session. Staff re-authenticate after it. sessionTTL = 12 * time.Hour // staffSessionIdle ends a staff session that has authenticated no request for // this long; a player session has only sessionTTL. Any authenticated request // counts, a panel tab's background refresh included, so what this ends is a // session left behind in a closed tab or on a machine that went to sleep. staffSessionIdle = 30 * time.Minute // sessionTouchEvery is how stale a session's last_seen_at may grow before a // request advances it: an active session costs one write a minute, not one per // request, and the idle limit is honored to within this. sessionTouchEvery = time.Minute // maxSessionUserAgent caps the User-Agent a session keeps to name its device. maxSessionUserAgent = 256 ) // LocalAuthEnabledKey is the platform_settings key that gates whether // local 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. Exported so the break-glass writer and this // per-request reader share one source of truth instead of drifting copies. const 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[:]) } // signInProof says whether the sign-in minting a session proved a factor of the // account. A proven sign-in counts as a fresh reauth, so the new session may add // a passkey or change the email straight away (requireReauth). type signInProof bool const ( // provenSignIn: a passkey, an email code, op-login or the setup token. provenSignIn signInProof = true // bindCodeSignIn: the in-game identity alone, which never unlocks the // account's other factors. bindCodeSignIn signInProof = false ) // startSession mints a session for userID and sets its cookie. Every sign-in door // ends here (or at mintSession), so every session records the device it was // minted for. func (a *API) startSession(w http.ResponseWriter, r *http.Request, userID string, proof signInProof) error { token, s, err := a.mintSession(r, userID, proof) if err != nil { return err } if err := a.Repo.CreateSession(r.Context(), s); err != nil { return err } setSessionCookie(w, token, s.ExpiresAt) return nil } // mintSession makes a session cookie value and the row that stores it, for a door // that writes the row itself. func (a *API) mintSession(r *http.Request, userID string, proof signInProof) (string, NewSession, error) { token, err := newSessionToken() if err != nil { return "", NewSession{}, err } now := a.now() expires := now.Add(sessionTTL) ip := "" if addr := a.clientIP(r); addr.IsValid() { ip = addr.String() } var reauth time.Time if proof == provenSignIn { reauth = now } return token, NewSession{ TokenHash: hashCookie(token), UserID: userID, CreatedAt: now, ExpiresAt: expires, UserAgent: truncateUTF8(r.UserAgent(), maxSessionUserAgent), ClientIP: ip, ReauthAt: reauth, }, nil } // currentSessionHash is the storage key of the session cookie r carries, or "" // when it carries none. func currentSessionHash(r *http.Request) string { c, err := r.Cookie(sessionCookieName) if err != nil || c.Value == "" { return "" } return hashCookie(c.Value) } // 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 configured // operator console host. The session cookie is host-only, so a session minted on // the admin host is structurally unable to reach the player console. If older // configs omit [auth].admin_hostname, fall back to op.console.. // // A bare IP counts only when the install names it: the address a // .nip.io / .sslip.io root domain embeds (what `felis setup` prints as // the local panel URL when wildcard DNS is unavailable), or an admin_hostname // set to an IP. The Host header is the client's to choose, so "any loopback or // private address" would let anyone who reaches the origin's port present // Host: 10.0.0.1 and be graded as the operator console. func hostIsAdminConsole(r *http.Request, rootDomain, adminHostname string) bool { want := strings.TrimSpace(adminHostname) if want == "" { if rootDomain == "" { return false } want = "op.console." + rootDomain } host := r.Host if h, _, err := net.SplitHostPort(host); err == nil { host = h } if ip, err := netip.ParseAddr(strings.Trim(host, "[]")); err == nil { ip = ip.Unmap() if named, err := netip.ParseAddr(strings.Trim(want, "[]")); err == nil && named.Unmap() == ip { return true } embedded, ok := rootDomainIP(rootDomain) return ok && embedded == ip } return strings.EqualFold(strings.TrimSuffix(host, "."), strings.TrimSuffix(want, ".")) } // rootDomainIP is the address a wildcard-DNS root domain spells out: // 10.0.0.5.nip.io and 10.0.0.5.sslip.io both name 10.0.0.5. func rootDomainIP(rootDomain string) (netip.Addr, bool) { domain := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(rootDomain), ".")) for _, suffix := range []string{".nip.io", ".sslip.io"} { if base, ok := strings.CutSuffix(domain, suffix); ok { if ip, err := netip.ParseAddr(base); err == nil { return ip.Unmap(), true } } } return netip.Addr{}, false } // SessionAuth is the ExternalAuth for the web face: the local session cookie // the sign-in doors mint. There is no other credential; Cloudflare Access, when // the install sits behind it, is enforced at the edge. // // - No cookie → unauthenticated. // - 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. type SessionAuth struct { Repo Repo RootDomain string AdminHostname 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 the session cookie (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 == "" { return nil, fmt.Errorf("no session") } ctx := r.Context() enabled, err := localAuthEnabledStatus(ctx, s.Repo) if err != nil { // The session store is unreachable: this is an outage, not a verdict on // the caller's credentials, so the middleware answers 503 rather than a // misleading "please log in". return nil, fmt.Errorf("%w: %v", errAuthBackend, err) } if !enabled { // A cookie was presented but local auth is off: reject. return nil, fmt.Errorf("local auth disabled") } hash, now := hashCookie(cookie.Value), s.now() u, err := s.Repo.SessionUser(ctx, hash, now) switch { case errors.Is(err, ErrNotFound): return nil, fmt.Errorf("invalid session: %w", err) case err != nil: return nil, fmt.Errorf("%w: %v", errAuthBackend, err) } if now.Sub(u.LastSeenAt) >= sessionTouchEvery { // A failed touch costs at most an early idle sign-out, so the request goes on. if err := s.Repo.TouchSession(ctx, hash, now); err != nil { log.Printf("api: record session activity (request_id=%s): %v", requestIDFromContext(ctx), err) } } return &Principal{ UserID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role, ViaAdminAccess: staffRole(u.Role) && hostIsAdminConsole(r, s.RootDomain, s.AdminHostname), EmailVerified: u.EmailVerified, ViaSession: true, ReauthAt: u.ReauthAt, }, nil } // errAuthBackend marks an authentication failure caused by the session store // being unreachable (e.g. Postgres down) rather than by a missing or invalid // credential. Middleware maps it to 503 so an outage is not misreported as 401. var errAuthBackend = errors.New("auth backend unavailable") // 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 { enabled, _ := localAuthEnabledStatus(ctx, repo) return enabled } // localAuthEnabledStatus is localAuthEnabled with the outage case kept apart: a // MISSING setting (ErrNotFound — never enabled) reads as (false, nil), while a // store read failure reads as (false, err) so SessionAuth can tell "local auth // is off" (401) from "the database is down" (503). An unreadable value still // fails closed as disabled — it is a config fault, not an outage. func localAuthEnabledStatus(ctx context.Context, repo Repo) (bool, error) { raw, err := repo.GetSetting(ctx, LocalAuthEnabledKey) switch { case errors.Is(err, ErrNotFound): return false, nil case err != nil: return false, err } var enabled bool if err := json.Unmarshal(raw, &enabled); err != nil { return false, nil } return enabled, nil } // ensure SessionAuth satisfies ExternalAuth at compile time. var _ ExternalAuth = SessionAuth{}