feat(auth): server-side sessions and current password for account changes (FLAW-30, FLAW-31)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s

- Every login is a session (device, IP, last use); the token's jti is its ID.
  Sessions end on logout, per device from the profile, or after 30 idle days.
- Tokens older than a day are renewed via the X-Session-Token header.
- Changing password or login e-mail needs the current password; a new
  password logs out all other devices and mails the old address.
- Demo reset keeps the demo visitors' sessions.
- revoked_tokens is replaced by sessions (migration 4); everyone is logged
  out once after the upgrade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 19:25:22 +01:00
1 parent b2ecda9896
commit 1af3a6e90a
25 files changed
+705 -75

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
package storage
import (
"crypto/rand"
"encoding/hex"
"errors"
"time"
)
// Sessions (FLAW-31): every login is a row; the session token carries its ID. A session ends on
// logout, when it is ended from the session list, when all sessions are ended, or after
// SessionIdleTimeout without use. Tokens are renewed while the session is used (sliding login).
// SessionIdleTimeout ends sessions that were not used for this long.
const SessionIdleTimeout = 30 * 24 * time.Hour
// sessionTouchEvery limits writes: last_used_at is updated at most this often.
const sessionTouchEvery = 5 * time.Minute
var ErrSessionNotFound = errors.New("Sitzung nicht gefunden")
type Session struct {
ID string `json:"id"`
CreatedAt string `json:"createdAt"`
LastUsedAt string `json:"lastUsedAt"`
UserAgent string `json:"userAgent"`
IP string `json:"ip"`
Current bool `json:"current"`
}
func (s *Store) migrateSessions() error {
return s.execInTx(
stmt(`CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT NOT NULL,
user_agent TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
revoked_at TEXT NOT NULL DEFAULT ''
)`),
stmt(`CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`),
// Replaced by sessions: a logout now ends the session instead of listing the token
stmt(`DROP TABLE IF EXISTS revoked_tokens`),
)
}
func nowUTC() string { return time.Now().UTC().Format(time.RFC3339) }
// CreateSession starts a session for a login; old ended or idle sessions of the user are removed.
func (s *Store) CreateSession(userID, userAgent, ip string) (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
id := hex.EncodeToString(b)
if len(userAgent) > 300 {
userAgent = userAgent[:300]
}
idleCutoff := time.Now().Add(-SessionIdleTimeout).UTC().Format(time.RFC3339)
return id, s.execInTx(
stmt(`DELETE FROM sessions WHERE user_id = ? AND (revoked_at != '' OR last_used_at < ?)`, userID, idleCutoff),
stmt(`INSERT INTO sessions (id, user_id, created_at, last_used_at, user_agent, ip) VALUES (?, ?, ?, ?, ?, ?)`,
id, userID, nowUTC(), nowUTC(), userAgent, ip),
)
}
// SessionActive reports whether the session belongs to the user, was not ended and was used recently.
// It also records the use (at most every few minutes).
func (s *Store) SessionActive(sessionID, userID string) bool {
if sessionID == "" {
return false
}
var lastUsed, revoked string
err := s.db.QueryRow(`SELECT last_used_at, revoked_at FROM sessions WHERE id = ? AND user_id = ?`, sessionID, userID).
Scan(&lastUsed, &revoked)
if err != nil || revoked != "" {
return false
}
last, err := time.Parse(time.RFC3339, lastUsed)
if err != nil || time.Since(last) > SessionIdleTimeout {
return false
}
if time.Since(last) > sessionTouchEvery {
_, _ = s.db.Exec(`UPDATE sessions SET last_used_at = ? WHERE id = ?`, nowUTC(), sessionID)
}
return true
}
// ListSessions returns the user's active sessions, most recently used first.
func (s *Store) ListSessions(userID, currentID string) ([]Session, error) {
idleCutoff := time.Now().Add(-SessionIdleTimeout).UTC().Format(time.RFC3339)
rows, err := s.db.Query(`SELECT id, created_at, last_used_at, user_agent, ip FROM sessions
WHERE user_id = ? AND revoked_at = '' AND last_used_at >= ? ORDER BY last_used_at DESC`, userID, idleCutoff)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Session{}
for rows.Next() {
var ss Session
if err := rows.Scan(&ss.ID, &ss.CreatedAt, &ss.LastUsedAt, &ss.UserAgent, &ss.IP); err != nil {
return nil, err
}
ss.Current = ss.ID == currentID
out = append(out, ss)
}
return out, rows.Err()
}
// RevokeSession ends one session of the user.
func (s *Store) RevokeSession(userID, sessionID string) error {
res, err := s.db.Exec(`UPDATE sessions SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at = ''`, nowUTC(), sessionID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrSessionNotFound
}
return nil
}
// RevokeOtherSessions ends every session of the user except keepID ("" ends all).
func (s *Store) RevokeOtherSessions(userID, keepID string) error {
_, err := s.db.Exec(`UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND id != ? AND revoked_at = ''`, nowUTC(), userID, keepID)
return err
}