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

+25
View File
@@ -7,6 +7,8 @@ import (
// ResetAndSeedDemo drops all tables, recreates the schema, and seeds demo accounts and demo data.
func (s *Store) ResetAndSeedDemo() error {
// Logged-in demo visitors stay logged in: their sessions survive the reset (fixed account IDs)
kept := s.demoSessions()
if err := s.dropAllTables(); err != nil {
return fmt.Errorf("reset demo: drop all tables: %w", err)
}
@@ -16,6 +18,29 @@ func (s *Store) ResetAndSeedDemo() error {
if err := s.EnsureDemoAccounts(); err != nil {
return fmt.Errorf("reset demo: ensure demo accounts: %w", err)
}
for _, k := range kept {
_, _ = s.db.Exec(`INSERT OR IGNORE INTO sessions (id, user_id, created_at, last_used_at, user_agent, ip) VALUES (?, ?, ?, ?, ?, ?)`,
k.id, k.userID, k.created, k.lastUsed, k.agent, k.ip)
}
slog.Info("🧹 Demo-Modus: Datenbank für Testumgebung zurückgesetzt und neu befüllt")
return nil
}
type keptSession struct{ id, userID, created, lastUsed, agent, ip string }
func (s *Store) demoSessions() []keptSession {
rows, err := s.db.Query(`SELECT id, user_id, created_at, last_used_at, user_agent, ip FROM sessions
WHERE revoked_at = '' AND user_id IN (?, ?)`, DemoAdminID, DemoUserID)
if err != nil {
return nil // first start: no sessions table yet
}
defer rows.Close()
var out []keptSession
for rows.Next() {
var k keptSession
if rows.Scan(&k.id, &k.userID, &k.created, &k.lastUsed, &k.agent, &k.ip) == nil {
out = append(out, k)
}
}
return out
}
+1
View File
@@ -24,6 +24,7 @@ type migration struct {
var migrations = []migration{
{3, "Basis-Schema v0.4.1", (*Store).migrateBaseline},
{4, "Sitzungen", (*Store).migrateSessions},
}
func (s *Store) migrate() error {
+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
}
+5 -26
View File
@@ -303,7 +303,7 @@ func (s *Store) DeleteUser(userID string) error {
return err
}
if err := s.execInTx(
stmt(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID),
stmt(`DELETE FROM sessions WHERE user_id = ?`, userID),
stmt(`DELETE FROM task_assignees WHERE user_id = ?`, userID),
stmt(`DELETE FROM password_reset_tokens WHERE user_id = ?`, userID),
stmt(`DELETE FROM users WHERE id = ?`, userID),
@@ -315,33 +315,12 @@ func (s *Store) DeleteUser(userID string) error {
return nil
}
// RevokeToken adds an individual JWT to the blacklist until its expiration date
func (s *Store) RevokeToken(tokenStr, userID string, expiresAt time.Time) error {
hash := auth.HashAPIKey(tokenStr)
_, err := s.db.Exec(
`INSERT OR REPLACE INTO revoked_tokens (token_hash, user_id, expires_at, revoked_at) VALUES (?, ?, ?, ?)`,
hash, userID, expiresAt.UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339),
)
// Idempotently prune old expired tokens
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339))
return err
}
// IsTokenRevoked checks if a specific JWT token has been explicitly blacklisted
func (s *Store) IsTokenRevoked(tokenStr string) bool {
hash := auth.HashAPIKey(tokenStr)
var count int
err := s.db.QueryRow(
`SELECT COUNT(1) FROM revoked_tokens WHERE token_hash = ? AND expires_at >= ?`,
hash, time.Now().UTC().Format(time.RFC3339),
).Scan(&count)
return err == nil && count > 0
}
// RevokeAllUserTokens increments the user's token_version, instantly invalidating all existing JWTs for that user
func (s *Store) RevokeAllUserTokens(userID string) error {
_, err := s.db.Exec(`UPDATE users SET token_version = token_version + 1 WHERE id = ?`, userID)
return err
return s.execInTx(
stmt(`UPDATE users SET token_version = token_version + 1 WHERE id = ?`, userID),
stmt(`UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at = ''`, nowUTC(), userID),
)
}
// --- USER INVITES ---