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:
2026-08-02 23:21:49 +03:00
parent 750a65d5ee
commit 82ec287ba1
14 changed files with 331 additions and 51 deletions
+8
View File
@@ -5,6 +5,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
- panel: login sessions now persist in SQLite instead of memory, so an
administrator's login survives a container restart or redeploy. Only the
SHA-256 of the session token is stored, never the token itself. The
absolute 12-hour TTL is replaced by a **sliding idle timeout**
(`PANEL_SESSION_IDLE_DAYS`, default 7 days, no absolute cap): the
monitoring screens' background polling does not count as activity, so a
forgotten open tab does not keep a session alive forever. Changing the
password still signs out every other session.
- panel: security headers on every response — `Content-Security-Policy`, - panel: security headers on every response — `Content-Security-Policy`,
`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, and `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, and
`Strict-Transport-Security` where the deployment is HTTPS-only. They are `Strict-Transport-Security` where the deployment is HTTPS-only. They are
+1
View File
@@ -37,6 +37,7 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
TLSCertFile: cfg.tlsCertFile, TLSCertFile: cfg.tlsCertFile,
OpenDKIMSocket: cfg.opendkimSocket, OpenDKIMSocket: cfg.opendkimSocket,
JournalSocket: cfg.journalSocket, JournalSocket: cfg.journalSocket,
SessionIdleDays: cfg.sessionIdleDays,
}, cfg.setupTokenPath) }, cfg.setupTokenPath)
if err != nil { if err != nil {
return err return err
+4
View File
@@ -60,6 +60,7 @@ type config struct {
cookieSecure bool cookieSecure bool
submissionEnabled bool submissionEnabled bool
trustedProxies []*net.IPNet trustedProxies []*net.IPNet
sessionIdleDays int
// Read-only inputs to the panel's status page: the certificate Postfix // Read-only inputs to the panel's status page: the certificate Postfix
// serves and the two milter sockets it connects to. The defaults mirror // serves and the two milter sockets it connects to. The defaults mirror
@@ -103,6 +104,9 @@ func loadConfig() config {
// XFF header is trivially forgeable, so it's ignored unless the panel is // XFF header is trivially forgeable, so it's ignored unless the panel is
// told which proxy to trust. // told which proxy to trust.
trustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDR")), trustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDR")),
// Sliding session idle timeout (spec 7.6.6, plan B.1). Non-positive/invalid
// falls back to the 7-day default inside internal/web.
sessionIdleDays: envInt("PANEL_SESSION_IDLE_DAYS", 7),
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"), tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"), opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
+5
View File
@@ -16,6 +16,11 @@ RATE_LIMIT_WINDOW_SECONDS=3600
# Days of send-log history kept before the background sweep deletes rows. # Days of send-log history kept before the background sweep deletes rows.
SEND_LOG_RETENTION_DAYS=90 SEND_LOG_RETENTION_DAYS=90
# Sliding idle timeout for the panel login session, in days. No absolute cap:
# an admin who keeps coming back stays signed in indefinitely. Polling by the
# monitoring screens does not count as activity, only navigation/actions do.
PANEL_SESSION_IDLE_DAYS=7
# Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to # Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to
# supply X-Forwarded-For for login/setup rate-limiting. Leave unset unless # supply X-Forwarded-For for login/setup rate-limiting. Leave unset unless
# you know the exact address of your reverse proxy — trusting the wrong # you know the exact address of your reverse proxy — trusting the wrong
+2
View File
@@ -39,6 +39,8 @@ services:
# How long the send log keeps rows before the background sweep deletes # How long the send log keeps rows before the background sweep deletes
# them (spec 7.3, 9) — the main driver of /data growth over time. # them (spec 7.3, 9) — the main driver of /data growth over time.
SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}" SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}"
# Sliding idle timeout for the panel login session, in days (spec 7.6.6).
PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}"
volumes: volumes:
# All persistent state lives under /data (spec 9): SQLite DB, DKIM keys, # All persistent state lives under /data (spec 9): SQLite DB, DKIM keys,
# sasldb2, sender map, setup token. Back this up (panel button or the # sasldb2, sender map, setup token. Back this up (panel button or the
+2 -1
View File
@@ -38,7 +38,8 @@
## Текущее состояние ## Текущее состояние
- **Выполнено и принято:** базовый линейный план 0→11 (v1.0; аудит безопасности ТЗ 7.6 — полное соответствие), Фаза 12 (UI/UX), Фаза 13 (страница `/status`, DNS-проверки домена) и Фаза 14 (security-заголовки, проверка origin, cookie `__Host-` + обнаружение дублей, документация про `/data/setup-token`). Что именно сделано — в `git log` и `CHANGELOG.md`, здесь не дублируется. - **Выполнено и принято:** базовый линейный план 0→11 (v1.0; аудит безопасности ТЗ 7.6 — полное соответствие), Фаза 12 (UI/UX), Фаза 13 (страница `/status`, DNS-проверки домена) и Фаза 14 (security-заголовки, проверка origin, cookie `__Host-` + обнаружение дублей, документация про `/data/setup-token`). Что именно сделано — в `git log` и `CHANGELOG.md`, здесь не дублируется.
- **Решено, но ещё не реализовано:** пункты **B.1**, **B.2**, **B.3** и **C.4** плана, именно в этом порядке. B.1 — сессии переезжают в SQLite (скользящий срок бездействия 7 дней, `PANEL_SESSION_IDLE_DAYS`, опросы не продлевают, смена пароля разлогинивает всех). B.2 — ротация `mail.log` уходит с `copytruncate` на «переименовать + `postfix reload`» (правки в `logrotate-mail.conf`, `follow()` в `internal/logtail`, `readLogTail()` в `internal/web`; на стенде проверить, что после reload новый `mail.log` создаётся). B.3 — незаданный `SELFPOST_HOSTNAME` роняет контейнер в `entrypoint.sh` с развёрнутым текстом ошибки плюс синтаксическая проверка значения. C.4 — герметичный контейнерный e2e отдельным Go-модулем `test/e2e/` поверх поставляемого compose, гейт перед публикацией образа по тегу, нативная матрица amd64/arm64 вместо qemu в `release.yml`; делается **после** B.1–B.3, стендовые проверки B.1/B.3 переезжают в него регрессиями. Параметры всех четырёх согласованы полностью, кода нет: делать отдельными шагами. Замыкает очередь **D.5** — предрелизная проверка на уязвимости моделью Fable по всему дифу от `v1.0.0` плюс повторный проход по ТЗ 7.6; вместе с e2e это гейт перед тегом. - **B.1 реализован** (не выкачен на прод): сессии переехали в SQLite (`internal/store/migrations/0002_sessions.sql`, `internal/store/sessions.go`, `internal/web/session.go`) — хранится SHA-256 токена, не сам токен; скользящий срок бездействия `PANEL_SESSION_IDLE_DAYS` (по умолчанию 7 дней, без абсолютного потолка); запись в БД продлевается не чаще раза в час (`renewThreshold`); опросы мониторинга (`GET` с `HX-Request`) продление не триггерят (`isSessionActivity` в `internal/web/middleware.go`); `Max-Age` cookie выставляется тем же значением при логине и при продлении (`setSessionCookie`); смена пароля разлогинивает все сессии кроме текущей (уже было, теперь через БД). Проверено на стенде: логин → рестарт процесса панели → сессия жива по старой cookie; HX-Request-опрос и повторный GET внутри часового окна не шлют `Set-Cookie`. `go vet`/`go test ./...`/`gofmt -l .` чистые.
- **Решено, но ещё не реализовано:** пункты **B.2**, **B.3** и **C.4** плана, именно в этом порядке. B.2 — ротация `mail.log` уходит с `copytruncate` на «переименовать + `postfix reload`» (правки в `logrotate-mail.conf`, `follow()` в `internal/logtail`, `readLogTail()` в `internal/web`; на стенде проверить, что после reload новый `mail.log` создаётся). B.3 — незаданный `SELFPOST_HOSTNAME` роняет контейнер в `entrypoint.sh` с развёрнутым текстом ошибки плюс синтаксическая проверка значения. C.4 — герметичный контейнерный e2e отдельным Go-модулем `test/e2e/` поверх поставляемого compose, гейт перед публикацией образа по тегу, нативная матрица amd64/arm64 вместо qemu в `release.yml`; делается **после** B.1–B.3, стендовые проверки B.1/B.3 переезжают в него регрессиями. Замыкает очередь **D.5** — предрелизная проверка на уязвимости моделью Fable по всему дифу от `v1.0.0` плюс повторный проход по ТЗ 7.6; вместе с e2e это гейт перед тегом.
- **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы закрыты, раздел E теперь только указатель на объём 2.x (входящий релей O1+ и роль администратора домена; 2FA снята с рассмотрения); остаются принятые риски безопасности (переехали в [security.md](security.md): `POST` без `Sec-Fetch-Site`/`Origin` пропускается, CSRF-токенов нет) и опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования). - **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы закрыты, раздел E теперь только указатель на объём 2.x (входящий релей O1+ и роль администратора домена; 2FA снята с рассмотрения); остаются принятые риски безопасности (переехали в [security.md](security.md): `POST` без `Sec-Fetch-Site`/`Origin` пропускается, CSRF-токенов нет) и опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования).
- **Прод:** `selfpost.mixfed.ru`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного `Host` (Apache-фрагмент из `deploy/` это делает). - **Прод:** `selfpost.mixfed.ru`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного `Host` (Apache-фрагмент из `deploy/` это делает).
@@ -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);
+113
View File
@@ -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
}
+10 -1
View File
@@ -152,15 +152,24 @@ func (s *Server) submitLogin(w http.ResponseWriter, r *http.Request) {
} }
token := s.sessions.Create(admin.Username) 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{ http.SetCookie(w, &http.Cookie{
Name: s.sessionCookie(), Name: s.sessionCookie(),
Value: token, Value: token,
Path: "/", Path: "/",
MaxAge: s.sessions.MaxAge(),
HttpOnly: true, HttpOnly: true,
Secure: s.cfg.CookieSecure, Secure: s.cfg.CookieSecure,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
http.Redirect(w, r, "/", http.StatusSeeOther)
} }
// handleLogout destroys the session and clears the cookie. // handleLogout destroys the session and clears the cookie.
+2 -2
View File
@@ -62,7 +62,7 @@ func TestSessionTokenIgnoresTheOtherName(t *testing.T) {
} }
func TestRequireAuthRejectsDuplicateCookies(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") token := s.sessions.Create("admin")
reached := false 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 // 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. // over from a pre-__Host- build stays in the browser for the rest of its life.
func TestLogoutClearsBothCookieNames(t *testing.T) { 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") token := s.sessions.Create("admin")
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil) r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil)
+16
View File
@@ -12,6 +12,13 @@ const usernameKey ctxKey = 0
// requireAuth wraps a handler so only requests with a valid session cookie // requireAuth wraps a handler so only requests with a valid session cookie
// reach it; everyone else is redirected to the login page. The authenticated // reach it; everyone else is redirected to the login page. The authenticated
// username is stashed in the request context for downstream handlers. // 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 { func (s *Server) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := s.sessionToken(r) 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) http.Redirect(w, r, "/login", http.StatusSeeOther)
return return
} }
if isSessionActivity(r) && s.sessions.Touch(token) {
s.setSessionCookie(w, token)
}
ctx := context.WithValue(r.Context(), usernameKey, username) ctx := context.WithValue(r.Context(), usernameKey, username)
next.ServeHTTP(w, r.WithContext(ctx)) 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. // currentUser returns the authenticated username from the request context.
func currentUser(r *http.Request) string { func currentUser(r *http.Request) string {
if v, ok := r.Context().Value(usernameKey).(string); ok { if v, ok := r.Context().Value(usernameKey).(string); ok {
+96 -43
View File
@@ -1,68 +1,125 @@
package web package web
import ( import (
"sync" "crypto/sha256"
"encoding/hex"
"time" "time"
"codeberg.org/mix/selfpost/internal/store"
) )
// sessionTTL bounds how long a login lasts before re-authentication is needed. // renewThreshold bounds how often an active session's expiry is written back
const sessionTTL = 12 * time.Hour // 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 // sessionStore persists login sessions in the database (plan B.1): a login
// persisted (spec 9 lists what must survive restart; sessions are not on it): // survives a container restart or redeploy. Only the SHA-256 of the token is
// a restart simply logs the admin out, which is acceptable and avoids storing // stored, never the token itself (spec 7.6.6's crypto-random bearer token), so
// bearer tokens on disk. Tokens are crypto-random (spec 7.6.6). // 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 { type sessionStore struct {
mu sync.Mutex store *store.Store
sessions map[string]session // 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 { func newSessionStore(st *store.Store, idle time.Duration) *sessionStore {
username string return &sessionStore{store: st, idle: idle}
expiresAt time.Time
} }
func newSessionStore() *sessionStore { // MaxAge is the session cookie's Max-Age in seconds, kept equal to the
return &sessionStore{sessions: make(map[string]session)} // 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. // Create issues a new session for username and returns its token.
func (s *sessionStore) Create(username string) string { func (s *sessionStore) Create(username string) string {
token := randomToken(32) token := randomToken(32)
s.mu.Lock() now := time.Now()
defer s.mu.Unlock() if err := s.store.CreateSession(hashToken(token), username, now.Add(s.idle)); err != nil {
s.sessions[token] = session{username: username, expiresAt: time.Now().Add(sessionTTL)} 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 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) { func (s *sessionStore) Lookup(token string) (string, bool) {
if token == "" { if token == "" {
return "", false 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() now := time.Now()
s.mu.Lock() if now.Sub(lastRenewal) < renewThreshold {
defer s.mu.Unlock() return false
sess, ok := s.sessions[token]
if !ok {
return "", false
} }
if now.After(sess.expiresAt) { if err := s.store.RenewSession(hash, now.Add(s.idle)); err != nil {
delete(s.sessions, token) logf("panel: session: renew failed: %v", err)
return "", false return false
} }
return sess.username, true return true
} }
// Rename updates the username carried by a session, keeping its expiry. It is // 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 // used when the administrator renames their own account so the current
// keeps working under the new name. // session keeps working under the new name.
func (s *sessionStore) Rename(token, username string) { func (s *sessionStore) Rename(token, username string) {
s.mu.Lock() if err := s.store.RenameSession(hashToken(token), username); err != nil {
defer s.mu.Unlock() logf("panel: session: rename failed: %v", err)
if sess, ok := s.sessions[token]; ok {
sess.username = username
s.sessions[token] = sess
} }
} }
@@ -71,18 +128,14 @@ func (s *sessionStore) Rename(token, username string) {
// password must stop working, while the admin performing the change stays // password must stop working, while the admin performing the change stays
// signed in. // signed in.
func (s *sessionStore) DestroyOthers(keep string) { func (s *sessionStore) DestroyOthers(keep string) {
s.mu.Lock() if err := s.store.DeleteOtherSessions(hashToken(keep)); err != nil {
defer s.mu.Unlock() logf("panel: session: destroy others failed: %v", err)
for token := range s.sessions {
if token != keep {
delete(s.sessions, token)
}
} }
} }
// Destroy invalidates a session token (logout). // Destroy invalidates a session token (logout).
func (s *sessionStore) Destroy(token string) { func (s *sessionStore) Destroy(token string) {
s.mu.Lock() if err := s.store.DeleteSession(hashToken(token)); err != nil {
defer s.mu.Unlock() logf("panel: session: destroy failed: %v", err)
delete(s.sessions, token) }
} }
+51 -3
View File
@@ -1,9 +1,25 @@
package web 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) { func TestSessionRename(t *testing.T) {
s := newSessionStore() s := newTestSessionStore(t)
token := s.Create("admin") token := s.Create("admin")
s.Rename(token, "operator") 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 // under the old password stops working) while keeping the one performing the
// change signed in. // change signed in.
func TestSessionDestroyOthers(t *testing.T) { func TestSessionDestroyOthers(t *testing.T) {
s := newSessionStore() s := newTestSessionStore(t)
keep := s.Create("admin") keep := s.Create("admin")
other := s.Create("admin") other := s.Create("admin")
@@ -34,3 +50,35 @@ func TestSessionDestroyOthers(t *testing.T) {
t.Fatal("other session survived") 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
View File
@@ -59,6 +59,10 @@ type Config struct {
// only for the send log (the journal-milter fails open). // only for the send log (the journal-milter fails open).
OpenDKIMSocket string OpenDKIMSocket string
JournalSocket 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. // 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 { if err != nil {
return nil, err return nil, err
} }
idleDays := cfg.SessionIdleDays
if idleDays <= 0 {
idleDays = 7
}
s := &Server{ s := &Server{
store: st, store: st,
domains: domains, domains: domains,
apps: apps, apps: apps,
cfg: cfg, cfg: cfg,
tmpl: tmpl, 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 // 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 // checker caches its own results, so page views do not each pay for a
// round of lookups (phase 13). // round of lookups (phase 13).