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
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:
1 parent
b2ecda9896
commit
1af3a6e90a
25 files changed
+705
-75
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ---
|
||||
|
||||
Reference in new issue
Block a user