diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fb40f3..c187b34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [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`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, and `Strict-Transport-Security` where the deployment is HTTPS-only. They are diff --git a/cmd/panel/httpserver.go b/cmd/panel/httpserver.go index cce384e..72ca803 100644 --- a/cmd/panel/httpserver.go +++ b/cmd/panel/httpserver.go @@ -37,6 +37,7 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error { TLSCertFile: cfg.tlsCertFile, OpenDKIMSocket: cfg.opendkimSocket, JournalSocket: cfg.journalSocket, + SessionIdleDays: cfg.sessionIdleDays, }, cfg.setupTokenPath) if err != nil { return err diff --git a/cmd/panel/main.go b/cmd/panel/main.go index 9898424..6d6daa2 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -60,6 +60,7 @@ type config struct { cookieSecure bool submissionEnabled bool trustedProxies []*net.IPNet + sessionIdleDays int // Read-only inputs to the panel's status page: the certificate Postfix // 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 // told which proxy to trust. 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"), opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"), diff --git a/deploy/.env.example b/deploy/.env.example index c73d4cb..9ed5d19 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -16,6 +16,11 @@ RATE_LIMIT_WINDOW_SECONDS=3600 # Days of send-log history kept before the background sweep deletes rows. 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 # supply X-Forwarded-For for login/setup rate-limiting. Leave unset unless # you know the exact address of your reverse proxy — trusting the wrong diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 40ea213..159b0c5 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -39,6 +39,8 @@ services: # 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. 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: # All persistent state lives under /data (spec 9): SQLite DB, DKIM keys, # sasldb2, sender map, setup token. Back this up (panel button or the diff --git a/docs/progress.md b/docs/progress.md index 0c99597..4f05ff1 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -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`, здесь не дублируется. -- **Решено, но ещё не реализовано:** пункты **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, требует согласования). - **Прод:** `selfpost.example.com`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного `Host` (Apache-фрагмент из `deploy/` это делает). diff --git a/internal/store/migrations/0002_sessions.sql b/internal/store/migrations/0002_sessions.sql new file mode 100644 index 0000000..7e14a16 --- /dev/null +++ b/internal/store/migrations/0002_sessions.sql @@ -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); diff --git a/internal/store/sessions.go b/internal/store/sessions.go new file mode 100644 index 0000000..e254994 --- /dev/null +++ b/internal/store/sessions.go @@ -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 +} diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go index 459541c..0682e26 100644 --- a/internal/web/handlers_auth.go +++ b/internal/web/handlers_auth.go @@ -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. diff --git a/internal/web/handlers_auth_test.go b/internal/web/handlers_auth_test.go index 8fc7599..227c8a5 100644 --- a/internal/web/handlers_auth_test.go +++ b/internal/web/handlers_auth_test.go @@ -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) diff --git a/internal/web/middleware.go b/internal/web/middleware.go index efa05ae..5af4c90 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -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 { diff --git a/internal/web/session.go b/internal/web/session.go index bf565d0..b61c316 100644 --- a/internal/web/session.go +++ b/internal/web/session.go @@ -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) + } } diff --git a/internal/web/session_test.go b/internal/web/session_test.go index 38e2685..cb381c7 100644 --- a/internal/web/session_test.go +++ b/internal/web/session_test.go @@ -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") + } +} diff --git a/internal/web/web.go b/internal/web/web.go index a124560..e075252 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -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).