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

@@ -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');
});
});
+14
View File
@@ -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;
+10
View File
@@ -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;
}