feat: implement B.1 — persist login sessions in SQLite with sliding idle timeout
Sessions move from an in-memory map (absolute 12h TTL) to a `sessions` table (migration 0002), storing only the SHA-256 of the token. Expiry is now a sliding idle window (PANEL_SESSION_IDLE_DAYS, default 7, no absolute cap), extended at most once an hour and never by the monitoring screens' background polling (GET + HX-Request), so a forgotten open tab doesn't keep a session alive indefinitely. A login now survives a container restart or redeploy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
-- Panel login sessions (spec 7.6.6, plan B.1). Persisted so a login survives a
|
||||
-- container restart or redeploy; only the SHA-256 of the session token is
|
||||
-- stored, never the token itself, so a stolen database file cannot be used to
|
||||
-- sign in. expires_at implements the sliding idle timeout: it is pushed
|
||||
-- forward on activity rather than being fixed at creation time.
|
||||
CREATE TABLE sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_sessions_expires_at ON sessions (expires_at);
|
||||
@@ -0,0 +1,113 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionRow is a persisted login session, keyed by the SHA-256 of its token
|
||||
// (see internal/web, which owns the token itself).
|
||||
type SessionRow struct {
|
||||
Username string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// CreateSession inserts a new session row.
|
||||
func (s *Store) CreateSession(tokenHash, username string, expiresAt time.Time) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO sessions (token_hash, username, created_at, expires_at) VALUES (?, ?, ?, ?)`,
|
||||
tokenHash, username, now, expiresAt.UTC().Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LookupSession returns the session for tokenHash, if any. It does not check
|
||||
// expiry itself — callers compare ExpiresAt against time.Now() and call
|
||||
// DeleteSession on an expired row, keeping the read side lock-free.
|
||||
func (s *Store) LookupSession(tokenHash string) (SessionRow, bool, error) {
|
||||
var (
|
||||
row SessionRow
|
||||
expiresAt string
|
||||
)
|
||||
err := s.db.QueryRow(
|
||||
`SELECT username, expires_at FROM sessions WHERE token_hash = ?`,
|
||||
tokenHash,
|
||||
).Scan(&row.Username, &expiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return SessionRow{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return SessionRow{}, false, fmt.Errorf("lookup session: %w", err)
|
||||
}
|
||||
row.ExpiresAt, err = time.Parse(time.RFC3339, expiresAt)
|
||||
if err != nil {
|
||||
return SessionRow{}, false, fmt.Errorf("parse session expiry: %w", err)
|
||||
}
|
||||
return row, true, nil
|
||||
}
|
||||
|
||||
// RenewSession pushes a session's expiry forward, implementing the sliding
|
||||
// idle timeout.
|
||||
func (s *Store) RenewSession(tokenHash string, expiresAt time.Time) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE sessions SET expires_at = ? WHERE token_hash = ?`,
|
||||
expiresAt.UTC().Format(time.RFC3339), tokenHash,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("renew session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameSession updates the username carried by a session, keeping its
|
||||
// expiry, so a session stays usable after the administrator renames their own
|
||||
// account.
|
||||
func (s *Store) RenameSession(tokenHash, username string) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE sessions SET username = ? WHERE token_hash = ?`,
|
||||
username, tokenHash,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSession removes a session row (logout, or a lookup finding it
|
||||
// expired).
|
||||
func (s *Store) DeleteSession(tokenHash string) error {
|
||||
if _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash = ?`, tokenHash); err != nil {
|
||||
return fmt.Errorf("delete session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOtherSessions removes every session except keepHash. It is called
|
||||
// when the administrator changes their password: a stolen cookie issued
|
||||
// under the old password must stop working, while the session performing the
|
||||
// change stays signed in.
|
||||
func (s *Store) DeleteOtherSessions(keepHash string) error {
|
||||
if _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash != ?`, keepHash); err != nil {
|
||||
return fmt.Errorf("delete other sessions: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteExpiredSessions prunes rows whose expiry has already passed, so an
|
||||
// abandoned session (cookie never presented again) does not sit in the table
|
||||
// forever. It piggybacks on session creation rather than running as its own
|
||||
// background loop.
|
||||
func (s *Store) DeleteExpiredSessions(now time.Time) (int64, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, now.UTC().Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune sessions: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
@@ -152,15 +152,24 @@ func (s *Server) submitLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
token := s.sessions.Create(admin.Username)
|
||||
s.setSessionCookie(w, token)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// setSessionCookie (re)issues the session cookie with a fresh Max-Age equal
|
||||
// to the sliding idle window (plan B.1), so the browser-side expiry tracks
|
||||
// whatever the database row was just set to — at login, and again whenever
|
||||
// requireAuth extends an active session.
|
||||
func (s *Server) setSessionCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.sessionCookie(),
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: s.sessions.MaxAge(),
|
||||
HttpOnly: true,
|
||||
Secure: s.cfg.CookieSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleLogout destroys the session and clears the cookie.
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestSessionTokenIgnoresTheOtherName(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
|
||||
s := &Server{cfg: Config{CookieSecure: false}, sessions: newSessionStore()}
|
||||
s := &Server{cfg: Config{CookieSecure: false}, sessions: newTestSessionStore(t)}
|
||||
token := s.sessions.Create("admin")
|
||||
|
||||
reached := false
|
||||
@@ -85,7 +85,7 @@ func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
|
||||
// Signing out has to expire the cookie under both names, or the cookie left
|
||||
// over from a pre-__Host- build stays in the browser for the rest of its life.
|
||||
func TestLogoutClearsBothCookieNames(t *testing.T) {
|
||||
s := &Server{cfg: Config{CookieSecure: true}, sessions: newSessionStore()}
|
||||
s := &Server{cfg: Config{CookieSecure: true}, sessions: newTestSessionStore(t)}
|
||||
token := s.sessions.Create("admin")
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil)
|
||||
|
||||
@@ -12,6 +12,13 @@ const usernameKey ctxKey = 0
|
||||
// requireAuth wraps a handler so only requests with a valid session cookie
|
||||
// reach it; everyone else is redirected to the login page. The authenticated
|
||||
// username is stashed in the request context for downstream handlers.
|
||||
//
|
||||
// It also extends the sliding session (plan B.1) on activity, defined as
|
||||
// everything except a GET request carrying HX-Request: the four monitoring
|
||||
// fragments (/status/fragment, /queue/body, /logtail/body, /sendlog/rows)
|
||||
// poll every 5s regardless of whether anyone is looking at the tab, so
|
||||
// counting those as activity would make "N days idle" mean "N days since a
|
||||
// browser tab was last open" instead.
|
||||
func (s *Server) requireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := s.sessionToken(r)
|
||||
@@ -24,11 +31,20 @@ func (s *Server) requireAuth(next http.Handler) http.Handler {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if isSessionActivity(r) && s.sessions.Touch(token) {
|
||||
s.setSessionCookie(w, token)
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), usernameKey, username)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// isSessionActivity reports whether a request counts as administrator
|
||||
// activity for the sliding session timeout, per requireAuth's doc comment.
|
||||
func isSessionActivity(r *http.Request) bool {
|
||||
return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "")
|
||||
}
|
||||
|
||||
// currentUser returns the authenticated username from the request context.
|
||||
func currentUser(r *http.Request) string {
|
||||
if v, ok := r.Context().Value(usernameKey).(string); ok {
|
||||
|
||||
+96
-43
@@ -1,68 +1,125 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// sessionTTL bounds how long a login lasts before re-authentication is needed.
|
||||
const sessionTTL = 12 * time.Hour
|
||||
// renewThreshold bounds how often an active session's expiry is written back
|
||||
// to the database. Renewing on every request would mean a write (and a new
|
||||
// Set-Cookie) per click; renewing at most once an hour keeps that cost low
|
||||
// while still keeping a busy admin's session alive indefinitely (plan B.1).
|
||||
const renewThreshold = time.Hour
|
||||
|
||||
// sessionStore keeps active sessions in memory. Sessions are deliberately not
|
||||
// persisted (spec 9 lists what must survive restart; sessions are not on it):
|
||||
// a restart simply logs the admin out, which is acceptable and avoids storing
|
||||
// bearer tokens on disk. Tokens are crypto-random (spec 7.6.6).
|
||||
// sessionStore persists login sessions in the database (plan B.1): a login
|
||||
// survives a container restart or redeploy. Only the SHA-256 of the token is
|
||||
// stored, never the token itself (spec 7.6.6's crypto-random bearer token), so
|
||||
// a stolen database file or backup archive cannot be replayed as a session —
|
||||
// it only extends the login of whichever browser still holds the original
|
||||
// cookie.
|
||||
type sessionStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]session
|
||||
store *store.Store
|
||||
// idle is the sliding inactivity window (PANEL_SESSION_IDLE_DAYS). There is
|
||||
// no absolute cap: an administrator who keeps coming back stays signed in
|
||||
// indefinitely, deliberately.
|
||||
idle time.Duration
|
||||
}
|
||||
|
||||
type session struct {
|
||||
username string
|
||||
expiresAt time.Time
|
||||
func newSessionStore(st *store.Store, idle time.Duration) *sessionStore {
|
||||
return &sessionStore{store: st, idle: idle}
|
||||
}
|
||||
|
||||
func newSessionStore() *sessionStore {
|
||||
return &sessionStore{sessions: make(map[string]session)}
|
||||
// MaxAge is the session cookie's Max-Age in seconds, kept equal to the
|
||||
// sliding idle window so the browser drops the cookie no later than the
|
||||
// server would have expired it anyway.
|
||||
func (s *sessionStore) MaxAge() int {
|
||||
return int(s.idle.Seconds())
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Create issues a new session for username and returns its token.
|
||||
func (s *sessionStore) Create(username string) string {
|
||||
token := randomToken(32)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[token] = session{username: username, expiresAt: time.Now().Add(sessionTTL)}
|
||||
now := time.Now()
|
||||
if err := s.store.CreateSession(hashToken(token), username, now.Add(s.idle)); err != nil {
|
||||
logf("panel: session: create failed: %v", err)
|
||||
}
|
||||
// Opportunistic cleanup: a session nobody ever came back to otherwise sits
|
||||
// in the table forever. Piggybacking on Create (the one write every login
|
||||
// already pays for) avoids a dedicated background sweep for what is, on a
|
||||
// single-admin panel, a handful of rows at most.
|
||||
if _, err := s.store.DeleteExpiredSessions(now); err != nil {
|
||||
logf("panel: session: prune expired failed: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// Lookup returns the session username for a token if it exists and is unexpired.
|
||||
// Lookup returns the session username for a token if it exists and is
|
||||
// unexpired.
|
||||
func (s *sessionStore) Lookup(token string) (string, bool) {
|
||||
if token == "" {
|
||||
return "", false
|
||||
}
|
||||
hash := hashToken(token)
|
||||
row, found, err := s.store.LookupSession(hash)
|
||||
if err != nil {
|
||||
logf("panel: session: lookup failed: %v", err)
|
||||
return "", false
|
||||
}
|
||||
if !found {
|
||||
return "", false
|
||||
}
|
||||
if time.Now().After(row.ExpiresAt) {
|
||||
if err := s.store.DeleteSession(hash); err != nil {
|
||||
logf("panel: session: delete expired failed: %v", err)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
return row.Username, true
|
||||
}
|
||||
|
||||
// Touch extends a session's sliding expiry if it has been at least
|
||||
// renewThreshold since the last extension, and reports whether it did so —
|
||||
// the caller uses that to decide whether the response needs a fresh
|
||||
// Set-Cookie. It assumes the caller has just confirmed the session is valid
|
||||
// (e.g. via Lookup); it does nothing for a token that no longer exists.
|
||||
func (s *sessionStore) Touch(token string) bool {
|
||||
hash := hashToken(token)
|
||||
row, found, err := s.store.LookupSession(hash)
|
||||
if err != nil {
|
||||
logf("panel: session: touch lookup failed: %v", err)
|
||||
return false
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
// expiresAt = lastRenewal + idle, so this recovers when the session was
|
||||
// last extended without a separate column.
|
||||
lastRenewal := row.ExpiresAt.Add(-s.idle)
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.sessions[token]
|
||||
if !ok {
|
||||
return "", false
|
||||
if now.Sub(lastRenewal) < renewThreshold {
|
||||
return false
|
||||
}
|
||||
if now.After(sess.expiresAt) {
|
||||
delete(s.sessions, token)
|
||||
return "", false
|
||||
if err := s.store.RenewSession(hash, now.Add(s.idle)); err != nil {
|
||||
logf("panel: session: renew failed: %v", err)
|
||||
return false
|
||||
}
|
||||
return sess.username, true
|
||||
return true
|
||||
}
|
||||
|
||||
// Rename updates the username carried by a session, keeping its expiry. It is
|
||||
// used when the administrator renames their own account so the current session
|
||||
// keeps working under the new name.
|
||||
// used when the administrator renames their own account so the current
|
||||
// session keeps working under the new name.
|
||||
func (s *sessionStore) Rename(token, username string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if sess, ok := s.sessions[token]; ok {
|
||||
sess.username = username
|
||||
s.sessions[token] = sess
|
||||
if err := s.store.RenameSession(hashToken(token), username); err != nil {
|
||||
logf("panel: session: rename failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,18 +128,14 @@ func (s *sessionStore) Rename(token, username string) {
|
||||
// password must stop working, while the admin performing the change stays
|
||||
// signed in.
|
||||
func (s *sessionStore) DestroyOthers(keep string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for token := range s.sessions {
|
||||
if token != keep {
|
||||
delete(s.sessions, token)
|
||||
}
|
||||
if err := s.store.DeleteOtherSessions(hashToken(keep)); err != nil {
|
||||
logf("panel: session: destroy others failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy invalidates a session token (logout).
|
||||
func (s *sessionStore) Destroy(token string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, token)
|
||||
if err := s.store.DeleteSession(hashToken(token)); err != nil {
|
||||
logf("panel: session: destroy failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
package web
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
func newTestSessionStore(t *testing.T) *sessionStore {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return newSessionStore(st, 7*24*time.Hour)
|
||||
}
|
||||
|
||||
func TestSessionRename(t *testing.T) {
|
||||
s := newSessionStore()
|
||||
s := newTestSessionStore(t)
|
||||
token := s.Create("admin")
|
||||
|
||||
s.Rename(token, "operator")
|
||||
@@ -21,7 +37,7 @@ func TestSessionRename(t *testing.T) {
|
||||
// under the old password stops working) while keeping the one performing the
|
||||
// change signed in.
|
||||
func TestSessionDestroyOthers(t *testing.T) {
|
||||
s := newSessionStore()
|
||||
s := newTestSessionStore(t)
|
||||
keep := s.Create("admin")
|
||||
other := s.Create("admin")
|
||||
|
||||
@@ -34,3 +50,35 @@ func TestSessionDestroyOthers(t *testing.T) {
|
||||
t.Fatal("other session survived")
|
||||
}
|
||||
}
|
||||
|
||||
// A session past its sliding idle expiry must not be honoured.
|
||||
func TestSessionLookupRejectsExpired(t *testing.T) {
|
||||
s := newTestSessionStore(t)
|
||||
s.idle = -time.Minute // already expired the instant it's created
|
||||
token := s.Create("admin")
|
||||
|
||||
if _, ok := s.Lookup(token); ok {
|
||||
t.Fatal("expired session was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// Touch must not rewrite the expiry (or report a renewal) inside the
|
||||
// once-an-hour throttle window, so an active tab's polling doesn't turn into
|
||||
// a database write per request.
|
||||
func TestSessionTouchThrottled(t *testing.T) {
|
||||
s := newTestSessionStore(t)
|
||||
token := s.Create("admin")
|
||||
|
||||
if s.Touch(token) {
|
||||
t.Fatal("touch renewed a session created moments ago")
|
||||
}
|
||||
|
||||
// Back-date the session's last renewal by rewriting its expiry, as if it
|
||||
// had been created (or last renewed) 2 hours ago rather than moments ago.
|
||||
if err := s.store.RenewSession(hashToken(token), time.Now().Add(-2*time.Hour).Add(s.idle)); err != nil {
|
||||
t.Fatalf("renew session: %v", err)
|
||||
}
|
||||
if !s.Touch(token) {
|
||||
t.Fatal("touch did not renew a session past the throttle window")
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -59,6 +59,10 @@ type Config struct {
|
||||
// only for the send log (the journal-milter fails open).
|
||||
OpenDKIMSocket string
|
||||
JournalSocket string
|
||||
// SessionIdleDays is the sliding inactivity window after which a login
|
||||
// session expires (env PANEL_SESSION_IDLE_DAYS, plan B.1). Non-positive
|
||||
// falls back to the 7-day default.
|
||||
SessionIdleDays int
|
||||
}
|
||||
|
||||
// Server is the panel HTTP application.
|
||||
@@ -87,13 +91,17 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idleDays := cfg.SessionIdleDays
|
||||
if idleDays <= 0 {
|
||||
idleDays = 7
|
||||
}
|
||||
s := &Server{
|
||||
store: st,
|
||||
domains: domains,
|
||||
apps: apps,
|
||||
cfg: cfg,
|
||||
tmpl: tmpl,
|
||||
sessions: newSessionStore(),
|
||||
sessions: newSessionStore(st, time.Duration(idleDays)*24*time.Hour),
|
||||
// Published-DNS checks for the status page and the domain pages. The
|
||||
// checker caches its own results, so page views do not each pay for a
|
||||
// round of lookups (phase 13).
|
||||
|
||||
Reference in New Issue
Block a user