Phase 16: Petal authenticates for itself
Petal is now an OIDC client in its own right rather than trusting a header from the proxy. The Phase-0 Resolver seam was the only integration point: main.go picks the session store when Authentik is configured and the static local user otherwise, and no handler or query moved for either. internal/auth gains three pieces. session.go issues an opaque cookie token and stores only its SHA-256, so a database copy yields nothing usable; the 30-day expiry slides on every request, throttled to one write an hour, and logout deletes the row rather than just the cookie. oidc.go runs the authorization-code flow with state, nonce and PKCE, and discovers the provider lazily and on retry — an Authentik outage should block new logins without stopping Petal booting or invalidating live sessions. users.go provisions accounts from the token's claims and gates them on an allowlist that matches emails as well as subject ids, since a subject is an opaque uuid that doesn't exist until someone has already logged in once. Migration 0010 lands sessions, images and users.pair_lang together. The images table closes the capability-URL hole the Phase-0 audit flagged: a hash was previously enough to fetch anyone's picture. Rows are keyed (name, user_id) so one file can have several owners and deduplication survives; a stranger gets 404 rather than 403, the cache header drops to private, and files already on disk are claimed at startup or every image already pasted into a document would 404. On the frontend a single 401 interceptor feeds a warm bilingual sign-in overlay, drawn over a still-visible editor because nothing has been taken away. Behind it is the part that matters: a save that comes back 401 stashes its body to localStorage before anything else and stops the auto-save loop, and reopening that document after signing in merges the draft back and saves it. An expired session must not cost writing. Writing the round-trip test against a stub identity provider turned up a real bug: the one-shot state/nonce/PKCE cookies were cleared in a defer, which runs after the redirect has written the response header, so the clearing Set-Cookie was silently dropped and they lingered for their full ten minutes. Also swaps the emoji favicon for a drawn sakura, which renders as Petal's own rose palette everywhere instead of whatever each platform's font decides, and doubles as the app tile in Authentik. Migration 0010 verified against a VACUUM INTO copy of the live millenia database: counts intact, FTS still matching, the one existing image claimed. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionCookie is the cookie carrying the opaque session token.
|
||||
const SessionCookie = "petal_session"
|
||||
|
||||
const (
|
||||
// sessionTTL is how long a session lives without use. Thirty days, sliding:
|
||||
// every authenticated request pushes the expiry back out. An editor that
|
||||
// logs you out mid-draft is hostile, and Petal auto-saves every 1.5s, so a
|
||||
// surprise 401 costs real writing.
|
||||
sessionTTL = 30 * 24 * time.Hour
|
||||
|
||||
// sessionTTLModifier is the same span as a SQLite datetime() modifier. All
|
||||
// expiry math happens inside SQLite so stored values stay canonical UTC and
|
||||
// never depend on the server's local clock or on Go/SQLite parsing agreeing.
|
||||
sessionTTLModifier = "+30 days"
|
||||
|
||||
// sessionRenewAfter throttles the sliding extension: a session is only
|
||||
// pushed forward once its expiry has drifted this far from the maximum. It
|
||||
// turns "a write on every request" into "a write at most once an hour per
|
||||
// session" while leaving the sliding window indistinguishable to the user.
|
||||
sessionRenewAfter = "-1 hour"
|
||||
)
|
||||
|
||||
// ErrNoSession means the request carried no session cookie, or one that is
|
||||
// unknown or expired. It is not an internal failure: the caller is simply not
|
||||
// signed in.
|
||||
var ErrNoSession = errors.New("no valid session")
|
||||
|
||||
// SessionStore issues, validates and revokes login sessions, and is itself the
|
||||
// [Resolver] the API middleware runs on.
|
||||
//
|
||||
// The cookie holds a random token; the table stores only its SHA-256. A dump of
|
||||
// the database therefore hands an attacker no usable session — the same reason
|
||||
// passwords are never stored as given. Server-side rows (rather than a signed
|
||||
// stateless cookie) are what make logout and revocation actually revoke.
|
||||
type SessionStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSessionStore returns a store backed by the given database.
|
||||
func NewSessionStore(db *sql.DB) *SessionStore { return &SessionStore{db: db} }
|
||||
|
||||
// Create issues a new session for userID and returns the token to put in the
|
||||
// cookie. The token is never stored; only its hash is.
|
||||
func (s *SessionStore) Create(userID, userAgent string) (string, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||
|
||||
if len(userAgent) > 256 {
|
||||
userAgent = userAgent[:256]
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO sessions (id, user_id, expires_at, user_agent)
|
||||
VALUES (?, ?, datetime('now', ?), ?)`,
|
||||
hashToken(token), userID, sessionTTLModifier, userAgent,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Resolve implements [Resolver]: it reads the session cookie, validates it, and
|
||||
// returns the user it belongs to — extending the session's life while it does.
|
||||
func (s *SessionStore) Resolve(r *http.Request) (string, error) {
|
||||
c, err := r.Cookie(SessionCookie)
|
||||
if err != nil || c.Value == "" {
|
||||
return "", ErrNoSession
|
||||
}
|
||||
return s.userFor(c.Value)
|
||||
}
|
||||
|
||||
// userFor validates a raw token and slides its expiry forward.
|
||||
func (s *SessionStore) userFor(token string) (string, error) {
|
||||
id := hashToken(token)
|
||||
|
||||
var userID string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT user_id FROM sessions WHERE id = ? AND expires_at > datetime('now')`, id,
|
||||
).Scan(&userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNoSession
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Slide the window. Throttled, and deliberately not fatal: a failed
|
||||
// extension shortens one session's life, which is no reason to reject a
|
||||
// request that is otherwise perfectly authenticated.
|
||||
_, _ = s.db.Exec(
|
||||
`UPDATE sessions SET expires_at = datetime('now', ?)
|
||||
WHERE id = ? AND expires_at < datetime('now', ?, ?)`,
|
||||
sessionTTLModifier, id, sessionTTLModifier, sessionRenewAfter,
|
||||
)
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// Revoke deletes the session behind a token. Unknown tokens are not an error —
|
||||
// signing out of a session that is already gone is a success, not a failure.
|
||||
func (s *SessionStore) Revoke(token string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM sessions WHERE id = ?`, hashToken(token))
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokeAll deletes every session for a user, signing them out everywhere.
|
||||
func (s *SessionStore) RevokeAll(userID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM sessions WHERE user_id = ?`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Prune removes expired rows and returns how many it deleted. Nothing depends
|
||||
// on it for correctness — expired sessions are already rejected on lookup — it
|
||||
// just keeps the table from accumulating dead rows forever.
|
||||
func (s *SessionStore) Prune() (int64, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= datetime('now')`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// hashToken maps a raw session token to the id stored in the table.
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// SetSessionCookie writes the session cookie. Secure is set only when Petal is
|
||||
// served over https — flagging it on a plain-http dev server would make the
|
||||
// browser drop the cookie and silently break local login.
|
||||
func SetSessionCookie(w http.ResponseWriter, token string, secure bool) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(sessionTTL / time.Second),
|
||||
})
|
||||
}
|
||||
|
||||
// ClearSessionCookie expires the session cookie in the browser. The matching
|
||||
// server-side row must be revoked separately — that's the half that counts.
|
||||
func ClearSessionCookie(w http.ResponseWriter, secure bool) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user