feat(auth): reset a forgotten password by mail
"Passwort vergessen?" appears on the login screen when SMTP and BASE_URL are configured. The request always gets the same answer and sends the mail in the background (at most every 2 minutes per account). The link holds a random token valid once for one hour; only its SHA-256 is stored. Setting the new password invalidates all open links and ends every session. The app removes the token from the address bar and offers a way back for expired links. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
677fe0fc0a
commit
5e20710aa7
16 files changed
+489
-8
No files matched your search
@@ -12,6 +12,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
|
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
|
||||||
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben |
|
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben |
|
||||||
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
|
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
|
||||||
|
| POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen |
|
||||||
|
| POST | `/auth/reset-password` | `{token, password}` (mind. 4 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link |
|
||||||
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
|
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
|
||||||
| POST | `/webhooks/ingest` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen |
|
| POST | `/webhooks/ingest` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen |
|
||||||
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig |
|
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig |
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
|||||||
| `DATA_DIR` | `./data` (Container: `/data`) | Verzeichnis der Datenbank |
|
| `DATA_DIR` | `./data` (Container: `/data`) | Verzeichnis der Datenbank |
|
||||||
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) |
|
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) |
|
||||||
| `JWT_SECRET` | zufällig erzeugt, gespeichert in `$DATA_DIR/jwt.secret` | Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert ([FLAW-04](flaws.md)) |
|
| `JWT_SECRET` | zufällig erzeugt, gespeichert in `$DATA_DIR/jwt.secret` | Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert ([FLAW-04](flaws.md)) |
|
||||||
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz |
|
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz; explizit gesetzt Voraussetzung für das Passwort-Zurücksetzen per Mail |
|
||||||
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme; die Zeitzonendaten sind ins Binary eingebettet (`time/tzdata`) |
|
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme; die Zeitzonendaten sind ins Binary eingebettet (`time/tzdata`) |
|
||||||
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
|
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
|
||||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
|
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
|
||||||
@@ -76,7 +76,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** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
|
||||||
- **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. `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
|
- **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. `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
|
||||||
- **Logout** schreibt den Token-Hash in `revoked_tokens`.
|
- **Logout** schreibt den Token-Hash in `revoked_tokens`.
|
||||||
- **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 authentifiziert als Besitzer ([FLAW-07](flaws.md)).
|
- **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` ([FLAW-07](flaws.md)).
|
||||||
|
- **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.
|
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
|
||||||
|
|
||||||
## Hintergrundjob: Alarme
|
## Hintergrundjob: Alarme
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera
|
|||||||
### Gedanken und Aufgaben
|
### Gedanken und Aufgaben
|
||||||
| Tabelle | Zweck | Wichtige Spalten |
|
| Tabelle | Zweck | Wichtige Spalten |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| `password_reset_tokens` | Links aus „Passwort vergessen?“ | `token_hash` (SHA-256), `user_id`, `created_at`, `expires_at` (1 h), `used_at` |
|
||||||
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
|
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
|
||||||
| `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` |
|
| `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` |
|
||||||
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
|
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
|
|||||||
| `PORT` | `8080` | HTTP-Port |
|
| `PORT` | `8080` | HTTP-Port |
|
||||||
| `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` |
|
| `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` |
|
||||||
| `JWT_SECRET` | zufällig erzeugt (`/data/jwt.secret`) | Signaturschlüssel der Anmelde-Tokens; Platzhalter werden ignoriert |
|
| `JWT_SECRET` | zufällig erzeugt (`/data/jwt.secret`) | Signaturschlüssel der Anmelde-Tokens; Platzhalter werden ignoriert |
|
||||||
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL |
|
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL. Nur wenn gesetzt (und SMTP eingerichtet ist), gibt es „Passwort vergessen?“ – die Links in den Mails werden ausschließlich daraus gebaut |
|
||||||
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme (ganztägige Aufgaben melden sich um 9:00) |
|
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme (ganztägige Aufgaben melden sich um 9:00) |
|
||||||
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
||||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
||||||
|
|||||||
+20
-1
@@ -120,6 +120,17 @@ export const App: React.FC = () => {
|
|||||||
instanceName: 'MindSift',
|
instanceName: 'MindSift',
|
||||||
});
|
});
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
// Link from the password reset mail (?resetToken=…); removed from the address bar right away
|
||||||
|
const [resetToken, setResetToken] = useState<string | null>(() => {
|
||||||
|
try {
|
||||||
|
return new URLSearchParams(window.location.search).get('resetToken');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (resetToken) window.history.replaceState(null, '', window.location.pathname);
|
||||||
|
}, [resetToken]);
|
||||||
const [customThemes, setCustomThemes] = useState<UserTheme[]>([]);
|
const [customThemes, setCustomThemes] = useState<UserTheme[]>([]);
|
||||||
const allThemes = useMemo(() => [...AVAILABLE_THEMES, ...customThemes.map(customThemeOption)], [customThemes]);
|
const allThemes = useMemo(() => [...AVAILABLE_THEMES, ...customThemes.map(customThemeOption)], [customThemes]);
|
||||||
const [stats, setStats] = useState<DashboardStats>({
|
const [stats, setStats] = useState<DashboardStats>({
|
||||||
@@ -666,11 +677,19 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show Setup Wizard or Login Screen if needsSetup or not logged in
|
// Show Setup Wizard or Login Screen if needsSetup or not logged in
|
||||||
if (authStatus.needsSetup || !user) {
|
if (authStatus.needsSetup || !user || resetToken) {
|
||||||
return (
|
return (
|
||||||
<SetupAndAuthView
|
<SetupAndAuthView
|
||||||
authStatus={authStatus}
|
authStatus={authStatus}
|
||||||
onAuthenticated={handleAuthenticated}
|
onAuthenticated={handleAuthenticated}
|
||||||
|
resetToken={resetToken}
|
||||||
|
onResetDone={() => {
|
||||||
|
// The new password ended every session, this one included
|
||||||
|
setResetToken(null);
|
||||||
|
clearToken();
|
||||||
|
setUser(null);
|
||||||
|
}}
|
||||||
|
onResetCancel={() => setResetToken(null)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,17 @@ import { LegalLinks } from '../../components/LegalLinks';
|
|||||||
interface SetupAndAuthViewProps {
|
interface SetupAndAuthViewProps {
|
||||||
authStatus: AuthStatus;
|
authStatus: AuthStatus;
|
||||||
onAuthenticated: (user: User) => void;
|
onAuthenticated: (user: User) => void;
|
||||||
|
resetToken?: string | null;
|
||||||
|
onResetDone?: () => void;
|
||||||
|
onResetCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||||
authStatus,
|
authStatus,
|
||||||
onAuthenticated,
|
onAuthenticated,
|
||||||
|
resetToken,
|
||||||
|
onResetDone,
|
||||||
|
onResetCancel,
|
||||||
}) => {
|
}) => {
|
||||||
// --- Setup Wizard State ---
|
// --- Setup Wizard State ---
|
||||||
const [setupName, setSetupName] = useState('');
|
const [setupName, setSetupName] = useState('');
|
||||||
@@ -36,7 +42,9 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
const [createSampleData, setCreateSampleData] = useState(false);
|
const [createSampleData, setCreateSampleData] = useState(false);
|
||||||
|
|
||||||
// --- Login / Invite / Register State ---
|
// --- Login / Invite / Register State ---
|
||||||
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register'>('login');
|
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register' | 'forgot' | 'reset'>(
|
||||||
|
resetToken ? 'reset' : 'login'
|
||||||
|
);
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [displayName, setDisplayName] = useState('');
|
const [displayName, setDisplayName] = useState('');
|
||||||
@@ -44,6 +52,48 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
|
||||||
|
|
||||||
|
const handleForgotPassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await api.requestPasswordReset(email.trim());
|
||||||
|
setNotice('Falls ein Konto mit dieser E-Mail-Adresse existiert, haben wir dir einen Link zum Zurücksetzen geschickt. Er gilt eine Stunde.');
|
||||||
|
setAuthTab('login');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Anfrage fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetPassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (newPassword !== newPasswordConfirm) {
|
||||||
|
setError('Die eingegebenen Passwörter stimmen nicht überein.');
|
||||||
|
triggerHaptic(25);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await api.resetPassword(resetToken || '', newPassword);
|
||||||
|
setNewPassword('');
|
||||||
|
setNewPasswordConfirm('');
|
||||||
|
setPassword('');
|
||||||
|
setNotice('Dein Passwort wurde geändert. Melde dich jetzt mit dem neuen Passwort an.');
|
||||||
|
setAuthTab('login');
|
||||||
|
onResetDone?.();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Passwort konnte nicht geändert werden');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCompleteSetup = async (e: React.FormEvent) => {
|
const handleCompleteSetup = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -355,13 +405,14 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
<Lock className="w-4 h-4 text-faint" />
|
<Lock className="w-4 h-4 text-faint" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation Tabs */}
|
{/* Navigation Tabs (not while choosing a new password) */}
|
||||||
<div className="flex border-b border-border bg-canvas text-xs">
|
<div className={`${authTab === 'reset' || authTab === 'forgot' ? 'hidden' : 'flex'} border-b border-border bg-canvas text-xs`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAuthTab('login');
|
setAuthTab('login');
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
}}
|
}}
|
||||||
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 border-r border-border transition-colors ${
|
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 border-r border-border transition-colors ${
|
||||||
authTab === 'login'
|
authTab === 'login'
|
||||||
@@ -416,6 +467,107 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{notice && !error && (
|
||||||
|
<div role="status" className="mb-4 p-2.5 rounded-card border border-status-ok/40 bg-status-ok/10 text-main flex items-start gap-2">
|
||||||
|
<Check className="w-4 h-4 text-status-ok flex-shrink-0 mt-px" />
|
||||||
|
<span>{notice}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authTab === 'forgot' && (
|
||||||
|
<form onSubmit={handleForgotPassword} className="space-y-3.5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold text-main">Passwort vergessen?</h2>
|
||||||
|
<p className="text-muted mt-1">
|
||||||
|
Gib die E-Mail-Adresse deines Kontos ein. Wir schicken dir einen Link, mit dem du ein neues Passwort festlegst.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-muted mb-1">E-Mail-Adresse</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="name@beispiel.de"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="email"
|
||||||
|
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col-reverse sm:flex-row gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setAuthTab('login');
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="btn-action h-11 sm:h-9 px-4 text-xs"
|
||||||
|
>
|
||||||
|
Zurück zur Anmeldung
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="flex-1 btn-primary h-11 sm:h-9 text-xs font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'Sende…' : 'Link anfordern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authTab === 'reset' && (
|
||||||
|
<form onSubmit={handleResetPassword} className="space-y-3.5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold text-main">Neues Passwort festlegen</h2>
|
||||||
|
<p className="text-muted mt-1">Danach bist du überall abgemeldet und meldest dich mit dem neuen Passwort an.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-muted mb-1">Neues Passwort</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
minLength={4}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-muted mb-1">Passwort wiederholen</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPasswordConfirm}
|
||||||
|
onChange={(e) => setNewPasswordConfirm(e.target.value)}
|
||||||
|
minLength={4}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full btn-primary h-11 sm:h-9 text-xs font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'Speichere…' : 'Passwort speichern'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setError(null);
|
||||||
|
setAuthTab(authStatus.passwordReset ? 'forgot' : 'login');
|
||||||
|
onResetCancel?.();
|
||||||
|
}}
|
||||||
|
className="w-full btn-action h-11 sm:h-9 text-xs"
|
||||||
|
>
|
||||||
|
{authStatus.passwordReset ? 'Neuen Link anfordern' : 'Zur Anmeldung'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{authTab === 'login' && (
|
{authTab === 'login' && (
|
||||||
<form onSubmit={handleLogin} className="space-y-3.5">
|
<form onSubmit={handleLogin} className="space-y-3.5">
|
||||||
@@ -464,7 +616,22 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-muted mb-1">Passwort</label>
|
<div className="flex items-baseline justify-between mb-1">
|
||||||
|
<label className="block text-muted">Passwort</label>
|
||||||
|
{authStatus.passwordReset && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setAuthTab('forgot');
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
}}
|
||||||
|
className="text-accent hover:underline text-[11px] py-1"
|
||||||
|
>
|
||||||
|
Passwort vergessen?
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
|
|||||||
@@ -632,6 +632,9 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
|||||||
<li>
|
<li>
|
||||||
<strong className="text-main">Eigenes Theme:</strong> Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">.css</code>-Datei hochladen oder einfügen und „Speichern & aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
|
<strong className="text-main">Eigenes Theme:</strong> Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">.css</code>-Datei hochladen oder einfügen und „Speichern & aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-main">Passwort vergessen:</strong> Hat die Administration den Mailversand eingerichtet, steht im Anmeldefenster „Passwort vergessen?“. Du bekommst einen Link, der eine Stunde gilt; mit dem neuen Passwort bist du auf allen Geräten abgemeldet.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -188,6 +188,15 @@ export const api = {
|
|||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Public: always the same answer, whether the address exists or not
|
||||||
|
async requestPasswordReset(email: string): Promise<void> {
|
||||||
|
await request('/api/v1/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email }) });
|
||||||
|
},
|
||||||
|
|
||||||
|
async resetPassword(token: string, password: string): Promise<void> {
|
||||||
|
await request('/api/v1/auth/reset-password', { method: 'POST', body: JSON.stringify({ token, password }) });
|
||||||
|
},
|
||||||
|
|
||||||
async login(loginOrEmail: string, password: string): Promise<{ token: string; user: User }> {
|
async login(loginOrEmail: string, password: string): Promise<{ token: string; user: User }> {
|
||||||
const res = await request<{ token: string; user: User }>('/api/v1/auth/login', {
|
const res = await request<{ token: string; user: User }>('/api/v1/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface AuthStatus {
|
|||||||
instanceName: string;
|
instanceName: string;
|
||||||
demoMode?: boolean;
|
demoMode?: boolean;
|
||||||
sharingMode?: SharingMode;
|
sharingMode?: SharingMode;
|
||||||
|
// "Passwort vergessen?" works (SMTP and BASE_URL configured)
|
||||||
|
passwordReset?: boolean;
|
||||||
// Impressum / Datenschutz of this instance (LEGAL_* environment variables)
|
// Impressum / Datenschutz of this instance (LEGAL_* environment variables)
|
||||||
legal?: {
|
legal?: {
|
||||||
imprintUrl?: string;
|
imprintUrl?: string;
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sandrodach/mindsift/internal/auth"
|
||||||
|
"github.com/sandrodach/mindsift/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// passwordResetAvailable: mails can be sent and links point to a known address (BASE_URL).
|
||||||
|
func (s *Server) passwordResetAvailable() bool {
|
||||||
|
if !s.cfg.BaseURLSet || s.cfg.DemoMode {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := s.smtpSettings()
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleForgotPassword always answers the same, whether the address exists or not, and sends the mail
|
||||||
|
// in the background so the response time does not tell either.
|
||||||
|
func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.passwordResetAvailable() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "Passwort-Zurücksetzen ist auf diesem Server nicht eingerichtet. Bitte wende dich an die Administration.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil || strings.TrimSpace(req.Email) == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
email := strings.TrimSpace(req.Email)
|
||||||
|
go func() {
|
||||||
|
user, err := s.store.GetUserByEmail(email)
|
||||||
|
if err != nil || user == nil || !user.IsActive {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := s.store.CreatePasswordResetToken(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, storage.ErrResetTooSoon) {
|
||||||
|
slog.Warn("Passwort-Reset: Token konnte nicht erzeugt werden", "error", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
link := strings.TrimRight(s.cfg.BaseURL, "/") + "/?resetToken=" + url.QueryEscape(token)
|
||||||
|
body := fmt.Sprintf("Hallo %s,\n\nfür dein MindSift-Konto wurde ein neues Passwort angefordert. "+
|
||||||
|
"Über diesen Link kannst du es innerhalb der nächsten Stunde festlegen:\n\n%s\n\n"+
|
||||||
|
"Der Link funktioniert nur einmal. Hast du das nicht angefordert, ignoriere diese E-Mail; dein Passwort bleibt unverändert.\n",
|
||||||
|
user.DisplayName, link)
|
||||||
|
_ = s.sendEmailNotification(user.Email, "MindSift: Passwort zurücksetzen", body)
|
||||||
|
}()
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil || req.Token == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, storage.ErrResetTokenInvalid.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Password) < 4 {
|
||||||
|
writeError(w, http.StatusBadRequest, "Das Passwort braucht mindestens 4 Zeichen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := auth.HashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, err := s.store.ResetPasswordWithToken(req.Token, hash)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, storage.ErrResetTokenInvalid) {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("Passwort per E-Mail-Link zurückgesetzt", "user_id", userID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
@@ -92,6 +92,8 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Post("/api/v1/auth/register", s.handleRegister)
|
r.Post("/api/v1/auth/register", s.handleRegister)
|
||||||
r.Post("/api/v1/auth/login", s.handleLogin)
|
r.Post("/api/v1/auth/login", s.handleLogin)
|
||||||
r.Post("/api/v1/auth/quickstart", s.handleQuickStart)
|
r.Post("/api/v1/auth/quickstart", s.handleQuickStart)
|
||||||
|
r.Post("/api/v1/auth/forgot-password", s.handleForgotPassword)
|
||||||
|
r.Post("/api/v1/auth/reset-password", s.handleResetPassword)
|
||||||
r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest)
|
r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest)
|
||||||
r.Get("/api/v1/ws", s.handleWebSocket)
|
r.Get("/api/v1/ws", s.handleWebSocket)
|
||||||
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
|
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
|
||||||
@@ -487,6 +489,7 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
"instanceName": instanceName,
|
"instanceName": instanceName,
|
||||||
"demoMode": s.cfg.DemoMode,
|
"demoMode": s.cfg.DemoMode,
|
||||||
"sharingMode": s.store.SharingMode(),
|
"sharingMode": s.store.SharingMode(),
|
||||||
|
"passwordReset": s.passwordResetAvailable(),
|
||||||
// Public so the login screen can link Impressum and Datenschutz
|
// Public so the login screen can link Impressum and Datenschutz
|
||||||
"legal": map[string]interface{}{
|
"legal": map[string]interface{}{
|
||||||
"imprintUrl": s.cfg.Legal.ImprintURL,
|
"imprintUrl": s.cfg.Legal.ImprintURL,
|
||||||
|
|||||||
@@ -1449,3 +1449,39 @@ func TestAPIKeysOnlyDeliverData(t *testing.T) {
|
|||||||
t.Fatalf("revoked token must be refused by the ingest, got %d", code)
|
t.Fatalf("revoked token must be refused by the ingest, got %d", code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPasswordResetFlow(t *testing.T) {
|
||||||
|
srv, store, _ := setupTestServer(t)
|
||||||
|
router := srv.Router()
|
||||||
|
post := func(path, body string) (int, string) {
|
||||||
|
req := httptest.NewRequest("POST", path, strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
return rec.Code, rec.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without BASE_URL and SMTP the feature is off
|
||||||
|
if code, _ := post("/api/v1/auth/forgot-password", `{"email":"x@example.com"}`); code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("forgot-password without setup: want 503, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The link from the mail sets a new password once and ends the old sessions
|
||||||
|
u, _ := store.CreateUser("reset@example.com", "hash", "Reset", "member")
|
||||||
|
raw, err := store.CreatePasswordResetToken(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("token: %v", err)
|
||||||
|
}
|
||||||
|
if code, _ := post("/api/v1/auth/reset-password", `{"token":"`+raw+`","password":"abc"}`); code != http.StatusBadRequest {
|
||||||
|
t.Errorf("too short password must be refused, got %d", code)
|
||||||
|
}
|
||||||
|
if code, body := post("/api/v1/auth/reset-password", `{"token":"`+raw+`","password":"neues-passwort"}`); code != http.StatusOK {
|
||||||
|
t.Fatalf("reset: %d %s", code, body)
|
||||||
|
}
|
||||||
|
if code, _ := post("/api/v1/auth/login", `{"login":"reset@example.com","password":"neues-passwort"}`); code != http.StatusOK {
|
||||||
|
t.Errorf("login with the new password failed: %d", code)
|
||||||
|
}
|
||||||
|
if code, _ := post("/api/v1/auth/reset-password", `{"token":"`+raw+`","password":"nochmal1"}`); code != http.StatusBadRequest {
|
||||||
|
t.Errorf("token must work only once, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@ type Config struct {
|
|||||||
DatabasePath string
|
DatabasePath string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
BaseURL string
|
BaseURL string
|
||||||
|
// BaseURLSet: BASE_URL was configured explicitly. Links in mails (password reset) are only built
|
||||||
|
// from it, never from the request's Host header.
|
||||||
|
BaseURLSet bool
|
||||||
AllowRegistration bool
|
AllowRegistration bool
|
||||||
LogLevel string
|
LogLevel string
|
||||||
Debug bool
|
Debug bool
|
||||||
@@ -153,6 +156,7 @@ func Load() *Config {
|
|||||||
DatabasePath: dbPath,
|
DatabasePath: dbPath,
|
||||||
JWTSecret: jwtSecret,
|
JWTSecret: jwtSecret,
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
|
BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "",
|
||||||
AllowRegistration: allowReg,
|
AllowRegistration: allowReg,
|
||||||
LogLevel: logLevel,
|
LogLevel: logLevel,
|
||||||
Debug: debug,
|
Debug: debug,
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Password reset: the user gets a random link token by mail. Only its SHA-256 hash is stored, so a
|
||||||
|
// leaked database does not contain usable links. A token works once and for one hour; a new password
|
||||||
|
// also ends all sessions (token_version).
|
||||||
|
|
||||||
|
const (
|
||||||
|
resetTokenTTL = time.Hour
|
||||||
|
resetTokenCooldown = 2 * time.Minute // no new mail for the same account within this time
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrResetTokenInvalid = errors.New("Der Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.")
|
||||||
|
ErrResetTooSoon = errors.New("reset requested again too soon")
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Store) migratePasswordReset() error {
|
||||||
|
_, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used_at TEXT NOT NULL DEFAULT ''
|
||||||
|
)`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashResetToken(raw string) string {
|
||||||
|
sum := sha256.Sum256([]byte(raw))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePasswordResetToken returns a new raw token for the user, or ErrResetTooSoon while the last
|
||||||
|
// one is younger than the cooldown.
|
||||||
|
func (s *Store) CreatePasswordResetToken(userID string) (string, error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
var last sql.NullString
|
||||||
|
_ = s.db.QueryRow(`SELECT MAX(created_at) FROM password_reset_tokens WHERE user_id = ?`, userID).Scan(&last)
|
||||||
|
if last.Valid {
|
||||||
|
if t, err := time.Parse(time.RFC3339, last.String); err == nil && now.Sub(t) < resetTokenCooldown {
|
||||||
|
return "", ErrResetTooSoon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
raw := hex.EncodeToString(buf)
|
||||||
|
// Expired and used tokens are of no use any more
|
||||||
|
_, _ = s.db.Exec(`DELETE FROM password_reset_tokens WHERE expires_at < ? OR used_at != ''`, now.Format(time.RFC3339))
|
||||||
|
_, err := s.db.Exec(`INSERT INTO password_reset_tokens (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
|
||||||
|
hashResetToken(raw), userID, now.Format(time.RFC3339), now.Add(resetTokenTTL).Format(time.RFC3339))
|
||||||
|
return raw, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetPasswordWithToken sets a new password hash if the token is valid and returns the user ID.
|
||||||
|
// All other open tokens of the user become invalid as well.
|
||||||
|
func (s *Store) ResetPasswordWithToken(raw, passwordHash string) (string, error) {
|
||||||
|
var userID, expires, used string
|
||||||
|
var active bool
|
||||||
|
err := s.db.QueryRow(`SELECT t.user_id, t.expires_at, t.used_at, u.is_active FROM password_reset_tokens t
|
||||||
|
JOIN users u ON u.id = t.user_id WHERE t.token_hash = ?`, hashResetToken(raw)).Scan(&userID, &expires, &used, &active)
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrResetTokenInvalid
|
||||||
|
}
|
||||||
|
exp, perr := time.Parse(time.RFC3339, expires)
|
||||||
|
if used != "" || !active || perr != nil || time.Now().UTC().After(exp) {
|
||||||
|
return "", ErrResetTokenInvalid
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
res, err := s.db.Exec(`UPDATE password_reset_tokens SET used_at = ? WHERE token_hash = ? AND used_at = ''`, now, hashResetToken(raw))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return "", ErrResetTokenInvalid // used in parallel
|
||||||
|
}
|
||||||
|
_, _ = s.db.Exec(`UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at = ''`, now, userID)
|
||||||
|
return userID, s.UpdateUserPassword(userID, passwordHash)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPasswordResetToken(t *testing.T) {
|
||||||
|
store, err := NewStore(filepath.Join(t.TempDir(), "reset.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewStore: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
u, _ := store.CreateUser("anna@example.com", "old", "Anna", "member")
|
||||||
|
|
||||||
|
raw, err := store.CreatePasswordResetToken(u.ID)
|
||||||
|
if err != nil || len(raw) != 64 {
|
||||||
|
t.Fatalf("token: %q %v", raw, err)
|
||||||
|
}
|
||||||
|
var stored string
|
||||||
|
_ = store.db.QueryRow(`SELECT token_hash FROM password_reset_tokens`).Scan(&stored)
|
||||||
|
if stored == raw {
|
||||||
|
t.Fatalf("only the hash may be stored")
|
||||||
|
}
|
||||||
|
if _, err := store.CreatePasswordResetToken(u.ID); err != ErrResetTooSoon {
|
||||||
|
t.Errorf("second request within the cooldown must be refused, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
before, _ := store.GetUserByID(u.ID)
|
||||||
|
if _, err := store.ResetPasswordWithToken("falsch", "new"); err != ErrResetTokenInvalid {
|
||||||
|
t.Errorf("unknown token must fail")
|
||||||
|
}
|
||||||
|
if id, err := store.ResetPasswordWithToken(raw, "new"); err != nil || id != u.ID {
|
||||||
|
t.Fatalf("reset: %v", err)
|
||||||
|
}
|
||||||
|
after, _ := store.GetUserByID(u.ID)
|
||||||
|
if after.PasswordHash != "new" || after.TokenVersion != before.TokenVersion+1 {
|
||||||
|
t.Errorf("password not changed or sessions not ended: %+v", after)
|
||||||
|
}
|
||||||
|
if _, err := store.ResetPasswordWithToken(raw, "again"); err != ErrResetTokenInvalid {
|
||||||
|
t.Errorf("a token works only once")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired tokens fail
|
||||||
|
_, _ = store.db.Exec(`DELETE FROM password_reset_tokens`)
|
||||||
|
raw2, _ := store.CreatePasswordResetToken(u.ID)
|
||||||
|
_, _ = store.db.Exec(`UPDATE password_reset_tokens SET expires_at = '2000-01-01T00:00:00Z'`)
|
||||||
|
if _, err := store.ResetPasswordWithToken(raw2, "x"); err != ErrResetTokenInvalid {
|
||||||
|
t.Errorf("expired token must fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -514,6 +514,9 @@ func (s *Store) migrate() error {
|
|||||||
}
|
}
|
||||||
s.migrateAnchorTags()
|
s.migrateAnchorTags()
|
||||||
s.migrateSearchFTS()
|
s.migrateSearchFTS()
|
||||||
|
if err := s.migratePasswordReset(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
_, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion))
|
_, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion))
|
||||||
return err
|
return err
|
||||||
|
|||||||
Reference in new issue
Block a user