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