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
@@ -9,11 +9,19 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
||||
### Security
|
||||
- Links in Notizen, Aufgaben-Notizen und Impressum öffnen nur noch `http`, `https` und `mailto`; `javascript:` und ähnliche Links erscheinen als Text. Vorher konnte ein Bearbeiter einer geteilten Aufgabenliste über einen präparierten Link Code im Browser anderer ausführen (FLAW-28).
|
||||
- Sicherheits-Header für alle Antworten: Content-Security-Policy (Scripts nur von MindSift selbst, keine Einbettung in fremde Seiten), `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy` (FLAW-29).
|
||||
- Passwort und Login-E-Mail lassen sich nur noch mit dem aktuellen Passwort ändern; die bisherige Adresse bekommt eine Hinweismail, wenn SMTP eingerichtet ist (FLAW-30).
|
||||
- Anmeldungen sind serverseitige Sitzungen: Jedes Gerät lässt sich einzeln abmelden, unbenutzte Sitzungen enden nach 30 Tagen, aktive werden täglich erneuert. Ein neues Passwort meldet alle anderen Geräte ab (FLAW-31).
|
||||
|
||||
### Fixed
|
||||
- Der Offline-Start der App funktioniert zuverlässig: Ein altes Script in der Startseite hat bei jedem Aufruf alle Caches des Service Workers gelöscht.
|
||||
|
||||
### Added
|
||||
- Profil → „Aktive Sitzungen“: Liste der angemeldeten Geräte mit letzter Nutzung und „Abmelden“ je Gerät.
|
||||
- API: `GET /auth/sessions`, `DELETE /auth/sessions/{id}`.
|
||||
|
||||
### Changed
|
||||
- **Achtung:** Nach dem Update sind alle Benutzer einmal abgemeldet (neue Sitzungsverwaltung, die alte Sperrliste `revoked_tokens` entfällt).
|
||||
- **Achtung:** `PATCH /auth/settings` verlangt für `password` und `email` das Feld `currentPassword`, sonst `403`.
|
||||
- Versionsnummern folgen jetzt Semantic Versioning mit dreiteiligen Tags (`v0.6.0`); die Kopfzeile zeigt die volle Version (`v0.6.0-dev` für Entwicklungsstände). Vorabversionen wie `v1.0.0-rc.1` verschieben das Image `:stable` nicht.
|
||||
- Das Mengenfeld in Listen öffnet auf dem Handy die Zahlentastatur.
|
||||
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code
|
||||
- **Datenhaltung:** vollständig in SQLite. Es gibt keine `.md`-Dateien auf der Platte; Markdown wird bei Bedarf aus den Blöcken erzeugt (`documents.markdown_cache`, Export als `.md`).
|
||||
- **Echtzeit:** ein Hub mit Räumen und Benutzer-Kanälen (WebSocket, SSE als Ersatz). Verteilt werden Ereignisse nach Änderungen.
|
||||
- **Notizen gemeinsam bearbeiten:** server-autoritatives Operations-Log mit Versionen und Sperren pro Bullet, Konfliktkopien statt Überschreiben, Offline-Warteschlange in IndexedDB, Abgleich auch hinter restriktiven Proxys ([modules/collaboration.md](modules/collaboration.md)).
|
||||
- **Auth:** JWT (HS256, 30 Tage) mit `token_version` und Sperrliste, dazu API-Keys (`mindsift_sec_…`) für Webhooks.
|
||||
- **Auth:** JWT (HS256) mit serverseitiger Sitzung (`sessions`, gleitend 30 Tage) und `token_version`, dazu API-Keys (`mindsift_sec_…`) für Webhooks.
|
||||
- **Freigaben:** Aufgabenlisten, geteilte Listen, Notizen und Ordner jeweils mit Rolle Bearbeiter oder Leser.
|
||||
|
||||
## Entwickeln
|
||||
|
||||
+4
-2
@@ -26,9 +26,11 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
||||
|---|---|---|
|
||||
| GET | `/auth/me` | Benutzer und Kennzahlen |
|
||||
| POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff |
|
||||
| POST | `/auth/logout` | Token sperren |
|
||||
| POST | `/auth/logout` | Sitzung dieses Geräts beenden |
|
||||
| GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` |
|
||||
| DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung |
|
||||
| POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
|
||||
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes` |
|
||||
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` |
|
||||
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
|
||||
| GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) |
|
||||
| GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) |
|
||||
|
||||
@@ -78,8 +78,8 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
|
||||
|
||||
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
|
||||
- **Login-Sperre:** Fehlversuche zählen im Speicher in drei Töpfen: Konto + Adresse (3), Konto (20), Adresse (30), je innerhalb von 15 Minuten; ist einer voll, gibt es 15 Minuten lang `429` mit `Retry-After`. Unbekannte Namen zählen wie Konten (kein Erraten, welche existieren). Erfolg leert die Kontotöpfe, ein Passwort-Reset (Mail oder Admin) hebt die Sperre auf. Die Client-Adresse kommt nur von vertrauenswürdigen Proxys (`TRUSTED_PROXIES`).
|
||||
- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
|
||||
- **Logout** schreibt den Token-Hash in `revoked_tokens`.
|
||||
- **JWT und Sitzungen:** HS256, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Jede Anmeldung legt eine Zeile in `sessions` an (Gerät, IP, letzte Nutzung); `jti` ist deren ID. Gültig ist ein Token nur, wenn die Sitzung aktiv ist (nicht beendet, höchstens 30 Tage unbenutzt), das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Ist das Token älter als 24 Stunden, schickt der Server im Header `X-Session-Token` ein neues für dieselbe Sitzung; das Frontend übernimmt es (`request()` in `lib/api.ts`). Aktive Geräte bleiben so angemeldet, unbenutzte fallen nach 30 Tagen heraus (`internal/api/sessions.go`). Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
|
||||
- **Logout** beendet die Sitzung des Geräts; `GET /auth/sessions` listet die Geräte, `DELETE /auth/sessions/{id}` meldet eines ab. Passwort und Login-E-Mail ändern sich nur mit dem aktuellen Passwort (`currentPassword`, falsche Eingaben zählen für die Login-Sperre); ein neues Passwort meldet alle anderen Geräte ab, dieses Gerät erhält per `X-Session-Token` ein neues Token. Ist SMTP eingerichtet, geht eine Hinweismail an die bisherige Adresse.
|
||||
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und darf nur Daten anliefern (`POST /inbox`, `POST /webhooks/ingest`); alle anderen Endpunkte antworten mit `403`.
|
||||
- **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile.
|
||||
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j
|
||||
|---|---|---|
|
||||
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url`, `organization_id` (leer = keine Organisation) |
|
||||
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` |
|
||||
| `revoked_tokens` | Sperrliste ausgeloggter JWTs | `token_hash`, `expires_at` |
|
||||
| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) |
|
||||
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
|
||||
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
|
||||
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
|
||||
|
||||
+4
-3
@@ -12,8 +12,8 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
|
||||
|---|---|---|---|
|
||||
| FLAW-28 | Frontend | ~~`javascript:`-Links in Markdown (Aufgaben-Notizen) werden ausgeführt~~ | **Behoben** |
|
||||
| FLAW-29 | Netzwerk | ~~Keine Sicherheits-Header (CSP, Clickjacking-Schutz, `nosniff`)~~ | **Behoben** |
|
||||
| FLAW-30 | Konto | Passwort und E-Mail lassen sich ohne das aktuelle Passwort ändern | Mittel |
|
||||
| FLAW-31 | Auth | Sitzungs-Token 30 Tage gültig und im `localStorage` | Mittel |
|
||||
| FLAW-30 | Konto | ~~Passwort und E-Mail lassen sich ohne das aktuelle Passwort ändern~~ | **Behoben** |
|
||||
| FLAW-31 | Auth | ~~Sitzungs-Token 30 Tage gültig und im `localStorage`~~ | **Teilweise behoben** |
|
||||
| FLAW-32 | Daten | IDs und Einladungscodes haben nur 40 Bit Zufall | Mittel |
|
||||
| FLAW-33 | Demo | Öffentliche Demo als Absender missbrauchbar (Alarm-Webhooks, Testmails, Server-Einstellungen) | Mittel |
|
||||
| FLAW-34 | Admin | SMTP-Passwort im Klartext an die Oberfläche, in DB und Datenbank-Backups | Niedrig |
|
||||
@@ -45,11 +45,13 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
|
||||
- **Fundstelle:** `handleUpdateUserSettings` in [handlers_auth.go](../internal/api/handlers_auth.go) (`PATCH /auth/settings` mit `password` bzw. `email`).
|
||||
- **Problem:** Wer eine Sitzung in die Hände bekommt (offener Rechner, gestohlenes Token), setzt ein neues Passwort oder eine neue E-Mail-Adresse und sperrt den Besitzer dauerhaft aus; über die neue Adresse funktioniert dann auch „Passwort vergessen“ für den Angreifer. Konto löschen und Daten löschen verlangen das Passwort bereits (`confirmPassword`).
|
||||
- **Maßnahme:** Für Passwort- und E-Mail-Änderung `currentPassword` verlangen (gleiche Prüfung wie `confirmPassword`), Hinweis-Mail an die alte Adresse.
|
||||
- **Status: Behoben.** `PATCH /auth/settings` verlangt für `password` und `email` das aktuelle Passwort (`checkCurrentPassword`); falsche Eingaben zählen für die Login-Sperre, leere nicht. Ein neues Passwort meldet alle anderen Geräte ab. Mit SMTP geht eine Hinweismail an die bisherige Adresse. Im Profil erscheint das Feld „Aktuelles Passwort“, sobald Passwort oder E-Mail geändert werden. Tests: `TestPasswordChangeNeedsCurrentPassword`.
|
||||
|
||||
### FLAW-31: Langlebige Sitzung im `localStorage`
|
||||
- **Fundstelle:** `GenerateToken` (30 Tage) in [auth.go](../internal/auth/auth.go), `setToken` in [lib/api.ts](../frontend/src/lib/api.ts).
|
||||
- **Problem:** Jedes Script im Kontext der App kann das Token lesen (vgl. FLAW-28); ein gestohlenes Token gilt bis zu 30 Tage. Es gibt keine Übersicht der Sitzungen, nur „Alle Sitzungen beenden“.
|
||||
- **Maßnahme:** Kurzlebiges Zugriffs-Token mit Erneuerung, Sitzungsliste mit Gerät/Zeit und Einzel-Abmelden; mittelfristig HttpOnly-Cookie mit CSRF-Schutz.
|
||||
- **Status: Teilweise behoben.** Serverseitige Sitzungen (`sessions`, [sessions.go](../internal/api/sessions.go)): Jedes Token gehört zu einer Sitzung, die einzeln beendet werden kann und nach 30 Tagen ohne Nutzung endet; das Token wird täglich über `X-Session-Token` erneuert. Profil → „Aktive Sitzungen“ zeigt Gerät, IP und letzte Nutzung mit Einzel-Abmelden. Ein gestohlenes Token lässt sich damit gezielt entwerten. Das Token liegt weiter im `localStorage`; HttpOnly-Cookies sind bewusst zurückgestellt (CSP aus FLAW-29 und FLAW-28 schließen den bekannten Weg). Tests: `TestSessionsListEndAndLogout`, `TestSessionTokenRenewedWhenOld`.
|
||||
|
||||
### FLAW-32: Nur 40 Bit Zufall in IDs
|
||||
- **Fundstelle:** `NewID` in [domain/models.go](../internal/domain/models.go) (5 Zufallsbytes); Einladungscodes `INV-…` in `CreateUserInvite` ([users.go](../internal/storage/users.go)) leiten sich davon ab.
|
||||
@@ -131,7 +133,6 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
|
||||
|
||||
### Konto & Sicherheit
|
||||
- **Zwei-Faktor-Anmeldung** (TOTP) oder **Passkeys**.
|
||||
- **Sitzungsübersicht** mit Einzel-Abmelden (siehe FLAW-31).
|
||||
- **Anmeldung über OIDC** (Authelia, Authentik, Keycloak) für Homelab-Setups.
|
||||
- **Admin-Protokoll:** wer hat wann Konten, Rollen und Einstellungen geändert.
|
||||
|
||||
|
||||
@@ -68,6 +68,6 @@ Bei der Einrichtung im Modus `organizations` wird eine Organisation mit dem Name
|
||||
|
||||
## Sitzungen und Zugriff
|
||||
|
||||
- Tokens werden bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ ungültig (`token_version`); ausgeloggte Tokens landen in `revoked_tokens`.
|
||||
- Jede Anmeldung ist eine Sitzung (`sessions`), die einzeln beendet werden kann (Logout, Profil → Aktive Sitzungen). Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ beenden alle Sitzungen (`token_version`); eine Passwortänderung alle außer der aktuellen.
|
||||
- Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
|
||||
- Bekannte Lücken in der Zugriffskontrolle:.
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LogOut, Monitor, Smartphone } from 'lucide-react';
|
||||
import { api } from '../../lib/api';
|
||||
import { Session } from '../../lib/types';
|
||||
|
||||
// Rough device label from the user agent; good enough to tell one's own devices apart
|
||||
export function describeDevice(ua: string): { label: string; mobile: boolean } {
|
||||
if (!ua) return { label: 'Unbekanntes Gerät', mobile: false };
|
||||
if (ua.startsWith('MindSift-')) return { label: 'MindSift-Client', mobile: false };
|
||||
const mobile = /Android|iPhone|iPad|Mobile/i.test(ua);
|
||||
const browser = /Edg\//.test(ua)
|
||||
? 'Edge'
|
||||
: /Firefox\//.test(ua)
|
||||
? 'Firefox'
|
||||
: /Chrome\//.test(ua)
|
||||
? 'Chrome'
|
||||
: /Safari\//.test(ua)
|
||||
? 'Safari'
|
||||
: 'Browser';
|
||||
const os = /Android/.test(ua)
|
||||
? 'Android'
|
||||
: /iPhone|iPad/.test(ua)
|
||||
? 'iOS'
|
||||
: /Windows/.test(ua)
|
||||
? 'Windows'
|
||||
: /Mac OS X/.test(ua)
|
||||
? 'macOS'
|
||||
: /Linux/.test(ua)
|
||||
? 'Linux'
|
||||
: '';
|
||||
return { label: os ? `${browser} auf ${os}` : browser, mobile };
|
||||
}
|
||||
|
||||
function formatLastUsed(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const minutes = Math.round((Date.now() - d.getTime()) / 60000);
|
||||
if (minutes < 10) return 'gerade aktiv';
|
||||
return `zuletzt ${d.toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })}`;
|
||||
}
|
||||
|
||||
export function SessionsPanel() {
|
||||
const [sessions, setSessions] = useState<Session[] | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [ending, setEnding] = useState('');
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const list = await api.listSessions();
|
||||
setSessions([...list.filter((s) => s.current), ...list.filter((s) => !s.current)]);
|
||||
setError('');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Sitzungen konnten nicht geladen werden');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const endSession = async (id: string) => {
|
||||
setEnding(id);
|
||||
try {
|
||||
await api.endSession(id);
|
||||
setSessions((prev) => (prev || []).filter((s) => s.id !== id));
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Sitzung konnte nicht beendet werden');
|
||||
} finally {
|
||||
setEnding('');
|
||||
}
|
||||
};
|
||||
|
||||
if (error) return <p className="text-[11px] text-status-err">{error}</p>;
|
||||
if (!sessions) return <p className="text-[11px] text-muted">Lade Sitzungen…</p>;
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border rounded-card border border-border bg-canvas">
|
||||
{sessions.map((s) => {
|
||||
const device = describeDevice(s.userAgent);
|
||||
const Icon = device.mobile ? Smartphone : Monitor;
|
||||
return (
|
||||
<li key={s.id} className="flex items-center gap-3 p-3 text-xs">
|
||||
<Icon size={16} className="text-muted shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-main truncate">
|
||||
{device.label}
|
||||
{s.current && <span className="ml-2 text-[10px] font-semibold text-accent">Dieses Gerät</span>}
|
||||
</p>
|
||||
<p className="text-[11px] text-faint truncate">
|
||||
{[formatLastUsed(s.lastUsedAt), s.ip].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
{!s.current && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => endSession(s.id)}
|
||||
disabled={ending === s.id}
|
||||
title="Dieses Gerät abmelden"
|
||||
className="btn-secondary min-h-[44px] sm:min-h-0 h-8 px-3 text-xs shrink-0 flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<LogOut size={13} />
|
||||
<span>{ending === s.id ? 'Melde ab…' : 'Abmelden'}</span>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
|
||||
import { LegalLinks } from '../../components/LegalLinks';
|
||||
import { AboutModal } from '../../components/AboutModal';
|
||||
import { ImportCard } from './ImportCard';
|
||||
import { SessionsPanel } from './SessionsPanel';
|
||||
import { FormSheet } from '../../components/FormSheet';
|
||||
|
||||
interface UserSettingsViewProps {
|
||||
@@ -188,6 +189,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
const [email, setEmail] = useState(currentUser.email || '');
|
||||
const [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl || '');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [revokingSessions, setRevokingSessions] = useState(false);
|
||||
@@ -253,6 +255,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
triggerHaptic(10);
|
||||
};
|
||||
|
||||
// Changing the password or the login address needs the current password (FLAW-30)
|
||||
const needsCurrentPassword =
|
||||
newPassword.trim() !== '' || email.trim().toLowerCase() !== (currentUser.email || '').toLowerCase();
|
||||
|
||||
// --- Handlers: Profile ---
|
||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -273,9 +279,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
// Not trimmed: login compares the password exactly as typed
|
||||
patch.password = newPassword;
|
||||
}
|
||||
if (needsCurrentPassword) patch.currentPassword = currentPassword;
|
||||
const updated = await api.updateSettings(patch);
|
||||
onUserUpdated(updated);
|
||||
setNewPassword('');
|
||||
setCurrentPassword('');
|
||||
setProfileMsg({ type: 'ok', text: 'Profil und Anmeldedaten erfolgreich gespeichert.' });
|
||||
} catch (err: any) {
|
||||
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
|
||||
@@ -634,10 +642,27 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Mindestens 8 Zeichen"
|
||||
autoComplete="new-password"
|
||||
className="w-full h-9 px-3 input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{needsCurrentPassword && (
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">
|
||||
Aktuelles Passwort (zur Bestätigung)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="w-full h-9 px-3 input-field"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 border-t border-border flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -654,9 +679,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-main">Aktive Sitzungen & Sicherheit</h3>
|
||||
<p className="text-[11px] text-muted">
|
||||
Sitzungen werden durch kryptografische JWTs geschützt. Bei einer Passwortänderung werden alle alten Sitzungen automatisch ungültig.
|
||||
Geräte, auf denen du angemeldet bist. Unbenutzte Sitzungen enden nach 30 Tagen; eine Passwortänderung meldet alle anderen Geräte ab.
|
||||
</p>
|
||||
</div>
|
||||
<SessionsPanel />
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3.5 rounded-card border border-border bg-canvas">
|
||||
<div className="text-xs">
|
||||
<p className="font-semibold text-main">Alle Sitzungen sofort beenden</p>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describeDevice } from '../SessionsPanel';
|
||||
|
||||
describe('describeDevice', () => {
|
||||
it('names browser and system', () => {
|
||||
expect(
|
||||
describeDevice('Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0')
|
||||
).toEqual({ label: 'Firefox auf Linux', mobile: false });
|
||||
expect(
|
||||
describeDevice(
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
|
||||
)
|
||||
).toEqual({ label: 'Safari auf iOS', mobile: true });
|
||||
expect(
|
||||
describeDevice(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36 Edg/130.0'
|
||||
).label
|
||||
).toBe('Edge auf Windows');
|
||||
});
|
||||
|
||||
it('falls back for empty agents', () => {
|
||||
expect(describeDevice('').label).toBe('Unbekanntes Gerät');
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Team,
|
||||
TaskList,
|
||||
TaskListShare,
|
||||
Session,
|
||||
Thought,
|
||||
SubTask,
|
||||
SearchResults,
|
||||
@@ -145,6 +146,10 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
window.dispatchEvent(new CustomEvent('mindsift:unauthorized'));
|
||||
}
|
||||
|
||||
// Sliding session: the server hands out a fresh token once a day (or after a password change)
|
||||
const renewed = res.headers.get('X-Session-Token');
|
||||
if (renewed && res.ok) setToken(renewed);
|
||||
|
||||
if (!res.ok) {
|
||||
let errMsg = `HTTP ${res.status}`;
|
||||
try {
|
||||
@@ -209,6 +214,14 @@ export const api = {
|
||||
}
|
||||
},
|
||||
|
||||
async listSessions(): Promise<Session[]> {
|
||||
return ensureArray(await request<Session[]>('/api/v1/auth/sessions'));
|
||||
},
|
||||
|
||||
async endSession(id: string): Promise<void> {
|
||||
await request(`/api/v1/auth/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async revokeAllSessions(): Promise<void> {
|
||||
await request<{ success: boolean }>('/api/v1/auth/revoke-all', { method: 'POST' });
|
||||
},
|
||||
@@ -270,6 +283,7 @@ export const api = {
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
currentPassword?: string;
|
||||
avatarUrl?: string;
|
||||
alarmChannels?: string[];
|
||||
alarmEmail?: string;
|
||||
|
||||
@@ -532,3 +532,13 @@ export interface WSMessage<T = any> {
|
||||
payload: T;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// A logged-in device (Profil & Sicherheit)
|
||||
export interface Session {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
current: boolean;
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
||||
_ = s.store.SeedStarterContent(user)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, err := s.startSession(r, user)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||
return
|
||||
@@ -229,7 +229,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, err := s.startSession(r, user)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||
return
|
||||
@@ -297,7 +297,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, err := s.startSession(r, user)
|
||||
if err != nil {
|
||||
slog.Error("Login-Token konnte nicht erstellt werden", "login", identifier, "user_id", user.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||
@@ -393,21 +393,11 @@ func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr); err == nil && claims != nil {
|
||||
var exp time.Time
|
||||
if claims.ExpiresAt != nil {
|
||||
exp = claims.ExpiresAt.Time
|
||||
} else {
|
||||
exp = time.Now().Add(30 * 24 * time.Hour)
|
||||
}
|
||||
// The session must really end; the client is told if it did not
|
||||
if err := s.store.RevokeToken(tokenStr, userID, exp); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Abmelden fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
// The session must really end; the client is told if it did not
|
||||
if sid := sessionIDFrom(r); sid != "" {
|
||||
if err := s.store.RevokeSession(userID, sid); err != nil && !errors.Is(err, storage.ErrSessionNotFound) {
|
||||
writeError(w, http.StatusInternalServerError, "Abmelden fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
}
|
||||
// Streams are not tied to one session; other devices reconnect with a new ticket on their own
|
||||
@@ -437,11 +427,23 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
DiscordWebhookURL *string `json:"discordWebhookUrl"`
|
||||
CustomWebhookURL *string `json:"customWebhookUrl"`
|
||||
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
|
||||
// Needed to change password or e-mail address (FLAW-30)
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
before, err := s.store.GetUserByID(userID)
|
||||
if err != nil || before == nil {
|
||||
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
|
||||
return
|
||||
}
|
||||
changesPassword := req.Password != nil && strings.TrimSpace(*req.Password) != ""
|
||||
changesEmail := req.Email != nil && !strings.EqualFold(strings.TrimSpace(*req.Email), before.Email)
|
||||
if (changesPassword || changesEmail) && !s.checkCurrentPassword(w, r, before, req.CurrentPassword) {
|
||||
return
|
||||
}
|
||||
// Alarm URLs are checked before they are stored (SSRF protection, see netguard.go)
|
||||
allowPrivate := s.allowPrivateWebhooks()
|
||||
if req.DiscordWebhookURL != nil {
|
||||
@@ -470,7 +472,18 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
// Every other device is logged out; this one keeps its session with a fresh token
|
||||
sid := sessionIDFrom(r)
|
||||
if err := s.store.RevokeOtherSessions(userID, sid); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Andere Sitzungen konnten nicht beendet werden")
|
||||
return
|
||||
}
|
||||
s.wsHub.DisconnectUser(userID)
|
||||
if fresh, err := s.store.GetUserByID(userID); err == nil {
|
||||
current := &auth.Claims{}
|
||||
current.ID = sid
|
||||
s.renewSessionToken(w, fresh, current, true)
|
||||
}
|
||||
}
|
||||
u, err := s.store.UpdateUserFull(
|
||||
userID,
|
||||
@@ -489,9 +502,51 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if changesPassword || changesEmail {
|
||||
s.notifyAccountChange(before, changesPassword, changesEmail, u.Email)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
// checkCurrentPassword verifies the current password for sensitive changes; wrong attempts count
|
||||
// towards the login protection, so this cannot be used to guess the password.
|
||||
func (s *Server) checkCurrentPassword(w http.ResponseWriter, r *http.Request, user *domain.User, password string) bool {
|
||||
ip := clientIPFrom(r)
|
||||
if wait := s.loginGuard.lockedFor(user.ID, ip); wait > 0 {
|
||||
writeLoginLocked(w, wait)
|
||||
return false
|
||||
}
|
||||
// A forgotten field is no guess and does not count as a failure
|
||||
if password == "" || !auth.CheckPassword(password, user.PasswordHash) {
|
||||
if password != "" {
|
||||
s.loginGuard.recordFailure(user.ID, ip)
|
||||
}
|
||||
writeError(w, http.StatusForbidden, "Bitte gib zur Bestätigung dein aktuelles Passwort richtig ein")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// notifyAccountChange tells the previous address about a new password or e-mail address, so a
|
||||
// takeover does not go unnoticed. Only when mail is set up; errors are logged, never shown.
|
||||
func (s *Server) notifyAccountChange(before *domain.User, password, email bool, newEmail string) {
|
||||
if _, err := s.smtpSettings(); err != nil || before.Email == "" {
|
||||
return
|
||||
}
|
||||
var what []string
|
||||
if password {
|
||||
what = append(what, "dein Passwort wurde geändert")
|
||||
}
|
||||
if email {
|
||||
what = append(what, "die E-Mail-Adresse deines Kontos wurde auf "+newEmail+" geändert")
|
||||
}
|
||||
body := fmt.Sprintf("Hallo %s,\n\nin MindSift %s.\n\nWarst du das nicht, setze bitte sofort dein Passwort zurück und beende alle Sitzungen in den Kontoeinstellungen, oder wende dich an die Administration.\n",
|
||||
before.DisplayName, strings.Join(what, " und "))
|
||||
go func() {
|
||||
_ = s.sendEmailNotification(before.Email, "MindSift: Änderung an deinem Konto", body)
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) handleTestUserAlarm(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
user, err := s.store.GetUserByID(userID)
|
||||
|
||||
@@ -19,7 +19,7 @@ func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
||||
return ""
|
||||
}
|
||||
u, err := s.store.GetUserByID(tk.userID)
|
||||
if err != nil || u == nil || !u.IsActive || u.TokenVersion != tk.tokenVersion {
|
||||
if err != nil || u == nil || !u.IsActive || u.TokenVersion != tk.tokenVersion || !s.store.SessionActive(tk.sessionID, u.ID) {
|
||||
return ""
|
||||
}
|
||||
return u.ID
|
||||
|
||||
@@ -52,7 +52,7 @@ func newTestUser(t *testing.T, store *storage.Store, email string) (*domain.User
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
tok, err := auth.GenerateToken(testJWTSecret, u.ID, u.Email, u.DisplayName, u.TokenVersion)
|
||||
tok, err := auth.GenerateToken(testJWTSecret, u.ID, u.Email, u.DisplayName, u.TokenVersion, testSession(t, store, u.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("token: %v", err)
|
||||
}
|
||||
|
||||
+14
-2
@@ -97,7 +97,7 @@ func (s *Server) Router() http.Handler {
|
||||
AllowedOrigins: allowedOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
ExposedHeaders: []string{"Link", "X-Session-Token"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
@@ -125,6 +125,8 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/api/v1/auth/me", s.handleMe)
|
||||
r.Post("/api/v1/events/rooms", s.handleSSERooms)
|
||||
r.Post("/api/v1/auth/stream-ticket", s.handleStreamTicket)
|
||||
r.Get("/api/v1/auth/sessions", s.handleListSessions)
|
||||
r.Delete("/api/v1/auth/sessions/{id}", s.handleEndSession)
|
||||
r.Post("/api/v1/auth/logout", s.handleLogout)
|
||||
r.Post("/api/v1/auth/revoke-all", s.handleRevokeAllSessions)
|
||||
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
|
||||
@@ -417,7 +419,7 @@ func (s *Server) authenticateToken(token string) (user *domain.User, keyScopes [
|
||||
return u, scopes
|
||||
}
|
||||
claims, err := auth.ValidateToken(s.cfg.JWTSecret, token)
|
||||
if err != nil || claims == nil || s.store.IsTokenRevoked(token) {
|
||||
if err != nil || claims == nil {
|
||||
return nil, nil
|
||||
}
|
||||
u, err := s.store.GetUserByID(claims.UserID)
|
||||
@@ -427,6 +429,10 @@ func (s *Server) authenticateToken(token string) (user *domain.User, keyScopes [
|
||||
if claims.TokenVersion != 0 && claims.TokenVersion != u.TokenVersion {
|
||||
return nil, nil
|
||||
}
|
||||
// Logged out, ended from the session list or idle for 30 days
|
||||
if !s.store.SessionActive(claims.ID, u.ID) {
|
||||
return nil, nil
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
@@ -478,6 +484,12 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
ctx := auth.WithUserID(r.Context(), user.ID)
|
||||
if keyScopes == nil {
|
||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
|
||||
ctx = withSession(ctx, claims.ID)
|
||||
s.renewSessionToken(w, user, claims, false)
|
||||
}
|
||||
}
|
||||
if keyScopes != nil {
|
||||
if !apiKeyAllowed(r) {
|
||||
writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest)")
|
||||
|
||||
@@ -47,7 +47,7 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
|
||||
hub := ws.NewHub()
|
||||
srv := NewServer(cfg, store, hub, nil)
|
||||
|
||||
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion, testSession(t, store, user.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
@@ -222,7 +222,7 @@ func TestQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
||||
router := srv.Router()
|
||||
|
||||
// Verify stale token from non-existent user is rejected (401)
|
||||
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old", 1)
|
||||
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old", 1, "")
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -553,11 +553,11 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
|
||||
t.Fatalf("failed to get normal user: %v", err)
|
||||
}
|
||||
|
||||
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName, adminUser.TokenVersion)
|
||||
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName, adminUser.TokenVersion, testSession(t, store, adminUser.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName, normalUser.TokenVersion)
|
||||
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName, normalUser.TokenVersion, testSession(t, store, normalUser.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
@@ -670,7 +670,7 @@ func TestWorkspaceTagsAPI(t *testing.T) {
|
||||
router := srv.Router()
|
||||
|
||||
user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member")
|
||||
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion, testSession(t, store, user.ID))
|
||||
|
||||
// Create document with hashtags
|
||||
blocks := []domain.DocumentBlock{
|
||||
@@ -920,7 +920,7 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user B: %v", err)
|
||||
}
|
||||
tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
|
||||
tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion, testSession(t, store, userB.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token B: %v", err)
|
||||
}
|
||||
@@ -1238,10 +1238,10 @@ func TestDocumentSharingStrictlyReadOnly(t *testing.T) {
|
||||
router := srv.Router()
|
||||
|
||||
userA, _ := store.CreateUser("user_a_ro@example.com", "hash", "User A", "member")
|
||||
tokenA, _ := auth.GenerateToken(srv.cfg.JWTSecret, userA.ID, userA.Email, userA.DisplayName, userA.TokenVersion)
|
||||
tokenA, _ := auth.GenerateToken(srv.cfg.JWTSecret, userA.ID, userA.Email, userA.DisplayName, userA.TokenVersion, testSession(t, store, userA.ID))
|
||||
|
||||
userB, _ := store.CreateUser("user_b_ro@example.com", "hash", "User B", "member")
|
||||
tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
|
||||
tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion, testSession(t, store, userB.ID))
|
||||
|
||||
// Create document owned by User A
|
||||
blocks := []domain.DocumentBlock{
|
||||
@@ -1525,3 +1525,13 @@ func TestPasswordResetFlow(t *testing.T) {
|
||||
t.Errorf("token must work only once, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// testSession starts a server-side session, as a login would, for tokens made directly in tests.
|
||||
func testSession(t *testing.T, store *storage.Store, userID string) string {
|
||||
t.Helper()
|
||||
sid, err := store.CreateSession(userID, "go-test", "192.0.2.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
return sid
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
// Sessions (FLAW-31): a login creates a server-side session (device, address, last use) whose ID is
|
||||
// the token's JWT ID. Requests renew the token once a day through the X-Session-Token response
|
||||
// header, so active devices stay logged in while idle sessions end after 30 days.
|
||||
|
||||
const tokenRenewAfter = 24 * time.Hour
|
||||
|
||||
type sessionIDKey struct{}
|
||||
|
||||
func sessionIDFrom(r *http.Request) string {
|
||||
id, _ := r.Context().Value(sessionIDKey{}).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
// startSession creates the session for a fresh login and returns its token.
|
||||
func (s *Server) startSession(r *http.Request, user *domain.User) (string, error) {
|
||||
sid, err := s.store.CreateSession(user.ID, r.UserAgent(), clientIPFrom(r).ip)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion, sid)
|
||||
}
|
||||
|
||||
// renewSessionToken sends a fresh token for the current session when the presented one is old (or
|
||||
// right away when force is set, e.g. after the own password changed).
|
||||
func (s *Server) renewSessionToken(w http.ResponseWriter, user *domain.User, claims *auth.Claims, force bool) {
|
||||
if claims == nil || claims.ID == "" {
|
||||
return
|
||||
}
|
||||
if !force && claims.IssuedAt != nil && time.Since(claims.IssuedAt.Time) < tokenRenewAfter {
|
||||
return
|
||||
}
|
||||
if token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion, claims.ID); err == nil {
|
||||
w.Header().Set("X-Session-Token", token)
|
||||
}
|
||||
}
|
||||
|
||||
func withSession(ctx context.Context, sessionID string) context.Context {
|
||||
return context.WithValue(ctx, sessionIDKey{}, sessionID)
|
||||
}
|
||||
|
||||
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := s.store.ListSessions(auth.GetUserID(r.Context()), sessionIDFrom(r))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
// handleEndSession logs out one device.
|
||||
func (s *Server) handleEndSession(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
if err := s.store.RevokeSession(userID, chi.URLParam(r, "id")); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, storage.ErrSessionNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
writeError(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
// That device's streams end; the others reconnect with their still valid sessions
|
||||
s.wsHub.DisconnectUser(userID)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ended": true})
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
func sessionCall(router http.Handler, method, path, token, body, ua string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", ua)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func sessionLogin(t *testing.T, router http.Handler, login, pw, ua string) string {
|
||||
t.Helper()
|
||||
rec := sessionCall(router, "POST", "/api/v1/auth/login", "", `{"login":"`+login+`","password":"`+pw+`"}`, ua)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var res struct{ Token string }
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &res)
|
||||
return res.Token
|
||||
}
|
||||
|
||||
func TestSessionsListEndAndLogout(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
_, _ = store.CreateUser("sess@example.com", mustHash(t, "richtig123"), "Sess", "member")
|
||||
laptop := sessionLogin(t, router, "sess@example.com", "richtig123", "Mozilla/5.0 (X11; Linux x86_64) Firefox/130.0")
|
||||
phone := sessionLogin(t, router, "sess@example.com", "richtig123", "Mozilla/5.0 (iPhone) Safari/604.1")
|
||||
|
||||
rec := sessionCall(router, "GET", "/api/v1/auth/sessions", laptop, "", "")
|
||||
var list []storage.Session
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &list)
|
||||
if rec.Code != http.StatusOK || len(list) != 2 {
|
||||
t.Fatalf("want 2 sessions, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var other string
|
||||
for _, s := range list {
|
||||
if s.Current != strings.Contains(s.UserAgent, "Firefox") {
|
||||
t.Fatalf("current flag wrong: %+v", s)
|
||||
}
|
||||
if !s.Current {
|
||||
other = s.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Ending the phone's session logs the phone out, the laptop stays
|
||||
if rec := sessionCall(router, "DELETE", "/api/v1/auth/sessions/"+other, laptop, "", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("end session: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := sessionCall(router, "GET", "/api/v1/auth/me", phone, "", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("ended session must be rejected, got %d", rec.Code)
|
||||
}
|
||||
if rec := sessionCall(router, "GET", "/api/v1/auth/me", laptop, "", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("other session must stay, got %d", rec.Code)
|
||||
}
|
||||
// Other users' sessions cannot be ended
|
||||
_, strangerToken := newTestUser(t, store, "stranger@example.com")
|
||||
list = nil
|
||||
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/auth/sessions", laptop, "", "").Body.Bytes(), &list)
|
||||
if rec := sessionCall(router, "DELETE", "/api/v1/auth/sessions/"+list[0].ID, strangerToken, "", ""); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("foreign session: want 404, got %d", rec.Code)
|
||||
}
|
||||
|
||||
if rec := sessionCall(router, "POST", "/api/v1/auth/logout", laptop, "", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("logout: %d", rec.Code)
|
||||
}
|
||||
if rec := sessionCall(router, "GET", "/api/v1/auth/me", laptop, "", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("token must be dead after logout, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTokenRenewedWhenOld(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
if rec := sessionCall(router, "GET", "/api/v1/auth/me", token, "", ""); rec.Header().Get("X-Session-Token") != "" {
|
||||
t.Fatal("fresh token must not be renewed")
|
||||
}
|
||||
claims, err := auth.ValidateToken(srv.cfg.JWTSecret, token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claims.IssuedAt = jwt.NewNumericDate(time.Now().Add(-48 * time.Hour))
|
||||
old, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(srv.cfg.JWTSecret))
|
||||
rec := sessionCall(router, "GET", "/api/v1/auth/me", old, "", "")
|
||||
renewed := rec.Header().Get("X-Session-Token")
|
||||
if rec.Code != http.StatusOK || renewed == "" {
|
||||
t.Fatalf("old token must be renewed, got %d %q", rec.Code, renewed)
|
||||
}
|
||||
fresh, err := auth.ValidateToken(srv.cfg.JWTSecret, renewed)
|
||||
if err != nil || fresh.ID != claims.ID {
|
||||
t.Fatalf("renewed token must keep the session: %v", err)
|
||||
}
|
||||
_ = store
|
||||
}
|
||||
|
||||
func TestPasswordChangeNeedsCurrentPassword(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
_, _ = store.CreateUser("pw@example.com", mustHash(t, "richtig123"), "Pw", "member")
|
||||
me := sessionLogin(t, router, "pw@example.com", "richtig123", "laptop")
|
||||
other := sessionLogin(t, router, "pw@example.com", "richtig123", "phone")
|
||||
|
||||
for _, body := range []string{
|
||||
`{"password":"neuesPasswort1"}`,
|
||||
`{"password":"neuesPasswort1","currentPassword":"falsch"}`,
|
||||
`{"email":"neu@example.com"}`,
|
||||
} {
|
||||
if rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", me, body, ""); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s: want 403, got %d", body, rec.Code)
|
||||
}
|
||||
}
|
||||
// Harmless changes need no password
|
||||
if rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", me, `{"displayName":"Neu"}`, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("display name: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", me, `{"password":"neuesPasswort1","currentPassword":"richtig123"}`, "")
|
||||
renewed := rec.Header().Get("X-Session-Token")
|
||||
if rec.Code != http.StatusOK || renewed == "" {
|
||||
t.Fatalf("password change: want 200 with new token, got %d %q", rec.Code, renewed)
|
||||
}
|
||||
if rec := sessionCall(router, "GET", "/api/v1/auth/me", renewed, "", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("this device must stay logged in, got %d", rec.Code)
|
||||
}
|
||||
if rec := sessionCall(router, "GET", "/api/v1/auth/me", other, "", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("other device must be logged out, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ const streamTicketTTL = 60 * time.Second
|
||||
|
||||
type streamTicket struct {
|
||||
userID string
|
||||
sessionID string
|
||||
tokenVersion int
|
||||
expires time.Time
|
||||
}
|
||||
@@ -31,7 +32,7 @@ type streamTickets struct {
|
||||
tickets map[string]streamTicket
|
||||
}
|
||||
|
||||
func (t *streamTickets) issue(userID string, tokenVersion int) (string, error) {
|
||||
func (t *streamTickets) issue(userID, sessionID string, tokenVersion int) (string, error) {
|
||||
buf := make([]byte, 24)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
@@ -48,7 +49,7 @@ func (t *streamTickets) issue(userID string, tokenVersion int) (string, error) {
|
||||
delete(t.tickets, k)
|
||||
}
|
||||
}
|
||||
t.tickets[id] = streamTicket{userID: userID, tokenVersion: tokenVersion, expires: now.Add(streamTicketTTL)}
|
||||
t.tickets[id] = streamTicket{userID: userID, sessionID: sessionID, tokenVersion: tokenVersion, expires: now.Add(streamTicketTTL)}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -70,7 +71,7 @@ func (s *Server) handleStreamTicket(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
|
||||
return
|
||||
}
|
||||
ticket, err := s.streamTickets.issue(user.ID, user.TokenVersion)
|
||||
ticket, err := s.streamTickets.issue(user.ID, sessionIDFrom(r), user.TokenVersion)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Ticket konnte nicht erstellt werden")
|
||||
return
|
||||
|
||||
@@ -58,13 +58,16 @@ func CheckPassword(password, hash string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func GenerateToken(secret, userID, email, displayName string, tokenVersion int) (string, error) {
|
||||
// TokenLifetime is the absolute lifetime of a session token; while the session is used the token is
|
||||
// renewed long before (see api.authMiddleware), idle sessions end after 30 days on the server side.
|
||||
const TokenLifetime = 30 * 24 * time.Hour
|
||||
|
||||
// GenerateToken signs a session token; sessionID (the JWT ID) names the server-side session row.
|
||||
func GenerateToken(secret, userID, email, displayName string, tokenVersion int, sessionID string) (string, error) {
|
||||
if tokenVersion <= 0 {
|
||||
tokenVersion = 1
|
||||
}
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
jti := hex.EncodeToString(b)
|
||||
jti := sessionID
|
||||
|
||||
now := time.Now()
|
||||
claims := &Claims{
|
||||
@@ -74,7 +77,7 @@ func GenerateToken(secret, userID, email, displayName string, tokenVersion int)
|
||||
TokenVersion: tokenVersion,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: jti,
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(TokenLifetime)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
Issuer: "mindsift",
|
||||
},
|
||||
|
||||
@@ -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