From 5e20710aa738ada246e60c4a0695c9c75aaa9b61 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 08:06:56 +0100 Subject: [PATCH] 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 --- Doku/api/rest.md | 2 + Doku/architecture.md | 5 +- Doku/database.md | 1 + README.md | 2 +- frontend/src/App.tsx | 21 ++- .../src/features/auth/SetupAndAuthView.tsx | 175 +++++++++++++++++- frontend/src/features/help/HelpView.tsx | 3 + frontend/src/lib/api.ts | 9 + frontend/src/lib/types.ts | 2 + internal/api/password_reset.go | 91 +++++++++ internal/api/server.go | 3 + internal/api/server_test.go | 36 ++++ internal/config/config.go | 4 + internal/storage/password_reset.go | 89 +++++++++ internal/storage/password_reset_test.go | 51 +++++ internal/storage/sqlite.go | 3 + 16 files changed, 489 insertions(+), 8 deletions(-) create mode 100644 internal/api/password_reset.go create mode 100644 internal/storage/password_reset.go create mode 100644 internal/storage/password_reset_test.go diff --git a/Doku/api/rest.md b/Doku/api/rest.md index c7cb5c5..6c3ac43 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -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/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben | | 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 | `/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 | diff --git a/Doku/architecture.md b/Doku/architecture.md index 275b2f3..fe4099a 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -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 | | `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)) | -| `BASE_URL` | `http://localhost:` | Basis-URL der Instanz | +| `BASE_URL` | `http://localhost:` | 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`) | | `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` | @@ -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`). - **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`. -- **API-Keys:** `mindsift_sec_`, 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_`, 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. ## Hintergrundjob: Alarme diff --git a/Doku/database.md b/Doku/database.md index 07d44bb..dbc5073 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -19,6 +19,7 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera ### Gedanken und Aufgaben | 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` …) | | `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) | diff --git a/README.md b/README.md index a83ad51..231efc1 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d | `PORT` | `8080` | HTTP-Port | | `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 | -| `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) | | `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) | | `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5715ba2..f30c9e1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -120,6 +120,17 @@ export const App: React.FC = () => { instanceName: 'MindSift', }); const [user, setUser] = useState(null); + // Link from the password reset mail (?resetToken=…); removed from the address bar right away + const [resetToken, setResetToken] = useState(() => { + 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([]); const allThemes = useMemo(() => [...AVAILABLE_THEMES, ...customThemes.map(customThemeOption)], [customThemes]); const [stats, setStats] = useState({ @@ -666,11 +677,19 @@ export const App: React.FC = () => { } // Show Setup Wizard or Login Screen if needsSetup or not logged in - if (authStatus.needsSetup || !user) { + if (authStatus.needsSetup || !user || resetToken) { return ( { + // The new password ended every session, this one included + setResetToken(null); + clearToken(); + setUser(null); + }} + onResetCancel={() => setResetToken(null)} /> ); } diff --git a/frontend/src/features/auth/SetupAndAuthView.tsx b/frontend/src/features/auth/SetupAndAuthView.tsx index ed54997..fb36735 100644 --- a/frontend/src/features/auth/SetupAndAuthView.tsx +++ b/frontend/src/features/auth/SetupAndAuthView.tsx @@ -19,11 +19,17 @@ import { LegalLinks } from '../../components/LegalLinks'; interface SetupAndAuthViewProps { authStatus: AuthStatus; onAuthenticated: (user: User) => void; + resetToken?: string | null; + onResetDone?: () => void; + onResetCancel?: () => void; } export const SetupAndAuthView: React.FC = ({ authStatus, onAuthenticated, + resetToken, + onResetDone, + onResetCancel, }) => { // --- Setup Wizard State --- const [setupName, setSetupName] = useState(''); @@ -36,7 +42,9 @@ export const SetupAndAuthView: React.FC = ({ const [createSampleData, setCreateSampleData] = useState(false); // --- 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 [password, setPassword] = useState(''); const [displayName, setDisplayName] = useState(''); @@ -44,6 +52,48 @@ export const SetupAndAuthView: React.FC = ({ const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const [notice, setNotice] = useState(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) => { e.preventDefault(); @@ -355,13 +405,14 @@ export const SetupAndAuthView: React.FC = ({ - {/* Navigation Tabs */} -
+ {/* Navigation Tabs (not while choosing a new password) */} +
)} + {notice && !error && ( +
+ + {notice} +
+ )} + + {authTab === 'forgot' && ( +
+
+

Passwort vergessen?

+

+ Gib die E-Mail-Adresse deines Kontos ein. Wir schicken dir einen Link, mit dem du ein neues Passwort festlegst. +

+
+
+ + 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" + /> +
+
+ + +
+
+ )} + + {authTab === 'reset' && ( +
+
+

Neues Passwort festlegen

+

Danach bist du überall abgemeldet und meldest dich mit dem neuen Passwort an.

+
+
+ + 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" + /> +
+
+ + 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" + /> +
+ + +
+ )} {authTab === 'login' && (
@@ -464,7 +616,22 @@ export const SetupAndAuthView: React.FC = ({
- +
+ + {authStatus.passwordReset && ( + + )} +
= ({ onBack }) => {
  • Eigenes Theme: Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als .css-Datei hochladen oder einfügen und „Speichern & aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
  • +
  • + Passwort vergessen: 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. +
  • ), diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 38a4811..629df98 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -188,6 +188,15 @@ export const api = { return res; }, + // Public: always the same answer, whether the address exists or not + async requestPasswordReset(email: string): Promise { + await request('/api/v1/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email }) }); + }, + + async resetPassword(token: string, password: string): Promise { + 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 }> { const res = await request<{ token: string; user: User }>('/api/v1/auth/login', { method: 'POST', diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 595750b..da9c332 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -8,6 +8,8 @@ export interface AuthStatus { instanceName: string; demoMode?: boolean; sharingMode?: SharingMode; + // "Passwort vergessen?" works (SMTP and BASE_URL configured) + passwordReset?: boolean; // Impressum / Datenschutz of this instance (LEGAL_* environment variables) legal?: { imprintUrl?: string; diff --git a/internal/api/password_reset.go b/internal/api/password_reset.go new file mode 100644 index 0000000..c3e59da --- /dev/null +++ b/internal/api/password_reset.go @@ -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}) +} diff --git a/internal/api/server.go b/internal/api/server.go index 5f082c7..658b871 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -92,6 +92,8 @@ func (s *Server) Router() http.Handler { r.Post("/api/v1/auth/register", s.handleRegister) r.Post("/api/v1/auth/login", s.handleLogin) 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.Get("/api/v1/ws", 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, "demoMode": s.cfg.DemoMode, "sharingMode": s.store.SharingMode(), + "passwordReset": s.passwordResetAvailable(), // Public so the login screen can link Impressum and Datenschutz "legal": map[string]interface{}{ "imprintUrl": s.cfg.Legal.ImprintURL, diff --git a/internal/api/server_test.go b/internal/api/server_test.go index a284eb8..7be6722 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -1449,3 +1449,39 @@ func TestAPIKeysOnlyDeliverData(t *testing.T) { 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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index b3f118b..030f8c1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,9 @@ type Config struct { DatabasePath string JWTSecret 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 LogLevel string Debug bool @@ -153,6 +156,7 @@ func Load() *Config { DatabasePath: dbPath, JWTSecret: jwtSecret, BaseURL: baseURL, + BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "", AllowRegistration: allowReg, LogLevel: logLevel, Debug: debug, diff --git a/internal/storage/password_reset.go b/internal/storage/password_reset.go new file mode 100644 index 0000000..47e630c --- /dev/null +++ b/internal/storage/password_reset.go @@ -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) +} diff --git a/internal/storage/password_reset_test.go b/internal/storage/password_reset_test.go new file mode 100644 index 0000000..81c9ca7 --- /dev/null +++ b/internal/storage/password_reset_test.go @@ -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") + } +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index f2d3d65..f79b9d9 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -514,6 +514,9 @@ func (s *Store) migrate() error { } s.migrateAnchorTags() s.migrateSearchFTS() + if err := s.migratePasswordReset(); err != nil { + return err + } _, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion)) return err