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:
prosolis
2026-07-27 07:21:32 -07:00
parent 42d857a878
commit 1cf207d73f
30 changed files with 2407 additions and 97 deletions
+386
View File
@@ -0,0 +1,386 @@
package auth
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"log"
"net/http"
"strings"
"sync"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/go-chi/chi/v5"
"golang.org/x/oauth2"
)
// Temporary cookies that carry one login attempt from /auth/login to
// /auth/callback. They live for ten minutes and are cleared the moment the
// callback runs.
const (
stateCookie = "petal_oidc_state"
nonceCookie = "petal_oidc_nonce"
pkceCookie = "petal_oidc_pkce"
loginAttemptTTL = 600 // seconds
)
// Options configures the OIDC client.
type Options struct {
IssuerURL string // Authentik's issuer, e.g. https://auth.example.com/application/o/petal/
ClientID string
ClientSecret string
BaseURL string // Petal's public base URL; the redirect URI is derived from it
Allowed Allowlist
}
// OIDC implements Petal's half of an authorization-code login against
// Authentik: /auth/login starts it, /auth/callback finishes it by provisioning
// the account and issuing a session, /auth/logout ends it.
//
// Petal is the OIDC client itself rather than trusting a proxy-injected header.
// The header approach is far less code, but it is only safe while the container
// is unreachable except through that proxy — an invariant enforced by network
// configuration, not by anything in the repository, on a public host that also
// runs half a dozen other services. Petal holds someone's private journals; it
// should be safe to expose directly.
type OIDC struct {
opts Options
sessions *SessionStore
users *UserStore
secure bool
// The provider is discovered over the network, which means it can fail at
// startup for reasons that have nothing to do with Petal. Discovery is
// therefore lazy and retried: an Authentik outage blocks new logins but
// leaves every existing session working, since those only need the database.
mu sync.Mutex
provider *oidc.Provider
oauth *oauth2.Config
verifier *oidc.IDTokenVerifier
}
// NewOIDC builds the login flow. It attempts discovery once so a misconfigured
// issuer shows up in the startup log rather than on the writer's first login,
// but a failure here is not fatal.
func NewOIDC(ctx context.Context, opts Options, sessions *SessionStore, users *UserStore) *OIDC {
o := &OIDC{
opts: opts,
sessions: sessions,
users: users,
secure: strings.HasPrefix(strings.ToLower(opts.BaseURL), "https://"),
}
if err := o.discover(ctx); err != nil {
log.Printf("auth: OIDC discovery failed (%v) — login will retry on demand", err)
}
return o
}
// RedirectURI is the callback Authentik must have registered for this client.
func (o *OIDC) RedirectURI() string {
return strings.TrimSuffix(o.opts.BaseURL, "/") + "/auth/callback"
}
// discover resolves the provider metadata and builds the oauth2 config.
func (o *OIDC) discover(ctx context.Context) error {
o.mu.Lock()
defer o.mu.Unlock()
if o.provider != nil {
return nil
}
provider, err := oidc.NewProvider(ctx, strings.TrimSuffix(o.opts.IssuerURL, "/"))
if err != nil {
return err
}
o.provider = provider
o.verifier = provider.Verifier(&oidc.Config{ClientID: o.opts.ClientID})
o.oauth = &oauth2.Config{
ClientID: o.opts.ClientID,
ClientSecret: o.opts.ClientSecret,
Endpoint: provider.Endpoint(),
RedirectURL: o.RedirectURI(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
return nil
}
// ready returns the discovered client, discovering it first if an earlier
// attempt failed.
func (o *OIDC) ready(ctx context.Context) (*oauth2.Config, *oidc.IDTokenVerifier, error) {
if err := o.discover(ctx); err != nil {
return nil, nil, err
}
o.mu.Lock()
defer o.mu.Unlock()
return o.oauth, o.verifier, nil
}
// Routes mounts the login endpoints. Mount at "/auth", outside /api: these are
// browser navigations, not API calls, and they must be reachable without a
// session — that is their whole purpose.
func (o *OIDC) Routes() chi.Router {
r := chi.NewRouter()
r.Get("/login", o.login)
r.Get("/callback", o.callback)
r.Get("/logout", o.logout)
r.Post("/logout", o.logout)
return r
}
// login starts an authorization-code flow with PKCE.
func (o *OIDC) login(w http.ResponseWriter, r *http.Request) {
// Already signed in? Don't bounce a valid session through the IdP.
if _, err := o.sessions.Resolve(r); err == nil {
http.Redirect(w, r, "/", http.StatusFound)
return
}
conf, _, err := o.ready(r.Context())
if err != nil {
o.page(w, http.StatusServiceUnavailable,
"登录暂时不可用", "Sign-in is unavailable right now",
"Petal 联系不上登录服务。请稍后再试。",
"Petal can't reach the sign-in service. Please try again in a moment.")
return
}
state, err := randomToken()
if err != nil {
o.page(w, http.StatusInternalServerError, "出了点问题", "Something went wrong", "请再试一次。", "Please try again.")
return
}
nonce, err := randomToken()
if err != nil {
o.page(w, http.StatusInternalServerError, "出了点问题", "Something went wrong", "请再试一次。", "Please try again.")
return
}
pkce := oauth2.GenerateVerifier()
// state defends the callback against CSRF (a forged callback can't know the
// cookie); nonce ties the returned ID token to this attempt; PKCE binds the
// code to this client even if it leaks in transit.
o.setTemp(w, stateCookie, state)
o.setTemp(w, nonceCookie, nonce)
o.setTemp(w, pkceCookie, pkce)
http.Redirect(w, r, conf.AuthCodeURL(state,
oidc.Nonce(nonce),
oauth2.S256ChallengeOption(pkce),
), http.StatusFound)
}
// callback completes the flow: verify, allowlist, provision, issue a session.
func (o *OIDC) callback(w http.ResponseWriter, r *http.Request) {
// Expire the one-shot login cookies up front, not on the way out: every exit
// from here writes a response, and a Set-Cookie added after the header is
// written is silently dropped. They're read from the request below, so
// clearing them on the response now costs nothing.
o.clearTemp(w)
if errParam := r.URL.Query().Get("error"); errParam != "" {
o.page(w, http.StatusForbidden,
"登录未完成", "Sign-in didn't finish",
"登录服务拒绝了这次请求。你可以再试一次。",
"The sign-in service turned that request down. You can try again.")
return
}
state, err := r.Cookie(stateCookie)
if err != nil || state.Value == "" ||
subtle.ConstantTimeCompare([]byte(state.Value), []byte(r.URL.Query().Get("state"))) != 1 {
o.page(w, http.StatusBadRequest,
"这个登录链接过期了", "That sign-in link expired",
"请回到 Petal 重新登录。",
"Head back to Petal and sign in again.")
return
}
conf, verifier, err := o.ready(r.Context())
if err != nil {
o.page(w, http.StatusServiceUnavailable,
"登录暂时不可用", "Sign-in is unavailable right now",
"Petal 联系不上登录服务。请稍后再试。",
"Petal can't reach the sign-in service. Please try again in a moment.")
return
}
pkce, err := r.Cookie(pkceCookie)
if err != nil {
o.page(w, http.StatusBadRequest, "这个登录链接过期了", "That sign-in link expired",
"请回到 Petal 重新登录。", "Head back to Petal and sign in again.")
return
}
token, err := conf.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(pkce.Value))
if err != nil {
log.Printf("auth: code exchange failed: %v", err)
o.page(w, http.StatusBadGateway, "登录没有成功", "Sign-in didn't go through",
"请再试一次。", "Please try again.")
return
}
claims, err := o.claims(r.Context(), verifier, token)
if err != nil {
log.Printf("auth: id token rejected: %v", err)
o.page(w, http.StatusBadGateway, "登录没有成功", "Sign-in didn't go through",
"请再试一次。", "Please try again.")
return
}
// Nonce check: this ID token must belong to the attempt that started here.
nonce, err := r.Cookie(nonceCookie)
if err != nil || subtle.ConstantTimeCompare([]byte(nonce.Value), []byte(claims.nonce)) != 1 {
o.page(w, http.StatusBadRequest, "这个登录链接过期了", "That sign-in link expired",
"请回到 Petal 重新登录。", "Head back to Petal and sign in again.")
return
}
if !o.opts.Allowed.Permits(claims.Subject, claims.Email) {
log.Printf("auth: rejected sign-in for sub=%s email=%s (not on the allowlist)", claims.Subject, claims.Email)
o.page(w, http.StatusForbidden,
"这个 Petal 不是给你写的", "This Petal isn't yours to write in",
"你的账号是有效的,但还没有被邀请到这个 Petal。如果这是个误会,找管理员说一声就好。",
"Your account is valid, but it hasn't been invited to this Petal. If that's a mistake, a word with whoever runs it will sort it out.")
return
}
if err := o.users.Upsert(claims.Subject, claims.Email, claims.displayName()); err != nil {
log.Printf("auth: provisioning failed: %v", err)
o.page(w, http.StatusInternalServerError, "出了点问题", "Something went wrong",
"请再试一次。", "Please try again.")
return
}
session, err := o.sessions.Create(claims.Subject, r.UserAgent())
if err != nil {
log.Printf("auth: session creation failed: %v", err)
o.page(w, http.StatusInternalServerError, "出了点问题", "Something went wrong",
"请再试一次。", "Please try again.")
return
}
SetSessionCookie(w, session, o.secure)
log.Printf("auth: signed in %s (%s)", claims.Email, claims.Subject)
http.Redirect(w, r, "/", http.StatusFound)
}
// logout revokes the session server-side and clears the cookie. Doing both
// matters: clearing only the cookie leaves a token that still works if it was
// ever captured.
func (o *OIDC) logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(SessionCookie); err == nil && c.Value != "" {
if err := o.sessions.Revoke(c.Value); err != nil {
log.Printf("auth: revoke failed: %v", err)
}
}
ClearSessionCookie(w, o.secure)
http.Redirect(w, r, "/", http.StatusFound)
}
// idClaims is the subset of the ID token Petal cares about.
type idClaims struct {
Subject string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
nonce string
}
func (c idClaims) displayName() string {
if c.Name != "" {
return c.Name
}
if c.PreferredUsername != "" {
return c.PreferredUsername
}
return c.Email
}
// claims verifies the ID token in a token response and extracts its claims.
func (o *OIDC) claims(ctx context.Context, verifier *oidc.IDTokenVerifier, token *oauth2.Token) (idClaims, error) {
raw, ok := token.Extra("id_token").(string)
if !ok || raw == "" {
return idClaims{}, errors.New("no id_token in the token response")
}
idToken, err := verifier.Verify(ctx, raw)
if err != nil {
return idClaims{}, err
}
var claims idClaims
if err := idToken.Claims(&claims); err != nil {
return idClaims{}, err
}
if claims.Subject == "" {
claims.Subject = idToken.Subject
}
claims.nonce = idToken.Nonce
return claims, nil
}
func (o *OIDC) setTemp(w http.ResponseWriter, name, value string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/auth",
HttpOnly: true,
Secure: o.secure,
SameSite: http.SameSiteLaxMode,
MaxAge: loginAttemptTTL,
})
}
func (o *OIDC) clearTemp(w http.ResponseWriter) {
for _, name := range []string{stateCookie, nonceCookie, pkceCookie} {
http.SetCookie(w, &http.Cookie{
Name: name, Value: "", Path: "/auth",
HttpOnly: true, Secure: o.secure, SameSite: http.SameSiteLaxMode, MaxAge: -1,
})
}
}
func randomToken() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// page renders one of the flow's dead ends. Every one of them is a full stop in
// front of someone who was just trying to write, so they read as warm bilingual
// sentences rather than as a status code — the same standard as the rest of the
// app, and the reason these aren't plain http.Error calls.
func (o *OIDC) page(w http.ResponseWriter, status int, titleZH, titleEN, bodyZH, bodyEN string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
fmt.Fprintf(w, `<!doctype html>
<html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>%s · Petal</title>
<style>
:root { color-scheme: light dark; }
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
background:#fdf8f5; color:#5b4b52;
font-family:'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',system-ui,sans-serif; }
main { max-width:30rem; padding:2.5rem; text-align:center; }
.mark { font-size:2.5rem; }
h1 { font-size:1.5rem; margin:.75rem 0 .25rem; font-weight:700; }
h2 { font-size:1rem; margin:0 0 1.25rem; font-weight:600; opacity:.65; }
p { line-height:1.7; margin:.4rem 0; }
p.en { opacity:.7; font-size:.95rem; }
a { display:inline-block; margin-top:1.75rem; padding:.6rem 1.4rem; border-radius:999px;
background:#f3c7d3; color:#5b4b52; text-decoration:none; font-weight:700; }
@media (prefers-color-scheme: dark) { body { background:#231b28; color:#e9dfe6; } a { background:#7c5f78; color:#fdf8f5; } }
</style></head>
<body><main>
<div class="mark">🌸</div>
<h1>%s</h1><h2>%s</h2>
<p>%s</p><p class="en">%s</p>
<a href="/">回到 Petal · Back to Petal</a>
</main></body></html>
`, titleEN, titleZH, titleEN, bodyZH, bodyEN)
}
+363
View File
@@ -0,0 +1,363 @@
package auth
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
jose "github.com/go-jose/go-jose/v4"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// These tests run the whole login round-trip against a stub identity provider:
// discovery, the redirect out, the callback back, and the session that comes out
// the other end. The flow is the one place in Petal where getting a detail wrong
// (an unchecked state, a nonce nobody compares) is both easy and invisible —
// everything still "works" from the browser's point of view.
// stubIdP is a minimal OpenID provider: discovery, a JWKS, and a token endpoint
// that mints a signed ID token for whoever the test says just logged in.
type stubIdP struct {
*httptest.Server
key *rsa.PrivateKey
clientID string
// Claims the next token exchange will assert.
sub, email, name string
// nonce echoed into the token; set from the login attempt's cookie.
nonce string
// lastForm records what Petal sent to /token, so the test can assert PKCE.
lastForm url.Values
}
func newStubIdP(t *testing.T, clientID string) *stubIdP {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
idp := &stubIdP{key: key, clientID: clientID}
mux := http.NewServeMux()
idp.Server = httptest.NewServer(mux)
t.Cleanup(idp.Close)
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": idp.URL,
"authorization_endpoint": idp.URL + "/authorize",
"token_endpoint": idp.URL + "/token",
"jwks_uri": idp.URL + "/jwks",
"id_token_signing_alg_values_supported": []string{"RS256"},
})
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{
Keys: []jose.JSONWebKey{{Key: key.Public(), Algorithm: "RS256", Use: "sig"}},
})
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
idp.lastForm = r.PostForm
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "stub-access-token",
"token_type": "Bearer",
"id_token": idp.idToken(t),
})
})
return idp
}
// idToken mints a signed ID token asserting the currently configured claims.
func (idp *stubIdP) idToken(t *testing.T) string {
t.Helper()
signer, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.RS256, Key: idp.key},
(&jose.SignerOptions{}).WithType("JWT"),
)
if err != nil {
t.Fatal(err)
}
payload, _ := json.Marshal(map[string]any{
"iss": idp.URL,
"aud": idp.clientID,
"sub": idp.sub,
"email": idp.email,
"name": idp.name,
"nonce": idp.nonce,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
})
signed, err := signer.Sign(payload)
if err != nil {
t.Fatal(err)
}
raw, err := signed.CompactSerialize()
if err != nil {
t.Fatal(err)
}
return raw
}
// newFlow wires Petal's login routes to a stub provider.
func newFlow(t *testing.T, allowed Allowlist) (*stubIdP, http.Handler, *SessionStore, *UserStore) {
t.Helper()
sessions, users, _ := newStores(t)
idp := newStubIdP(t, "petal")
o := NewOIDC(context.Background(), Options{
IssuerURL: idp.URL,
ClientID: "petal",
ClientSecret: "shh",
BaseURL: "http://petal.test",
Allowed: allowed,
}, sessions, users)
return idp, o.Routes(), sessions, users
}
// cookieJar collects Set-Cookie headers across the redirect chain, standing in
// for the browser that would normally carry them.
type cookieJar map[string]string
func (j cookieJar) absorb(rec *httptest.ResponseRecorder) {
for _, c := range rec.Result().Cookies() {
if c.MaxAge < 0 || c.Value == "" {
delete(j, c.Name)
continue
}
j[c.Name] = c.Value
}
}
func (j cookieJar) attach(r *http.Request) *http.Request {
for name, value := range j {
r.AddCookie(&http.Cookie{Name: name, Value: value})
}
return r
}
// start runs /auth/login and returns the redirect target plus the cookies it set.
func start(t *testing.T, flow http.Handler) (*url.URL, cookieJar) {
t.Helper()
rec := httptest.NewRecorder()
flow.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
if rec.Code != http.StatusFound {
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body)
}
target, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
jar := cookieJar{}
jar.absorb(rec)
return target, jar
}
func TestLoginRoundTrip(t *testing.T) {
idp, flow, sessions, users := newFlow(t, nil)
idp.sub, idp.email, idp.name = "sub-her", "her@example.com", "Her Name"
target, jar := start(t, flow)
// The redirect must carry everything the flow depends on later.
q := target.Query()
if q.Get("state") == "" || q.Get("nonce") == "" {
t.Fatalf("login redirect missing state/nonce: %s", target)
}
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
t.Fatalf("login redirect missing PKCE challenge: %s", target)
}
if q.Get("redirect_uri") != "http://petal.test/auth/callback" {
t.Fatalf("redirect_uri = %q", q.Get("redirect_uri"))
}
if jar[stateCookie] != q.Get("state") {
t.Fatal("the state cookie does not match the state sent to the provider")
}
idp.nonce = jar[nonceCookie]
// Come back as the provider would.
rec := httptest.NewRecorder()
flow.ServeHTTP(rec, jar.attach(
httptest.NewRequest(http.MethodGet, "/callback?code=abc&state="+url.QueryEscape(q.Get("state")), nil)))
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/" {
t.Fatalf("callback status=%d location=%q body=%s", rec.Code, rec.Header().Get("Location"), rec.Body)
}
// PKCE: the code verifier must reach the token endpoint.
if v := idp.lastForm.Get("code_verifier"); v == "" {
t.Fatal("token exchange sent no code_verifier")
}
// The account was provisioned from the token's claims...
user, err := users.Get("sub-her")
if err != nil {
t.Fatalf("user was not provisioned: %v", err)
}
if user.Email != "her@example.com" || user.DisplayName != "Her Name" {
t.Fatalf("unexpected provisioned user %+v", user)
}
// ...and the response carries a session that resolves to them.
jar.absorb(rec)
token := jar[SessionCookie]
if token == "" {
t.Fatal("callback issued no session cookie")
}
got, err := sessions.Resolve(withCookie(token))
if err != nil || got != "sub-her" {
t.Fatalf("session resolved to %q (err=%v), want sub-her", got, err)
}
// The one-shot login cookies must not linger.
for _, name := range []string{stateCookie, nonceCookie, pkceCookie} {
if jar[name] != "" {
t.Fatalf("%s survived the callback", name)
}
}
// Signing out revokes server-side, not just in the browser.
out := httptest.NewRecorder()
flow.ServeHTTP(out, jar.attach(httptest.NewRequest(http.MethodGet, "/logout", nil)))
if out.Code != http.StatusFound {
t.Fatalf("logout status=%d", out.Code)
}
if _, err := sessions.Resolve(withCookie(token)); err == nil {
t.Fatal("the session survived signing out")
}
}
// A callback whose state doesn't match the cookie is a forged one.
func TestCallbackRejectsBadState(t *testing.T) {
idp, flow, sessions, _ := newFlow(t, nil)
idp.sub, idp.email = "sub-her", "her@example.com"
_, jar := start(t, flow)
idp.nonce = jar[nonceCookie]
rec := httptest.NewRecorder()
flow.ServeHTTP(rec, jar.attach(
httptest.NewRequest(http.MethodGet, "/callback?code=abc&state=some-other-state", nil)))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want 400", rec.Code)
}
assertNoSession(t, sessions, rec)
// And so is one with no state cookie at all.
bare := httptest.NewRecorder()
flow.ServeHTTP(bare, httptest.NewRequest(http.MethodGet, "/callback?code=abc&state=x", nil))
if bare.Code != http.StatusBadRequest {
t.Fatalf("status=%d for a cookieless callback, want 400", bare.Code)
}
}
// An ID token minted for a different login attempt must not be accepted, even
// though it is perfectly valid and correctly signed.
func TestCallbackRejectsReplayedNonce(t *testing.T) {
idp, flow, sessions, _ := newFlow(t, nil)
idp.sub, idp.email = "sub-her", "her@example.com"
_, jarA := start(t, flow)
_, jarB := start(t, flow)
idp.nonce = jarB[nonceCookie] // a token belonging to the *other* attempt
rec := httptest.NewRecorder()
flow.ServeHTTP(rec, jarA.attach(
httptest.NewRequest(http.MethodGet, "/callback?code=abc&state="+url.QueryEscape(jarA[stateCookie]), nil)))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want 400", rec.Code)
}
assertNoSession(t, sessions, rec)
}
// Being a valid user at the identity provider is not the same as being a user
// here, and the refusal has to read like Petal rather than like a stack trace.
func TestCallbackHonoursAllowlist(t *testing.T) {
idp, flow, sessions, users := newFlow(t, ParseAllowlist("her@example.com"))
idp.sub, idp.email, idp.name = "sub-stranger", "stranger@example.com", "A Stranger"
_, jar := start(t, flow)
idp.nonce = jar[nonceCookie]
rec := httptest.NewRecorder()
flow.ServeHTTP(rec, jar.attach(
httptest.NewRequest(http.MethodGet, "/callback?code=abc&state="+url.QueryEscape(jar[stateCookie]), nil)))
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d, want 403", rec.Code)
}
if body := rec.Body.String(); !strings.Contains(body, "这个 Petal 不是给你写的") ||
!strings.Contains(body, "isn't yours to write in") {
t.Fatalf("refusal page is not the warm bilingual one: %s", body)
}
assertNoSession(t, sessions, rec)
if _, err := users.Get("sub-stranger"); err == nil {
t.Fatal("a rejected login still provisioned an account")
}
// The person on the list gets in through the same door.
idp.sub, idp.email, idp.name = "sub-her", "her@example.com", "Her Name"
_, jar2 := start(t, flow)
idp.nonce = jar2[nonceCookie]
ok := httptest.NewRecorder()
flow.ServeHTTP(ok, jar2.attach(
httptest.NewRequest(http.MethodGet, "/callback?code=abc&state="+url.QueryEscape(jar2[stateCookie]), nil)))
if ok.Code != http.StatusFound {
t.Fatalf("an allowed writer was turned away: status=%d body=%s", ok.Code, ok.Body)
}
}
// The provider refusing the login (a cancelled consent, a locked account) is a
// dead end, not a session.
func TestCallbackHandlesProviderError(t *testing.T) {
_, flow, sessions, _ := newFlow(t, nil)
rec := httptest.NewRecorder()
flow.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/callback?error=access_denied", nil))
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d, want 403", rec.Code)
}
assertNoSession(t, sessions, rec)
}
// Signing in when already signed in shouldn't bounce a good session through the
// identity provider.
func TestLoginSkipsWhenAlreadySignedIn(t *testing.T) {
_, flow, sessions, _ := newFlow(t, nil)
token, err := sessions.Create(db.LocalUserID, "")
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
flow.ServeHTTP(rec, withCookie(token))
// withCookie builds a GET "/" request; point it at the login route.
rec = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/login", nil)
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: token})
flow.ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/" {
t.Fatalf("status=%d location=%q, want a redirect home", rec.Code, rec.Header().Get("Location"))
}
}
// assertNoSession fails if a response handed out a usable session cookie.
func assertNoSession(t *testing.T, sessions *SessionStore, rec *httptest.ResponseRecorder) {
t.Helper()
for _, c := range rec.Result().Cookies() {
if c.Name == SessionCookie && c.Value != "" {
if _, err := sessions.Resolve(withCookie(c.Value)); err == nil {
t.Fatal("a rejected login was given a working session")
}
}
}
}
+171
View File
@@ -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,
})
}
+311
View File
@@ -0,0 +1,311 @@
package auth
import (
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// newStores opens a database holding two users and returns the session and user
// stores over it.
func newStores(t *testing.T) (*SessionStore, *UserStore, *db.DB) {
t.Helper()
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
if _, err := database.Exec(
`INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)`,
"bob", "bob@petal.local", "Bob",
); err != nil {
t.Fatalf("seed second user: %v", err)
}
return NewSessionStore(database.DB), NewUserStore(database.DB), database
}
// withCookie builds a request carrying a session token.
func withCookie(token string) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: SessionCookie, Value: token})
return r
}
func TestSessionLifecycle(t *testing.T) {
sessions, _, _ := newStores(t)
token, err := sessions.Create(db.LocalUserID, "test-agent")
if err != nil {
t.Fatalf("create: %v", err)
}
got, err := sessions.Resolve(withCookie(token))
if err != nil {
t.Fatalf("resolve: %v", err)
}
if got != db.LocalUserID {
t.Fatalf("resolved %q, want %q", got, db.LocalUserID)
}
// Signing out must invalidate the token server-side, not just in the browser:
// clearing only the cookie leaves a token that still works if it ever leaked.
if err := sessions.Revoke(token); err != nil {
t.Fatalf("revoke: %v", err)
}
if _, err := sessions.Resolve(withCookie(token)); !errors.Is(err, ErrNoSession) {
t.Fatalf("revoked token still resolves (err=%v)", err)
}
}
// A request with no cookie, or a token nobody issued, is simply not signed in.
func TestSessionRejectsUnknown(t *testing.T) {
sessions, _, _ := newStores(t)
if _, err := sessions.Resolve(httptest.NewRequest(http.MethodGet, "/", nil)); !errors.Is(err, ErrNoSession) {
t.Fatalf("bare request err=%v, want ErrNoSession", err)
}
if _, err := sessions.Resolve(withCookie("not-a-real-token")); !errors.Is(err, ErrNoSession) {
t.Fatalf("forged token err=%v, want ErrNoSession", err)
}
}
// The table stores a hash, so a database dump yields no usable session.
func TestSessionTokenIsNotStored(t *testing.T) {
sessions, _, database := newStores(t)
token, err := sessions.Create(db.LocalUserID, "")
if err != nil {
t.Fatalf("create: %v", err)
}
var stored string
if err := database.QueryRow(`SELECT id FROM sessions`).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored == token || strings.Contains(stored, token) {
t.Fatal("the raw session token is stored in the database")
}
if stored != hashToken(token) {
t.Fatal("stored id is not the token's hash")
}
}
func TestSessionExpiry(t *testing.T) {
sessions, _, database := newStores(t)
token, err := sessions.Create(db.LocalUserID, "")
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := database.Exec(
`UPDATE sessions SET expires_at = datetime('now','-1 minute')`,
); err != nil {
t.Fatal(err)
}
if _, err := sessions.Resolve(withCookie(token)); !errors.Is(err, ErrNoSession) {
t.Fatalf("expired session still resolves (err=%v)", err)
}
n, err := sessions.Prune()
if err != nil {
t.Fatalf("prune: %v", err)
}
if n != 1 {
t.Fatalf("pruned %d rows, want 1", n)
}
}
// The window slides: using a session pushes its expiry back out, so someone who
// writes in Petal every few days is never signed out mid-draft.
func TestSessionSlidesForward(t *testing.T) {
sessions, _, database := newStores(t)
token, err := sessions.Create(db.LocalUserID, "")
if err != nil {
t.Fatalf("create: %v", err)
}
// Pretend the session has been idle for a fortnight.
if _, err := database.Exec(
`UPDATE sessions SET expires_at = datetime('now','+16 days')`,
); err != nil {
t.Fatal(err)
}
if _, err := sessions.Resolve(withCookie(token)); err != nil {
t.Fatalf("resolve: %v", err)
}
var extended bool
if err := database.QueryRow(
`SELECT expires_at > datetime('now','+29 days') FROM sessions`,
).Scan(&extended); err != nil {
t.Fatal(err)
}
if !extended {
t.Fatal("using a session did not extend it")
}
}
// Two live sessions must each resolve to their own writer — the whole point.
func TestSessionsAreNotInterchangeable(t *testing.T) {
sessions, _, _ := newStores(t)
aliceToken, err := sessions.Create(db.LocalUserID, "")
if err != nil {
t.Fatal(err)
}
bobToken, err := sessions.Create("bob", "")
if err != nil {
t.Fatal(err)
}
for token, want := range map[string]string{aliceToken: db.LocalUserID, bobToken: "bob"} {
got, err := sessions.Resolve(withCookie(token))
if err != nil {
t.Fatalf("resolve: %v", err)
}
if got != want {
t.Fatalf("token resolved to %q, want %q", got, want)
}
}
// Revoking one session leaves the other alone.
if err := sessions.Revoke(aliceToken); err != nil {
t.Fatal(err)
}
if _, err := sessions.Resolve(withCookie(bobToken)); err != nil {
t.Fatalf("bob was signed out by alice's logout: %v", err)
}
// RevokeAll signs one writer out everywhere and nobody else.
second, _ := sessions.Create("bob", "phone")
if err := sessions.RevokeAll("bob"); err != nil {
t.Fatal(err)
}
for _, token := range []string{bobToken, second} {
if _, err := sessions.Resolve(withCookie(token)); !errors.Is(err, ErrNoSession) {
t.Fatalf("RevokeAll left a session alive (err=%v)", err)
}
}
}
// The session store is itself the Resolver the API middleware runs on, so a
// valid cookie must carry all the way through to the handler.
func TestSessionStoreDrivesMiddleware(t *testing.T) {
sessions, _, _ := newStores(t)
token, err := sessions.Create("bob", "")
if err != nil {
t.Fatal(err)
}
var seen string
h := Middleware(sessions)(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) { seen = UserID(r.Context()) },
))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, withCookie(token))
if rec.Code != http.StatusOK || seen != "bob" {
t.Fatalf("status=%d user=%q, want 200/bob", rec.Code, seen)
}
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/", nil))
if rec2.Code != http.StatusUnauthorized {
t.Fatalf("status=%d for a cookieless request, want 401", rec2.Code)
}
}
func TestUserUpsert(t *testing.T) {
_, users, database := newStores(t)
if err := users.Upsert("sub-123", "her@example.com", "Her Name"); err != nil {
t.Fatalf("upsert: %v", err)
}
user, err := users.Get("sub-123")
if err != nil {
t.Fatalf("get: %v", err)
}
if user.Email != "her@example.com" || user.DisplayName != "Her Name" {
t.Fatalf("unexpected user %+v", user)
}
if user.PairLang != "zh" {
t.Fatalf("pair_lang = %q, want the zh default", user.PairLang)
}
// A rename upstream is reflected here; Petal's own settings are not touched.
if _, err := database.Exec(`UPDATE users SET pair_lang = 'pt-PT' WHERE id = 'sub-123'`); err != nil {
t.Fatal(err)
}
if err := users.Upsert("sub-123", "new@example.com", "New Name"); err != nil {
t.Fatalf("second upsert: %v", err)
}
user, _ = users.Get("sub-123")
if user.Email != "new@example.com" || user.DisplayName != "New Name" {
t.Fatalf("login did not refresh the profile: %+v", user)
}
if user.PairLang != "pt-PT" {
t.Fatalf("login reset pair_lang to %q", user.PairLang)
}
// Falling back to the email keeps the sidebar from showing an empty name.
if err := users.Upsert("sub-456", "them@example.com", ""); err != nil {
t.Fatal(err)
}
if u, _ := users.Get("sub-456"); u.DisplayName != "them@example.com" {
t.Fatalf("display name = %q, want the email fallback", u.DisplayName)
}
if err := users.Upsert("", "nobody@example.com", "Nobody"); err == nil {
t.Fatal("a login with no subject was accepted")
}
}
// Sessions belong to their account: deleting a user takes their logins with it.
func TestSessionsCascadeWithUser(t *testing.T) {
sessions, _, database := newStores(t)
token, err := sessions.Create("bob", "")
if err != nil {
t.Fatal(err)
}
if _, err := database.Exec(`DELETE FROM users WHERE id = 'bob'`); err != nil {
t.Fatal(err)
}
if _, err := sessions.Resolve(withCookie(token)); !errors.Is(err, ErrNoSession) {
t.Fatalf("session outlived its account (err=%v)", err)
}
}
func TestAllowlist(t *testing.T) {
// No list configured = anyone the IdP authenticates, which is the right
// default for a household instance.
if !ParseAllowlist("").Permits("anyone", "anyone@example.com") {
t.Fatal("an empty allowlist turned someone away")
}
if !ParseAllowlist(" ").Permits("anyone", "anyone@example.com") {
t.Fatal("a whitespace-only allowlist turned someone away")
}
list := ParseAllowlist(" sub-123 , Her@Example.com ,, ")
cases := []struct {
sub, email string
want bool
}{
{"sub-123", "someone@example.com", true}, // by subject
{"sub-999", "her@example.com", true}, // by email
{"sub-999", "HER@EXAMPLE.COM", true}, // case-insensitively
{"sub-999", "stranger@example.com", false}, // neither
{"", "", false}, // no claims at all
{"sub-1", "", false}, // a near-miss subject
}
for _, c := range cases {
if got := list.Permits(c.sub, c.email); got != c.want {
t.Fatalf("Permits(%q, %q) = %v, want %v", c.sub, c.email, got, c.want)
}
}
}
+100
View File
@@ -0,0 +1,100 @@
package auth
import (
"database/sql"
"errors"
"net/http"
"strings"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
)
// UserStore provisions and reads accounts. Petal has no signup flow: a row
// appears the first time someone Authentik vouches for signs in, and that is
// the only way one is ever created.
type UserStore struct {
db *sql.DB
}
// NewUserStore returns a store backed by the given database.
func NewUserStore(sqlDB *sql.DB) *UserStore { return &UserStore{db: sqlDB} }
// Upsert records the account behind an OIDC login, keyed by the issuer's
// subject id.
//
// The subject is the id — not the email, which people change and which
// Authentik does not promise is stable. Email and display name are refreshed on
// every login so a rename upstream shows up here; pair_lang is deliberately not
// touched, because it is Petal's own setting rather than the IdP's.
func (u *UserStore) Upsert(sub, email, displayName string) error {
if sub == "" {
return errors.New("oidc: empty subject")
}
if displayName == "" {
displayName = email
}
_, err := u.db.Exec(
`INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
email = excluded.email,
display_name = excluded.display_name`,
sub, email, displayName,
)
return err
}
// Get loads one account.
func (u *UserStore) Get(id string) (db.User, error) {
var user db.User
err := u.db.QueryRow(
`SELECT id, email, COALESCE(display_name, ''), created_at, pair_lang
FROM users WHERE id = ?`, id,
).Scan(&user.ID, &user.Email, &user.DisplayName, &user.CreatedAt, &user.PairLang)
return user, err
}
// MeHandler reports who the caller is. The frontend uses it to namespace
// per-account browser state and to show the signed-in writer; it sits behind
// the auth middleware, so reaching it at all already proves a valid session.
func (u *UserStore) MeHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, err := u.Get(UserID(r.Context()))
if err != nil {
httputil.ErrorJSON(w, http.StatusUnauthorized, "not signed in")
return
}
httputil.WriteJSON(w, http.StatusOK, user)
}
}
// Allowlist decides which of Authentik's users may write in this Petal.
// Authentik fronts several applications; being a valid user there does not mean
// being a user here.
//
// An entry matches a subject id or an email address, case-insensitively. Both
// are accepted on purpose: a subject is an opaque uuid nobody can know before
// that person's first login, so a subject-only list means the operator must let
// someone in, read a log line, and edit config — whereas an email is knowable in
// advance. An empty list allows everyone the IdP authenticates, which is the
// right default for a single-household instance.
type Allowlist map[string]bool
// ParseAllowlist builds an Allowlist from a comma-separated env value.
func ParseAllowlist(raw string) Allowlist {
list := Allowlist{}
for _, part := range strings.Split(raw, ",") {
if p := strings.ToLower(strings.TrimSpace(part)); p != "" {
list[p] = true
}
}
return list
}
// Permits reports whether this login may proceed.
func (a Allowlist) Permits(sub, email string) bool {
if len(a) == 0 {
return true
}
return a[strings.ToLower(sub)] || (email != "" && a[strings.ToLower(email)])
}
+21 -8
View File
@@ -36,11 +36,24 @@ type Config struct {
TTSTimeout time.Duration
TTSFormat string // mp3 | opus | wav — mp3/opus transcode Piper's WAV via ffmpeg
// Auth (deferred — not wired in the local-dev build, kept for later)
AuthentikURL string
// Auth. OIDC against Authentik. Login is enabled only when the issuer, the
// client id and the secret are all present; with any of them missing Petal
// falls back to the single hardcoded local user, which is what local
// development wants and what every deployment did before Phase 16.
AuthentikURL string // issuer URL of the Petal provider in Authentik
AuthentikClientID string
AuthentikClientSecret string
SessionSecret string
// AllowedSubs gates who may sign in, as a comma-separated list of OIDC
// subject ids and/or email addresses. Empty means everyone Authentik
// authenticates — right for a single-household instance, wrong the moment
// the IdP serves an audience wider than Petal's.
AllowedSubs string
}
// AuthEnabled reports whether real logins are configured. When false, Petal
// resolves every request to the local user.
func (c *Config) AuthEnabled() bool {
return c.AuthentikURL != "" && c.AuthentikClientID != "" && c.AuthentikClientSecret != ""
}
// Load reads configuration from the environment, applying sane local-dev defaults.
@@ -61,15 +74,15 @@ func Load() *Config {
TTSEndpointZH: env("TTS_ENDPOINT_ZH", ""),
TTSVoiceEN: env("TTS_VOICE_EN", "en_US-amy-medium"),
TTSVoiceZH: env("TTS_VOICE_ZH", "zh_CN-huayan-medium"),
TTSPath: env("TTS_PATH", "/"),
TTSCacheDir: env("TTS_CACHE_DIR", "./data/tts"),
TTSTimeout: envDuration("TTS_TIMEOUT", 15*time.Second),
TTSFormat: env("TTS_AUDIO_FORMAT", "mp3"),
TTSPath: env("TTS_PATH", "/"),
TTSCacheDir: env("TTS_CACHE_DIR", "./data/tts"),
TTSTimeout: envDuration("TTS_TIMEOUT", 15*time.Second),
TTSFormat: env("TTS_AUDIO_FORMAT", "mp3"),
AuthentikURL: env("AUTHENTIK_URL", ""),
AuthentikClientID: env("AUTHENTIK_CLIENT_ID", ""),
AuthentikClientSecret: env("AUTHENTIK_CLIENT_SECRET", ""),
SessionSecret: env("SESSION_SECRET", "dev-insecure-secret-change-me"),
AllowedSubs: env("PETAL_ALLOWED_SUBS", ""),
}
}
+46
View File
@@ -390,6 +390,52 @@ CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
ALTER TABLE documents ADD COLUMN preserve_history INTEGER NOT NULL DEFAULT 0;
ALTER TABLE document_versions ADD COLUMN content_hash TEXT NOT NULL DEFAULT '';
ALTER TABLE document_versions ADD COLUMN prev_hash TEXT NOT NULL DEFAULT '';
`,
},
{
// Real accounts. Three separate things land together because they are
// one change: Petal can now tell users apart.
//
// `sessions` backs server-side login state. The cookie carries an opaque
// random token and this table stores only its SHA-256 — a leaked database
// copy therefore yields no usable session, the same reason passwords are
// hashed. Server-side rows (rather than a signed stateless cookie) are
// what make logout and revocation actually revoke.
//
// `images` gives the content-addressed image store an owner. Until now it
// was a flat directory with no database row at all: any caller holding a
// hash could fetch anyone's image, which is capability-URL security, not
// access control. The primary key is (name, user_id), so the same picture
// uploaded by two people is still stored once on disk and simply has two
// rows — deduplication survives; the file is deleted only with its last
// row. Rows for images already on disk are backfilled at startup by the
// images package, which is the only code that knows the storage path.
//
// `users.pair_lang` is the writer's language pair (English + X). It is
// unused until the langpack work, but it belongs to provisioning and
// costs nothing to add while the users table is already being touched.
name: "0010_sessions_images_and_pair_lang",
stmt: `
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
user_agent TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE TABLE images (
name TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content_type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (name, user_id)
);
CREATE INDEX idx_images_user_id ON images(user_id);
ALTER TABLE users ADD COLUMN pair_lang TEXT NOT NULL DEFAULT 'zh';
`,
},
}
+8 -3
View File
@@ -2,14 +2,19 @@ package db
import "time"
// User is an account. With auth deferred, the app runs as a single hardcoded
// `local` user (see LocalUserID); the user_id columns and this type exist so
// real auth can drop in later without a schema migration.
// User is an account. Its ID is the OIDC subject for anyone who signed in, or
// LocalUserID for the pre-auth single user (and for local development, where
// StaticResolver still hands out that id).
type User struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
CreatedAt time.Time `json:"created_at"`
// PairLang is the X in this writer's (English + X) language pair — "zh"
// today, "pt-PT"/"fr"/"es" once the langpacks land. It selects the UI copy
// and dictionary set, not the language they may type in.
PairLang string `json:"pair_lang"`
}
// Document is a single piece of writing. `Content` is the Tiptap JSON document
+159 -13
View File
@@ -2,20 +2,35 @@
// images from the editor, they're saved to disk under the configured directory,
// and served back by hashed filename. Content addressing means the same image
// pasted twice is stored once, and URLs are stable and cacheable forever.
//
// Each stored file also has one row per owner in the `images` table, and a fetch
// joins on the caller. Before that, the store was a flat directory with no
// database presence at all: any authenticated user holding a sha256 could fetch
// anyone else's image. Hashes aren't guessable, so it was never an emergency —
// but "unguessable filename" is not access control, and images pasted into a
// private journal are exactly the content that shouldn't depend on it.
//
// One row per owner (rather than one owner per file) is what keeps deduplication:
// the same picture uploaded by two people is stored once and simply has two rows.
// The file is removed only with its last row.
package images
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/auth"
)
// maxUploadBytes caps a single image at 10 MiB — generous for a writing tool,
@@ -32,25 +47,81 @@ var extByContentType = map[string]string{
"image/svg+xml": ".svg",
}
// Handler serves the upload + fetch endpoints, backed by a directory on disk.
// Handler serves the upload + fetch endpoints, backed by a directory on disk and
// an ownership table.
type Handler struct {
dir string
db *sql.DB
}
// New constructs a Handler, ensuring the storage directory exists.
func New(dir string) (*Handler, error) {
// New constructs a Handler, ensuring the storage directory exists and that every
// file already in it has an owner.
func New(dir string, database *sql.DB, backfillOwner string) (*Handler, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
return &Handler{dir: dir}, nil
h := &Handler{dir: dir, db: database}
if err := h.backfill(backfillOwner); err != nil {
return nil, err
}
return h, nil
}
// backfill claims pre-existing files for one user. Images uploaded before
// ownership existed have no row, and a row is now what makes them fetchable —
// so without this every picture already pasted into a document would 404.
// Attributing them to the account that has been the only one until now is the
// only answer the data supports. Idempotent: files that already have an owner
// are left alone.
func (h *Handler) backfill(owner string) error {
if owner == "" {
return nil
}
entries, err := os.ReadDir(h.dir)
if err != nil {
return err
}
claimed := 0
for _, e := range entries {
if e.IsDir() {
continue
}
var exists bool
if err := h.db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM images WHERE name = ?)`, e.Name(),
).Scan(&exists); err != nil {
return err
}
if exists {
continue
}
var size int64
if info, err := e.Info(); err == nil {
size = info.Size()
}
if _, err := h.db.Exec(
`INSERT INTO images (name, user_id, content_type, size) VALUES (?, ?, '', ?)
ON CONFLICT DO NOTHING`,
e.Name(), owner, size,
); err != nil {
return err
}
claimed++
}
if claimed > 0 {
log.Printf("images: claimed %d pre-existing image(s) for %s", claimed, owner)
}
return nil
}
// Routes mounts the image endpoints. Mount under "/images" so the full paths are
// POST /api/images (upload) and GET /api/images/{name} (fetch).
// POST /api/images (upload), GET /api/images/{name} (fetch) and
// DELETE /api/images/{name} (drop your copy).
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Post("/", h.upload)
r.Get("/{name}", h.serve)
r.Delete("/{name}", h.remove)
return r
}
@@ -79,7 +150,7 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request) {
ext, ok := extByContentType[ct]
if !ok {
if looksLikeSVG(data) {
ext, ok = ".svg", true
ct, ext, ok = "image/svg+xml", ".svg", true
}
}
if !ok {
@@ -99,28 +170,103 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request) {
}
}
// Record the caller as an owner. Re-uploading your own image is a no-op;
// uploading someone else's identical image adds a second row over one file.
if _, err := h.db.Exec(
`INSERT INTO images (name, user_id, content_type, size) VALUES (?, ?, ?, ?)
ON CONFLICT (name, user_id) DO NOTHING`,
name, auth.UserID(r.Context()), ct, len(data),
); err != nil {
log.Printf("images: could not record ownership of %s: %v", name, err)
http.Error(w, "could not store image", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"url": "/api/images/" + name})
}
// serve returns a stored image by its hashed filename. The filename is validated
// to be a bare name (no path separators) so it can't escape the storage dir, and
// served with a long-lived cache header since content-addressed URLs never change.
// serve returns a stored image by its hashed filename, but only to someone who
// owns it. The filename is validated to be a bare name (no path separators) so
// it can't escape the storage dir, and served with a long-lived cache header
// since content-addressed URLs never change.
//
// Someone else's image is a 404, not a 403: whether a hash exists is itself
// information the caller has no business learning.
func (h *Handler) serve(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) {
name, ok := safeName(chi.URLParam(r, "name"))
if !ok || !h.owns(name, auth.UserID(r.Context())) {
http.NotFound(w, r)
return
}
path := filepath.Join(h.dir, filepath.Base(name))
path := filepath.Join(h.dir, name)
if _, err := os.Stat(path); err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
// Private: a shared cache must never hand one writer's image to another.
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
http.ServeFile(w, r, path)
}
// remove drops the caller's claim on an image, and deletes the file itself once
// nobody is left holding it.
func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
name, ok := safeName(chi.URLParam(r, "name"))
if !ok {
http.NotFound(w, r)
return
}
res, err := h.db.Exec(`DELETE FROM images WHERE name = ? AND user_id = ?`,
name, auth.UserID(r.Context()))
if err != nil {
http.Error(w, "could not delete image", http.StatusInternalServerError)
return
}
if n, _ := res.RowsAffected(); n == 0 {
http.NotFound(w, r)
return
}
var others bool
if err := h.db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM images WHERE name = ?)`, name,
).Scan(&others); err != nil {
// The row is gone either way; leaving an orphaned file behind is a
// wasted block, not a correctness problem.
log.Printf("images: could not check remaining owners of %s: %v", name, err)
w.WriteHeader(http.StatusNoContent)
return
}
if !others {
if err := os.Remove(filepath.Join(h.dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) {
log.Printf("images: could not remove %s: %v", name, err)
}
}
w.WriteHeader(http.StatusNoContent)
}
// owns reports whether userID has a claim on a stored image.
func (h *Handler) owns(name, userID string) bool {
var ok bool
if err := h.db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM images WHERE name = ? AND user_id = ?)`, name, userID,
).Scan(&ok); err != nil {
log.Printf("images: ownership check failed for %s: %v", name, err)
return false
}
return ok
}
// safeName rejects anything that isn't a bare filename, so a request can't walk
// out of the storage directory.
func safeName(name string) (string, bool) {
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) {
return "", false
}
return name, true
}
// looksLikeSVG does a cheap check for an <svg root tag near the start of the
// file, since DetectContentType doesn't recognize SVG.
func looksLikeSVG(data []byte) bool {
+169 -31
View File
@@ -6,8 +6,13 @@ import (
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// a 1x1 transparent PNG.
@@ -19,6 +24,39 @@ var pngBytes = []byte{
0x42, 0x60, 0x82,
}
// another 1x1 PNG, differing in one pixel byte, so it hashes elsewhere.
var otherPNG = append(append([]byte{}, pngBytes[:len(pngBytes)-8]...),
0x01, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44)
// newStore returns a handler over a fresh directory and database, plus a router
// per user: identical but for who the auth middleware says is calling. Two users
// over one store is the situation that ownership exists to handle.
func newStore(t *testing.T) (dir string, alice, bob http.Handler) {
t.Helper()
dir = t.TempDir()
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
if _, err := database.Exec(
`INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)`,
"bob", "bob@petal.local", "Bob",
); err != nil {
t.Fatalf("seed second user: %v", err)
}
h, err := New(dir, database.DB, db.LocalUserID)
if err != nil {
t.Fatalf("new store: %v", err)
}
mount := func(userID string) http.Handler {
return auth.Middleware(auth.StaticResolver(userID))(h.Routes())
}
return dir, mount(db.LocalUserID), mount("bob")
}
func uploadReq(t *testing.T, field string, data []byte) *http.Request {
t.Helper()
var buf bytes.Buffer
@@ -34,16 +72,11 @@ func uploadReq(t *testing.T, field string, data []byte) *http.Request {
return req
}
func TestUploadAndServe(t *testing.T) {
h, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
r := h.Routes()
// Upload a PNG → expect a JSON url under /api/images/.
// upload posts an image and returns its stored name.
func upload(t *testing.T, h http.Handler, data []byte) string {
t.Helper()
rec := httptest.NewRecorder()
r.ServeHTTP(rec, uploadReq(t, "image", pngBytes))
h.ServeHTTP(rec, uploadReq(t, "image", data))
if rec.Code != http.StatusOK {
t.Fatalf("upload code=%d body=%s", rec.Code, rec.Body)
}
@@ -54,44 +87,149 @@ func TestUploadAndServe(t *testing.T) {
if !strings.HasPrefix(resp.URL, "/api/images/") || !strings.HasSuffix(resp.URL, ".png") {
t.Fatalf("unexpected url %q", resp.URL)
}
return strings.TrimPrefix(resp.URL, "/api/images/")
}
func get(t *testing.T, h http.Handler, name string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/"+name, nil))
return rec
}
func TestUploadAndServe(t *testing.T) {
_, alice, _ := newStore(t)
name := upload(t, alice, pngBytes)
// The same content uploaded again dedupes to the same URL.
rec2 := httptest.NewRecorder()
r.ServeHTTP(rec2, uploadReq(t, "image", pngBytes))
var resp2 struct{ URL string }
json.Unmarshal(rec2.Body.Bytes(), &resp2)
if resp2.URL != resp.URL {
t.Fatalf("expected dedup to same url, got %q vs %q", resp2.URL, resp.URL)
if again := upload(t, alice, pngBytes); again != name {
t.Fatalf("expected dedup to same name, got %q vs %q", again, name)
}
// Fetch it back.
name := strings.TrimPrefix(resp.URL, "/api/images/")
rec3 := httptest.NewRecorder()
r.ServeHTTP(rec3, httptest.NewRequest(http.MethodGet, "/"+name, nil))
if rec3.Code != http.StatusOK {
t.Fatalf("serve code=%d", rec3.Code)
rec := get(t, alice, name)
if rec.Code != http.StatusOK {
t.Fatalf("serve code=%d", rec.Code)
}
if !bytes.Equal(rec3.Body.Bytes(), pngBytes) {
if !bytes.Equal(rec.Body.Bytes(), pngBytes) {
t.Fatal("served bytes differ from uploaded")
}
}
func TestUploadRejectsNonImage(t *testing.T) {
h, _ := New(t.TempDir())
r := h.Routes()
// The point of the ownership table: a hash is not a capability.
func TestImageIsolation(t *testing.T) {
_, alice, bob := newStore(t)
name := upload(t, alice, pngBytes)
if rec := get(t, bob, name); rec.Code != http.StatusNotFound {
t.Fatalf("bob fetched alice's image: code=%d", rec.Code)
}
// Nor can he delete it out from under her.
rec := httptest.NewRecorder()
r.ServeHTTP(rec, uploadReq(t, "image", []byte("this is plainly not an image at all")))
bob.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/"+name, nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("bob deleted alice's image: code=%d", rec.Code)
}
if got := get(t, alice, name); got.Code != http.StatusOK {
t.Fatalf("alice's image disappeared: code=%d", got.Code)
}
}
// Deduplication has to survive ownership: one file, one row each.
func TestDedupAcrossUsers(t *testing.T) {
dir, alice, bob := newStore(t)
name := upload(t, alice, pngBytes)
if bobName := upload(t, bob, pngBytes); bobName != name {
t.Fatalf("expected the same stored name, got %q vs %q", bobName, name)
}
entries, _ := os.ReadDir(dir)
if len(entries) != 1 {
t.Fatalf("expected 1 file on disk, found %d", len(entries))
}
for _, h := range []http.Handler{alice, bob} {
if rec := get(t, h, name); rec.Code != http.StatusOK {
t.Fatalf("owner could not fetch shared image: code=%d", rec.Code)
}
}
// Alice dropping her copy must not take Bob's picture away with it.
rec := httptest.NewRecorder()
alice.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/"+name, nil))
if rec.Code != http.StatusNoContent {
t.Fatalf("delete code=%d", rec.Code)
}
if got := get(t, alice, name); got.Code != http.StatusNotFound {
t.Fatalf("alice still sees a deleted image: code=%d", got.Code)
}
if got := get(t, bob, name); got.Code != http.StatusOK {
t.Fatalf("bob lost his image when alice deleted hers: code=%d", got.Code)
}
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
t.Fatalf("file removed while still owned: %v", err)
}
// The last owner leaving takes the file with them.
rec2 := httptest.NewRecorder()
bob.ServeHTTP(rec2, httptest.NewRequest(http.MethodDelete, "/"+name, nil))
if rec2.Code != http.StatusNoContent {
t.Fatalf("delete code=%d", rec2.Code)
}
if _, err := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(err) {
t.Fatalf("file survived its last owner: %v", err)
}
}
// Images that predate ownership must not vanish from documents that use them.
func TestBackfillClaimsExistingFiles(t *testing.T) {
dir := t.TempDir()
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer database.Close()
orphan := "deadbeefdeadbeefdeadbeefdeadbeef.png"
if err := os.WriteFile(filepath.Join(dir, orphan), pngBytes, 0o644); err != nil {
t.Fatal(err)
}
h, err := New(dir, database.DB, db.LocalUserID)
if err != nil {
t.Fatalf("new store: %v", err)
}
alice := auth.Middleware(auth.StaticResolver(db.LocalUserID))(h.Routes())
if rec := get(t, alice, orphan); rec.Code != http.StatusOK {
t.Fatalf("pre-existing image not claimed: code=%d", rec.Code)
}
// Re-running the backfill (i.e. a restart) must not double up or reassign.
if _, err := New(dir, database.DB, "bob"); err != nil {
t.Fatalf("second backfill: %v", err)
}
var owners int
if err := database.QueryRow(`SELECT COUNT(*) FROM images WHERE name = ?`, orphan).Scan(&owners); err != nil {
t.Fatal(err)
}
if owners != 1 {
t.Fatalf("expected the backfill to be idempotent, got %d owners", owners)
}
}
func TestUploadRejectsNonImage(t *testing.T) {
_, alice, _ := newStore(t)
rec := httptest.NewRecorder()
alice.ServeHTTP(rec, uploadReq(t, "image", []byte("this is plainly not an image at all")))
if rec.Code != http.StatusUnsupportedMediaType {
t.Fatalf("expected 415, got %d", rec.Code)
}
}
func TestServeMissing(t *testing.T) {
h, _ := New(t.TempDir())
r := h.Routes()
rec := httptest.NewRecorder()
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/deadbeef.png", nil))
if rec.Code != http.StatusNotFound {
_, alice, _ := newStore(t)
if rec := get(t, alice, "deadbeef.png"); rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}