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,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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)])
|
||||
}
|
||||
Reference in New Issue
Block a user