diff --git a/.env.example b/.env.example index ebc9827..4cccaf2 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,8 @@ TZ=Europe/Berlin # open jeder, sofort aktiv # REGISTRATION_MODE=approval # REGISTRATION_DOMAINS=firma.de, familie.de +# Neue Konten ohne Einladung bestätigen zuerst ihre E-Mail-Adresse per Link (braucht SMTP und BASE_URL): +# REGISTRATION_VERIFY_EMAIL=true # Alt: true entspricht REGISTRATION_MODE=open; false lässt die Wahl dem Admin. ALLOW_REGISTRATION=false diff --git a/CHANGELOG.md b/CHANGELOG.md index f81f697..af59e21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.24.0] - 2026-10-31 + +### Added +- **E-Mail-Adresse bestätigen:** Neue Einstellung (Administration → Server & Isolation, oder `REGISTRATION_VERIFY_EMAIL=true`). Wer sich ohne Einladung registriert, bekommt einen Link per E-Mail und ist erst danach aktiv; in den Freigabe-Modi geht das Konto danach an die Admins, die auch erst dann benachrichtigt werden. Beim Anmelden vor der Bestätigung gibt es „Link erneut senden“. Unbestätigte Konten werden nach 48 Stunden gelöscht, die Adresse ist dann wieder frei. Wirkt nur mit Mail-Server und `BASE_URL`; Einladungen brauchen keine Bestätigung. + +### Changed +- Einladung erzeugen: Die E-Mail-Adresse steht vorne und hat mehr Platz, „Wie oft verwendbar“ ist schmal. + ## [0.23.0] - 2026-10-31 ### Added @@ -484,7 +492,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.23.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.24.0...dev +[0.24.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.23.0...v0.24.0 [0.23.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.2...v0.23.0 [0.22.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.1...v0.22.2 [0.22.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.0...v0.22.1 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index dbefd8d..6b1f75e 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -11,7 +11,9 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist | | POST | `/share-target` | PWA Web Share Target Fallback: Weiterleitung auf `/` (`303 See Other`), falls Service Worker noch inaktiv | | 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`); mit Code immer, sonst je nach Registrierungsmodus. `201` mit Token oder `202 {"pending":true,"message"}`, wenn ein Admin erst freigeben muss (Login bis dahin `403`); `429` nach 20 Versuchen pro Adresse in 15 Minuten oder bei mehr als 50 wartenden Konten | +| POST | `/auth/verify-email` | Link aus der Bestätigungs-E-Mail (`token`): schaltet das Konto frei oder reicht es an die Freigabe weiter (`{"verified","pending","message"}`) | +| POST | `/auth/resend-verification` | Bestätigungslink erneut schicken (`email`); antwortet immer gleich, höchstens alle 2 Minuten pro Konto | +| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); mit Code immer, sonst je nach Registrierungsmodus. `201` mit Token oder `202 {"pending":true,"verify","message"}`, wenn erst die E-Mail-Adresse bestätigt (`verify`) und/oder ein Admin freigeben muss (Login bis dahin `403`); `429` nach 20 Versuchen pro Adresse in 15 Minuten oder bei mehr als 50 wartenden Konten | | 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; `429` nach 10 Anfragen pro Adresse in 15 Minuten | | POST | `/auth/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link | diff --git a/Doku/architecture.md b/Doku/architecture.md index 2319998..49d1c6a 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -64,6 +64,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `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`) | | `REGISTRATION_MODE`, `REGISTRATION_DOMAINS` | leer | Registrierung ohne Einladung (`closed`, `approval`, `domain`, `domain+approval`, `open`) und erlaubte E-Mail-Endungen. Gesetzt gewinnen sie über `system_settings` und sind im Admin-UI gesperrt (`registrationPolicy` in `internal/api/registration.go`). Wartende Konten: `is_active = 0` plus Zeile in `pending_registrations` (Migration 10) | +| `REGISTRATION_VERIFY_EMAIL` | leer | E-Mail-Bestätigung neuer Konten ohne Einladung (nur mit SMTP und `BASE_URL`). Token wie beim Passwort-Link nur als SHA-256 in `email_verifications` (Migration 11), 48 Stunden gültig; danach löscht die Verdichtung das unbestätigte Konto. Bei Freigabe-Modi erfahren Admins erst nach der Bestätigung davon | | `ALLOW_REGISTRATION` | `false` | `true` = `REGISTRATION_MODE=open`; `false` (Standard der Vorlagen) lässt die Wahl dem Admin-UI | | `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` | | `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen. Die Demo verschickt nichts (keine Alarme, Test- oder Hinweismails, Webhooks), Server-Einstellungen sind schreibgeschützt, Passwort, E-Mail und Konten der gemeinsam genutzten Demo-Konten lassen sich nicht ändern (`notInDemo` in `internal/api/demo.go`) | diff --git a/Doku/plans/registrierung-und-login.md b/Doku/plans/registrierung-und-login.md index 2c0d73c..052ab22 100644 --- a/Doku/plans/registrierung-und-login.md +++ b/Doku/plans/registrierung-und-login.md @@ -1,6 +1,6 @@ # Plan: Registrierung und Anmeldung (Entwurf) -Stand 2026-10-31. **Umgesetzt in v0.23.0:** Abschnitte 1 (Registrierungsmodus und Freigabe), 2 (Umgebungsvariablen) und 3 (Einladungslinks, per E-Mail einladen, mehrfach verwendbare Links). Offen: E-Mail-Bestätigung per Link, OpenID Connect. Ziel: ein Zwischending zwischen „jeder darf sich registrieren“ und „für jede Person einen Einladungscode erzeugen“, dazu bequemere Anmeldung. +Stand 2026-10-31. **Umgesetzt in v0.23.0:** Abschnitte 1 (Registrierungsmodus und Freigabe), 2 (Umgebungsvariablen) und 3 (Einladungslinks, per E-Mail einladen, mehrfach verwendbare Links). E-Mail-Bestätigung per Link in v0.24.0. Offen: OpenID Connect. Ziel: ein Zwischending zwischen „jeder darf sich registrieren“ und „für jede Person einen Einladungscode erzeugen“, dazu bequemere Anmeldung. ## Heute diff --git a/README.md b/README.md index c5877dc..60a1d7a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.23.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.24.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. @@ -106,7 +106,7 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md) ### 🏢 Benutzer, Gruppen, Administration -* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*. Einladungen als Code oder Link (auch für mehrere Personen, auf Wunsch per E-Mail verschickt). Registrierung ohne Einladung wahlweise geschlossen, mit Freigabe durch einen Admin, nur für bestimmte E-Mail-Endungen oder offen – auch fest per Umgebungsvariable. +* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*. Einladungen als Code oder Link (auch für mehrere Personen, auf Wunsch per E-Mail verschickt). Registrierung ohne Einladung wahlweise geschlossen, mit Freigabe durch einen Admin, nur für bestimmte E-Mail-Endungen oder offen, auf Wunsch mit Bestätigung der E-Mail-Adresse per Link – auch fest per Umgebungsvariable. * Gruppen mit Gruppen-Admins für Freigaben. * Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus, Sicherungen, Spracherkennung. @@ -193,6 +193,7 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d | `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme (ganztägige Aufgaben melden sich um 9:00) | | `REGISTRATION_MODE` | leer (Admin-UI entscheidet) | Registrierung ohne Einladung: `closed`, `approval` (Admin gibt frei), `domain` (nur erlaubte E-Mail-Endungen), `domain+approval` (Endungen sofort, andere nach Freigabe), `open`. Gesetzt ist es im Admin-UI gesperrt | | `REGISTRATION_DOMAINS` | leer | Erlaubte E-Mail-Endungen für die `domain`-Modi, z. B. `firma.de, *.firma.de`; gesetzt im Admin-UI gesperrt | +| `REGISTRATION_VERIFY_EMAIL` | leer (Admin-UI entscheidet) | `true`: Wer sich ohne Einladung registriert, bestätigt zuerst seine E-Mail-Adresse per Link (braucht SMTP und `BASE_URL`); unbestätigte Konten werden nach 48 Stunden gelöscht | | `ALLOW_REGISTRATION` | `false` | `true` = wie `REGISTRATION_MODE=open` (gesperrt); `false` lässt die Wahl dem Admin-UI | | `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) | diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d326fdf..99f9442 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.23.0", + "version": "0.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.23.0", + "version": "0.24.0", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index d5f1070..586169b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.23.0", + "version": "0.24.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/features/auth/SetupAndAuthView.tsx b/frontend/src/features/auth/SetupAndAuthView.tsx index a74b58c..ab43865 100644 --- a/frontend/src/features/auth/SetupAndAuthView.tsx +++ b/frontend/src/features/auth/SetupAndAuthView.tsx @@ -52,6 +52,34 @@ export const SetupAndAuthView: React.FC = ({ const [password, setPassword] = useState(''); const [displayName, setDisplayName] = useState(''); const [inviteCode, setInviteCode] = useState(linkInviteCode); + // Link from the confirmation mail (?verifyToken=…): confirm right away and say what happens next + useEffect(() => { + const url = new URL(window.location.href); + const token = url.searchParams.get('verifyToken'); + if (!token) return; + url.searchParams.delete('verifyToken'); + window.history.replaceState({}, '', url.pathname + url.search + url.hash); + setAuthTab('login'); + api + .verifyEmail(token) + .then((res) => setNotice(res.message)) + .catch((err) => setError(err.message || 'Der Bestätigungslink ist ungültig oder abgelaufen.')); + }, []); + // Login refused because the address is not confirmed: offer to send the link again + const [resendState, setResendState] = useState<'idle' | 'sending' | 'sent'>('idle'); + const resendVerification = async () => { + setResendState('sending'); + try { + await api.resendVerification(email.trim()); + setResendState('sent'); + setError(null); + setNotice('Falls die Adresse noch auf Bestätigung wartet, haben wir dir den Link erneut geschickt.'); + } catch (err: any) { + setResendState('idle'); + setError(err.message || 'Senden fehlgeschlagen'); + } + }; + // The code is in the form now; it should not stay in the address bar (history, screenshots) useEffect(() => { if (!linkInviteCode) return; @@ -65,6 +93,8 @@ export const SetupAndAuthView: React.FC = ({ switch (authStatus.registrationMode) { case 'approval': return 'Nach der Registrierung gibt ein Administrator dein Konto frei.'; + case 'open': + return authStatus.emailVerification ? 'Du bekommst eine E-Mail, um deine Adresse zu bestätigen.' : ''; case 'domain': return domains ? `Registrierung mit E-Mail-Adressen von ${domains}.` : ''; case 'domain+approval': @@ -74,6 +104,10 @@ export const SetupAndAuthView: React.FC = ({ } return ''; })(); + const registrationHintFull = + registrationHint && authStatus.emailVerification && authStatus.registrationMode !== 'open' + ? `${registrationHint} Vorher bestätigst du deine Adresse per E-Mail.` + : registrationHint; const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -512,7 +546,17 @@ export const SetupAndAuthView: React.FC = ({ {error && (
- {error} + {error} + {/bestätige zuerst deine E-Mail/i.test(error) && email.trim() && ( + + )}
)} {notice && !error && ( @@ -718,8 +762,8 @@ export const SetupAndAuthView: React.FC = ({ {(authTab === 'invite' || authTab === 'register') && (
- {authTab === 'register' && registrationHint && ( -

{registrationHint}

+ {authTab === 'register' && registrationHintFull && ( +

{registrationHintFull}

)} {authTab === 'invite' && (
diff --git a/frontend/src/features/settings/AdminSettingsView.tsx b/frontend/src/features/settings/AdminSettingsView.tsx index fd409ea..cfdd4e4 100644 --- a/frontend/src/features/settings/AdminSettingsView.tsx +++ b/frontend/src/features/settings/AdminSettingsView.tsx @@ -141,6 +141,9 @@ export const AdminSettingsView: React.FC = ({ registrationMode: sys.registrationMode || (sys.allowRegistration ? 'open' : 'closed'), registrationDomains: sys.registrationDomains || '', registrationConfiguredViaEnv: Boolean(sys.registrationConfiguredViaEnv), + registrationVerifyEmail: Boolean(sys.registrationVerifyEmail), + registrationVerifyConfiguredViaEnv: Boolean(sys.registrationVerifyConfiguredViaEnv), + emailLinksAvailable: Boolean(sys.emailLinksAvailable), instanceName: sys.instanceName || 'MindSift', smtpHost: sys.smtpHost || '', smtpPort: sys.smtpPort ? String(sys.smtpPort) : '587', @@ -902,7 +905,10 @@ export const AdminSettingsView: React.FC = ({
-

{u.displayName}

+

+ {u.displayName} + {u.emailUnverified && E-Mail unbestätigt} +

{u.email} · registriert am {new Date(u.createdAt).toLocaleDateString('de-DE')}

@@ -947,8 +953,8 @@ export const AdminSettingsView: React.FC = ({
{u.displayName} {!u.isActive && ( - - Gesperrt + + {u.emailUnverified ? 'E-Mail unbestätigt' : 'Gesperrt'} )}
@@ -1355,6 +1361,33 @@ export const AdminSettingsView: React.FC = ({ REGISTRATION_DOMAINS) und hier nicht änderbar.

)} + {mode !== 'closed' && ( +
+
+

E-Mail-Adresse bestätigen lassen

+

+ Neue Konten ohne Einladung bekommen einen Link per E-Mail und sind erst danach aktiv (bei Freigabe + landen sie danach bei dir). Unbestätigte Konten werden nach 48 Stunden gelöscht. +

+ {!settings.emailLinksAvailable && ( +

+ Wirkt erst mit eingerichtetem Mail-Server und gesetzter BASE_URL. +

+ )} + {settings.registrationVerifyConfiguredViaEnv && ( +

+ Über REGISTRATION_VERIFY_EMAIL festgelegt. +

+ )} +
+ handleSaveSystemSettings({ registrationVerifyEmail })} + title="E-Mail-Adresse bei der Registrierung bestätigen lassen" + /> +
+ )}
); })()} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c9c120e..8966f86 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1130,6 +1130,14 @@ export const api = { }); }, + async verifyEmail(token: string): Promise<{ verified: boolean; pending: boolean; message: string }> { + return request('/api/v1/auth/verify-email', { method: 'POST', body: JSON.stringify({ token }) }); + }, + + async resendVerification(email: string): Promise { + await request('/api/v1/auth/resend-verification', { method: 'POST', body: JSON.stringify({ email }) }); + }, + async adminSendInvite(id: string, email: string): Promise { return request(`/api/v1/admin/invites/${id}/send`, { method: 'POST', body: JSON.stringify({ email }) }); }, diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index bac9554..c448040 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -10,6 +10,8 @@ export interface AuthStatus { // Registering without an invite and the e-mail domains of the domain modes (hint on the form) registrationMode?: RegistrationMode; registrationDomains?: string[]; + // New accounts confirm their e-mail address by link first + emailVerification?: boolean; instanceName: string; demoMode?: boolean; sharingMode?: SharingMode; @@ -101,6 +103,8 @@ export interface User { isActive: boolean; // Registered and waiting for an admin's approval (admin user list) pendingApproval?: boolean; + // Registered, e-mail address not confirmed yet (admin user list) + emailUnverified?: boolean; alarmChannels?: string[]; alarmEmail?: string; discordWebhookUrl?: string; @@ -264,6 +268,10 @@ export interface SystemSettings { registrationDomains?: string; // REGISTRATION_MODE / REGISTRATION_DOMAINS (or ALLOW_REGISTRATION=true) fix it: shown, not editable registrationConfiguredViaEnv?: boolean; + registrationVerifyEmail?: boolean; + registrationVerifyConfiguredViaEnv?: boolean; + // Mails with links can go out (mail server and BASE_URL configured) + emailLinksAvailable?: boolean; // Automatic backups: daily time (HH:MM, server time zone) and days to keep them backupEnabled?: boolean; backupTime?: string; diff --git a/internal/api/alarms.go b/internal/api/alarms.go index 2b54fd6..964c31a 100644 --- a/internal/api/alarms.go +++ b/internal/api/alarms.go @@ -76,6 +76,9 @@ func (s *Server) sendEmailHTML(toEmail, subject, bodyText, html string) error { if err != nil { return err } + if s.notifyEmailHook != nil { // tests + return s.notifyEmailHook(toEmail, subject, bodyText, html) + } if err := sendMail(cfg, toEmail, subject, bodyText, html); err != nil { step := "" if se, ok := err.(*smtpStepError); ok { diff --git a/internal/api/docsync.go b/internal/api/docsync.go index 6c45c34..7a926ee 100644 --- a/internal/api/docsync.go +++ b/internal/api/docsync.go @@ -270,6 +270,9 @@ func (s *Server) startCompaction() { func (s *Server) compactOnce() { s.leases.Sweep() + if n := s.store.PurgeUnverifiedAccounts(time.Now()); n > 0 { + slog.Info("Unbestätigte Registrierungen gelöscht", "count", n) + } s.compactMedia(time.Now()) _ = s.store.PruneLinkPreviews(time.Now().Add(-30 * 24 * time.Hour)) st, err := s.store.CompactDocs(time.Now()) diff --git a/internal/api/handlers_admin.go b/internal/api/handlers_admin.go index 8cccf5e..c5dbda0 100644 --- a/internal/api/handlers_admin.go +++ b/internal/api/handlers_admin.go @@ -95,8 +95,10 @@ func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) { return } pending := s.store.PendingRegistrationIDs() + unverified := s.store.UnverifiedEmailIDs() for i := range users { users[i].PendingApproval = pending[users[i].ID] + users[i].EmailUnverified = unverified[users[i].ID] } writeJSON(w, http.StatusOK, users) } @@ -197,7 +199,9 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) { s.wsHub.DisconnectUser(targetUserID) } if req.IsActive != nil && *req.IsActive { - s.store.ClearPendingRegistration(targetUserID) // activating by hand counts as approval + // Activating by hand counts as approval and vouches for the address + s.store.ClearPendingRegistration(targetUserID) + s.store.ClearEmailVerification(targetUserID) } writeJSON(w, http.StatusOK, u) } @@ -332,6 +336,11 @@ func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.Sys out.RegistrationDomains = strings.Join(storage.ParseEmailDomains(s.cfg.RegistrationDomains), ", ") out.RegistrationConfiguredViaEnv = true } + if s.cfg.RegistrationVerifyEmail != "" { + out.RegistrationVerifyEmail = s.cfg.RegistrationVerifyEmail == "true" + out.RegistrationVerifyViaEnv = true + } + out.EmailLinksAvailable = s.emailLinksAvailable() return &out } @@ -358,6 +367,11 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques } } } + if s.cfg.RegistrationVerifyEmail != "" { + if cur, err := s.store.GetSystemSettings(); err == nil { + settings.RegistrationVerifyEmail = cur.RegistrationVerifyEmail + } + } previousMode := s.store.SharingMode() if err := s.store.UpdateSystemSettings(&settings); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 2e48dff..39d5bb3 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -43,6 +43,7 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { "allowRegistration": policy.Mode != storage.RegistrationClosed, "registrationMode": policy.Mode, "registrationDomains": policy.Domains, + "emailVerification": policy.VerifyEmail, "instanceName": instanceName, "demoMode": s.cfg.DemoMode, "sharingMode": s.store.SharingMode(), @@ -199,6 +200,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { role := "member" var validInvite *domain.UserInvite pending := false // registered without invite while approval is needed + verify := false // registered without invite while the address must be confirmed if strings.TrimSpace(req.InviteCode) != "" { inv, err := s.store.GetUserInviteByCode(req.InviteCode) @@ -215,8 +217,10 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { } else if count == 0 { role = "admin" } else { + policy := s.registrationPolicy() var err error - pending, err = s.registrationPolicy().registrationOutcome(req.Email) + pending, err = policy.registrationOutcome(req.Email) + verify = policy.VerifyEmail if err != nil { writeError(w, http.StatusForbidden, err.Error()) return @@ -261,22 +265,35 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { } } - // Waiting for approval: the account exists but stays locked, no session yet - if pending { + // Waiting for the e-mail confirmation and/or approval: the account exists but stays locked, no session + if pending || verify { inactive := false if _, err := s.store.UpdateUser(user.ID, nil, nil, nil, &inactive, nil, nil); err != nil { writeError(w, http.StatusInternalServerError, "Konto konnte nicht angelegt werden") return } - if err := s.store.AddPendingRegistration(user.ID); err != nil { - writeError(w, http.StatusInternalServerError, "Konto konnte nicht angelegt werden") - return + if pending { + if err := s.store.AddPendingRegistration(user.ID); err != nil { + writeError(w, http.StatusInternalServerError, "Konto konnte nicht angelegt werden") + return + } } - slog.Info("Registrierung wartet auf Freigabe", "user_id", user.ID, "email", user.Email) - go s.notifyAdminsOfRegistration(user) + message := "Dein Konto wurde angelegt und wartet auf die Freigabe durch einen Administrator. Du bekommst eine E-Mail, sobald du dich anmelden kannst." + if verify { + // Admins hear about it only once the address is confirmed + go s.sendVerificationMail(user) + message = "Fast geschafft: Wir haben dir eine E-Mail an " + user.Email + " geschickt. Bestätige deine Adresse über den Link darin." + if pending { + message += " Danach schaltet ein Administrator dein Konto frei." + } + } else { + go s.notifyAdminsOfRegistration(user) + } + slog.Info("Registrierung wartet", "user_id", user.ID, "email", user.Email, "approval", pending, "verify", verify) writeJSON(w, http.StatusAccepted, map[string]interface{}{ "pending": true, - "message": "Dein Konto wurde angelegt und wartet auf die Freigabe durch einen Administrator. Du bekommst eine E-Mail, sobald du dich anmelden kannst.", + "verify": verify, + "message": message, }) return } @@ -343,6 +360,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { return } s.loginGuard.recordSuccess(account, ip) + if !user.IsActive && s.store.IsEmailUnverified(user.ID) { + writeError(w, http.StatusForbidden, "Bitte bestätige zuerst deine E-Mail-Adresse über den Link, den wir dir geschickt haben") + return + } if !user.IsActive && s.store.IsPendingRegistration(user.ID) { writeError(w, http.StatusForbidden, "Dein Konto wartet noch auf die Freigabe durch einen Administrator") return diff --git a/internal/api/registration.go b/internal/api/registration.go index 97d5474..62012c4 100644 --- a/internal/api/registration.go +++ b/internal/api/registration.go @@ -22,6 +22,9 @@ type registrationPolicy struct { Mode string Domains []string ViaEnv bool + // VerifyEmail: self-registrations confirm their address by link first (only when mails with links + // can go out, see emailLinksAvailable) + VerifyEmail bool } // registrationPolicy: the environment (REGISTRATION_MODE / REGISTRATION_DOMAINS) wins over the admin @@ -31,7 +34,12 @@ func (s *Server) registrationPolicy() registrationPolicy { if sys, _ := s.store.GetSystemSettings(); sys != nil { p.Mode = sys.RegistrationMode p.Domains = storage.ParseEmailDomains(sys.RegistrationDomains) + p.VerifyEmail = sys.RegistrationVerifyEmail } + if s.cfg.RegistrationVerifyEmail != "" { + p.VerifyEmail = s.cfg.RegistrationVerifyEmail == "true" + } + p.VerifyEmail = p.VerifyEmail && s.emailLinksAvailable() if s.cfg.RegistrationMode != "" { p.Mode, p.ViaEnv = s.cfg.RegistrationMode, true } @@ -72,6 +80,100 @@ func domainList(domains []string) string { return strings.Join(out, ", ") } +// emailLinksAvailable: mails with links to people who are not logged in can go out – a mail server +// and BASE_URL (links to strangers are never built from the request's Host header). +func (s *Server) emailLinksAvailable() bool { + return s.passwordResetAvailable() +} + +// sendVerificationMail mails the confirmation link of a new account. +func (s *Server) sendVerificationMail(user *domain.User) { + raw, err := s.store.CreateEmailVerification(user.ID) + if err != nil { + if !errors.Is(err, storage.ErrResetTooSoon) { + slog.Warn("Bestätigungslink konnte nicht erzeugt werden", "user_id", user.ID, "error", err) + } + return + } + link := strings.TrimRight(s.cfg.BaseURL, "/") + "/?verifyToken=" + url.QueryEscape(raw) + err = s.sendMailContent(user.Email, "MindSift: Bitte bestätige deine E-Mail-Adresse", mailContent{ + Preheader: "Ein Klick, dann ist dein Konto bereit", + Greeting: mailGreeting(time.Now(), user.DisplayName), + Intro: "Du hast dich bei MindSift registriert. Bitte bestätige noch deine E-Mail-Adresse.", + Button: &mailButton{Label: "E-Mail-Adresse bestätigen", URL: link}, + Note: "Der Link gilt 48 Stunden. Hast du dich nicht registriert, ignoriere diese E-Mail – das Konto wird dann automatisch gelöscht.\n" + + "Falls der Knopf nicht geht: " + link, + Footer: mailFooterAccount, + }) + if err != nil { + slog.Warn("Bestätigungs-E-Mail nicht verschickt", "user_id", user.ID, "error", err) + } +} + +// handleVerifyEmail: POST /auth/verify-email {"token"} – the link from the mail. Without approval the +// account is active now, otherwise it goes on to the admins. +func (s *Server) handleVerifyEmail(w http.ResponseWriter, r *http.Request) { + var req struct { + Token string `json:"token"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil || strings.TrimSpace(req.Token) == "" { + writeError(w, http.StatusBadRequest, storage.ErrVerificationInvalid.Error()) + return + } + userID, err := s.store.ConfirmEmailVerification(strings.TrimSpace(req.Token)) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + user, err := s.store.GetUserByID(userID) + if err != nil || user == nil { + writeError(w, http.StatusBadRequest, storage.ErrVerificationInvalid.Error()) + return + } + if s.store.IsPendingRegistration(userID) { + go s.notifyAdminsOfRegistration(user) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "verified": true, "pending": true, + "message": "Deine E-Mail-Adresse ist bestätigt. Ein Administrator schaltet dein Konto noch frei – du bekommst dann eine E-Mail.", + }) + return + } + active := true + if _, err := s.store.UpdateUser(userID, nil, nil, nil, &active, nil, nil); err != nil { + writeError(w, http.StatusInternalServerError, "Konto konnte nicht freigeschaltet werden") + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "verified": true, "pending": false, + "message": "Deine E-Mail-Adresse ist bestätigt. Du kannst dich jetzt anmelden.", + }) +} + +// handleResendVerification: POST /auth/resend-verification {"email"} – always the same answer, the +// mail goes out in the background (at most every 2 minutes per account). +func (s *Server) handleResendVerification(w http.ResponseWriter, r *http.Request) { + if wait := s.loginGuard.throttle("forgot", clientIPFrom(r), limitForgotPerIP); wait > 0 { + writeThrottled(w, wait) + 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) + if s.emailLinksAvailable() { + go func() { + if user, err := s.store.GetUserByEmail(email); err == nil && user != nil && s.store.IsEmailUnverified(user.ID) { + s.sendVerificationMail(user) + } + }() + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + // appLink is the address of this instance for links in mails: BASE_URL, otherwise the address the // admin is using right now (only for mails an admin triggers, never for anonymous requests). func (s *Server) appLink(r *http.Request) string { @@ -137,6 +239,7 @@ func (s *Server) handleAdminApproveUser(w http.ResponseWriter, r *http.Request) return } user.IsActive = true + s.store.ClearEmailVerification(userID) // the admin vouches for the account link := s.appLink(r) go func() { _ = s.sendMailContent(user.Email, "MindSift: Dein Konto ist freigeschaltet", mailContent{ diff --git a/internal/api/registration_test.go b/internal/api/registration_test.go index e795cda..2746f31 100644 --- a/internal/api/registration_test.go +++ b/internal/api/registration_test.go @@ -3,8 +3,11 @@ package api import ( "encoding/json" "net/http" + "regexp" "strings" + "sync" "testing" + "time" "github.com/sandrodach/mindsift/internal/domain" "github.com/sandrodach/mindsift/internal/storage" @@ -155,3 +158,124 @@ func TestRegistrationFixedByEnvironment(t *testing.T) { t.Fatalf("the locked value must not be overwritten in the database") } } + +// verificationServer: mails can go out (SMTP + BASE_URL); returns the captured mails +func verificationServer(t *testing.T) (*Server, *storage.Store, string, func() []sentNote) { + t.Helper() + srv, store, adminToken := setupTestServer(t) + srv.cfg.BaseURL, srv.cfg.BaseURLSet = "https://mindsift.example", true + sys, _ := store.GetSystemSettings() + sys.SMTPHost, sys.RegistrationVerifyEmail = "smtp.example.com", true + if err := store.UpdateSystemSettings(sys); err != nil { + t.Fatal(err) + } + var mu sync.Mutex + var sent []sentNote + srv.notifyEmailHook = func(to, subject, text, html string) error { + mu.Lock() + defer mu.Unlock() + sent = append(sent, sentNote{channel: "email", to: to, subject: subject, text: text, html: html}) + return nil + } + mails := func() []sentNote { + mu.Lock() + defer mu.Unlock() + return append([]sentNote(nil), sent...) + } + return srv, store, adminToken, mails +} + +// waitMail waits for a mail to `to` (mails go out in the background) +func waitMail(t *testing.T, mails func() []sentNote, to string) sentNote { + t.Helper() + for i := 0; i < 100; i++ { + for _, m := range mails() { + if m.to == to { + return m + } + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("no mail to %s, got %+v", to, mails()) + return sentNote{} +} + +func TestEmailVerification(t *testing.T) { + srv, store, _, mails := verificationServer(t) + router := srv.Router() + setRegistration(t, store, storage.RegistrationOpen, "") + sys, _ := store.GetSystemSettings() + sys.RegistrationVerifyEmail = true + _ = store.UpdateSystemSettings(sys) + + code, out := register(srv, "neu@example.com", "") + if code != http.StatusAccepted || out["verify"] != true || out["token"] != nil { + t.Fatalf("register: want 202 with verify and no session, got %d %v", code, out) + } + mail := waitMail(t, mails, "neu@example.com") + m := regexp.MustCompile(`verifyToken=([0-9a-f]{64})`).FindStringSubmatch(mail.text) + if m == nil || !strings.HasPrefix(mail.text[strings.Index(mail.text, "https://"):], "https://mindsift.example/") { + t.Fatalf("mail must carry a link built from BASE_URL: %s", mail.text) + } + login := func() int { + return sessionCall(router, "POST", "/api/v1/auth/login", "", `{"login":"neu@example.com","password":"password123"}`, "").Code + } + if c := login(); c != http.StatusForbidden { + t.Fatalf("login before confirming: want 403, got %d", c) + } + if rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+strings.Repeat("0", 64)+`"}`, ""); rec.Code != http.StatusBadRequest { + t.Fatalf("wrong token: want 400, got %d", rec.Code) + } + rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+m[1]+`"}`, "") + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"pending":false`) { + t.Fatalf("verify: %d %s", rec.Code, rec.Body.String()) + } + if c := login(); c != http.StatusOK { + t.Fatalf("login after confirming: want 200, got %d", c) + } + if rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+m[1]+`"}`, ""); rec.Code != http.StatusBadRequest { + t.Fatalf("a link works only once, got %d", rec.Code) + } + + // Invites need no confirmation + inv, _ := store.CreateUserInvite("", "", "member", "", 7, 1) + if code, out := register(srv, "eingeladen@example.com", inv.Code); code != http.StatusCreated || out["token"] == nil { + t.Fatalf("invite: want 201 with session, got %d %v", code, out) + } +} + +func TestEmailVerificationBeforeApproval(t *testing.T) { + srv, store, _, mails := verificationServer(t) + router := srv.Router() + setRegistration(t, store, storage.RegistrationApproval, "") + sys, _ := store.GetSystemSettings() + sys.RegistrationVerifyEmail = true + _ = store.UpdateSystemSettings(sys) + admin, err := store.GetFirstUser() + if err != nil || admin == nil || admin.Role != "admin" { + t.Fatalf("test server needs its admin, got %+v %v", admin, err) + } + adminEmail := admin.Email + + if code, _ := register(srv, "wartet@example.com", ""); code != http.StatusAccepted { + t.Fatalf("register: want 202, got %d", code) + } + mail := waitMail(t, mails, "wartet@example.com") + time.Sleep(100 * time.Millisecond) + for _, m := range mails() { + if m.to != "wartet@example.com" { + t.Fatalf("admins must not hear about it before the address is confirmed, got mail to %s", m.to) + } + } + token := regexp.MustCompile(`verifyToken=([0-9a-f]{64})`).FindStringSubmatch(mail.text)[1] + rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+token+`"}`, "") + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"pending":true`) { + t.Fatalf("verify: %d %s", rec.Code, rec.Body.String()) + } + if m := waitMail(t, mails, adminEmail); !strings.Contains(m.subject, "Freigabe") { + t.Fatalf("admin mail after confirming expected, got %q", m.subject) + } + if c := sessionCall(router, "POST", "/api/v1/auth/login", "", `{"login":"wartet@example.com","password":"password123"}`, "").Code; c != http.StatusForbidden { + t.Fatalf("still waiting for approval: want 403, got %d", c) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index a6e0750..a5d609f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -122,6 +122,8 @@ func (s *Server) Router() http.Handler { r.Get("/api/v1/auth/status", s.handleAuthStatus) r.Get("/api/v1/legal/{type}", s.handleLegalText) r.Post("/api/v1/auth/setup", s.handleFirstBootSetup) + r.Post("/api/v1/auth/verify-email", s.handleVerifyEmail) + r.Post("/api/v1/auth/resend-verification", s.handleResendVerification) r.Post("/api/v1/auth/register", s.handleRegister) r.Post("/api/v1/auth/login", s.handleLogin) r.Post("/api/v1/auth/quickstart", s.handleQuickStart) diff --git a/internal/config/config.go b/internal/config/config.go index 63ce408..decfcd9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,9 +26,11 @@ type Config struct { // ALLOW_REGISTRATION=false (the template default) leaves the choice to the admin settings. RegistrationMode string RegistrationDomains string - LogLevel string - Debug bool - DemoMode bool + // RegistrationVerifyEmail: REGISTRATION_VERIFY_EMAIL ("true"/"false", "" = admin settings decide) + RegistrationVerifyEmail string + LogLevel string + Debug bool + DemoMode bool // DemoResetMinutes: in demo mode the database is reset to the sample data this often (0 = never) DemoResetMinutes int // TrustedProxies: whose X-Forwarded-For/X-Real-IP is believed ("private", "all", "none" or CIDRs) @@ -233,29 +235,30 @@ func Load() *Config { smtpSkipVerify := strings.ToLower(getEnv("SMTP_SKIP_VERIFY", "false")) == "true" return &Config{ - Port: port, - DataDir: dataDir, - DatabasePath: dbPath, - JWTSecret: jwtSecret, - BaseURL: baseURL, - BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "", - AllowRegistration: allowReg, - RegistrationMode: registrationMode(allowReg), - RegistrationDomains: strings.TrimSpace(os.Getenv("REGISTRATION_DOMAINS")), - LogLevel: logLevel, - Debug: debug, - DemoMode: demoMode, - DemoResetMinutes: demoResetMinutes(), - TrustedProxies: getEnv("TRUSTED_PROXIES", "private"), - DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false", - SMTPHost: smtpHost, - SMTPPort: smtpPort, - SMTPUser: smtpUser, - SMTPPass: smtpPass, - SMTPFrom: smtpFrom, - SMTPFromName: getEnv("SMTP_FROM_NAME", "MindSift"), - SMTPTLS: smtpTLS, - SMTPSkipVerify: smtpSkipVerify, + Port: port, + DataDir: dataDir, + DatabasePath: dbPath, + JWTSecret: jwtSecret, + BaseURL: baseURL, + BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "", + AllowRegistration: allowReg, + RegistrationMode: registrationMode(allowReg), + RegistrationDomains: strings.TrimSpace(os.Getenv("REGISTRATION_DOMAINS")), + RegistrationVerifyEmail: boolEnvOrEmpty("REGISTRATION_VERIFY_EMAIL"), + LogLevel: logLevel, + Debug: debug, + DemoMode: demoMode, + DemoResetMinutes: demoResetMinutes(), + TrustedProxies: getEnv("TRUSTED_PROXIES", "private"), + DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false", + SMTPHost: smtpHost, + SMTPPort: smtpPort, + SMTPUser: smtpUser, + SMTPPass: smtpPass, + SMTPFrom: smtpFrom, + SMTPFromName: getEnv("SMTP_FROM_NAME", "MindSift"), + SMTPTLS: smtpTLS, + SMTPSkipVerify: smtpSkipVerify, Speech: SpeechConfig{ URL: strings.TrimRight(getEnv("STT_URL", ""), "/"), Model: getEnv("STT_MODEL", "deepdml/faster-whisper-large-v3-turbo-ct2"), @@ -349,3 +352,14 @@ func registrationMode(allowReg bool) string { } return "" } + +// boolEnvOrEmpty: "true" or "false" when the variable is set to a boolean, "" when unset or unknown. +func boolEnvOrEmpty(key string) string { + switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { + case "true", "1", "yes", "on": + return "true" + case "false", "0", "no", "off": + return "false" + } + return "" +} diff --git a/internal/domain/models.go b/internal/domain/models.go index 88fc5a7..63a6ab3 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -137,6 +137,8 @@ type User struct { CreatedAt string `json:"createdAt"` // Registered while registration needs approval and not approved yet (admin user list only) PendingApproval bool `json:"pendingApproval,omitempty"` + // Registered and the e-mail address is not confirmed yet (admin user list only) + EmailUnverified bool `json:"emailUnverified,omitempty"` // Only filled for the user's own profile (GET /auth/me, PATCH /auth/settings) NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"` } @@ -363,11 +365,17 @@ type SystemSettings struct { // E-mail domains for the domain modes, comma separated ("firma.de, familie.de") RegistrationDomains string `json:"registrationDomains"` // Set by REGISTRATION_MODE / REGISTRATION_DOMAINS (or ALLOW_REGISTRATION=true): not editable here - RegistrationConfiguredViaEnv bool `json:"registrationConfiguredViaEnv,omitempty"` - InstanceName string `json:"instanceName"` - SMTPHost string `json:"smtpHost"` - SMTPPort string `json:"smtpPort"` - SMTPUser string `json:"smtpUser"` + RegistrationConfiguredViaEnv bool `json:"registrationConfiguredViaEnv,omitempty"` + // Self-registrations confirm their e-mail address by link first (needs SMTP and BASE_URL) + RegistrationVerifyEmail bool `json:"registrationVerifyEmail"` + // Set by REGISTRATION_VERIFY_EMAIL: not editable here + RegistrationVerifyViaEnv bool `json:"registrationVerifyConfiguredViaEnv,omitempty"` + // Mails with links to unknown people can be sent (SMTP and BASE_URL configured) + EmailLinksAvailable bool `json:"emailLinksAvailable,omitempty"` + InstanceName string `json:"instanceName"` + SMTPHost string `json:"smtpHost"` + SMTPPort string `json:"smtpPort"` + SMTPUser string `json:"smtpUser"` // SMTPPass is never sent to the browser (FLAW-34): reads leave it empty and set SMTPPassSet; // writing it empty keeps the stored password SMTPPass string `json:"smtpPass"` diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index 26c0b70..a1404bc 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -31,6 +31,7 @@ var migrations = []migration{ {8, "Journal", (*Store).migrateJournal}, {9, "Web-Push", (*Store).migratePush}, {10, "Registrierung und Einladungslinks", (*Store).migrateRegistration}, + {11, "E-Mail-Bestätigung", (*Store).migrateEmailVerification}, } func (s *Store) migrate() error { diff --git a/internal/storage/registration.go b/internal/storage/registration.go index 1874f66..b1bbd00 100644 --- a/internal/storage/registration.go +++ b/internal/storage/registration.go @@ -1,6 +1,9 @@ package storage import ( + "crypto/rand" + "encoding/hex" + "errors" "strings" "time" ) @@ -125,3 +128,124 @@ func (s *Store) ApprovePendingRegistration(userID string) (bool, error) { } return true, nil } + +// --- E-mail verification --- +// Accounts registered without invite while verification is on stay locked until the link in the +// mail is opened. Only the SHA-256 of the token is stored (like password reset links); unconfirmed +// accounts are deleted after EmailVerificationTTL so the address is free again. + +const ( + EmailVerificationTTL = 48 * time.Hour + emailVerificationCooldown = 2 * time.Minute // no second mail for the same account within this time +) + +var ErrVerificationInvalid = errors.New("Der Bestätigungslink ist ungültig oder abgelaufen. Bitte registriere dich erneut.") + +func (s *Store) migrateEmailVerification() error { + return s.execInTx( + stmt(`CREATE TABLE IF NOT EXISTS email_verifications ( + user_id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL + )`), + stmt(`CREATE INDEX IF NOT EXISTS idx_email_verifications_token ON email_verifications(token_hash)`), + ) +} + +// CreateEmailVerification returns a new raw token for the account (the previous one stops working), +// or ErrResetTooSoon while the last mail is younger than the cooldown. The expiry of the account stays +// counted from the registration. +func (s *Store) CreateEmailVerification(userID string) (string, error) { + now := time.Now().UTC() + var created, expires string + err := s.db.QueryRow(`SELECT created_at, expires_at FROM email_verifications WHERE user_id = ?`, userID).Scan(&created, &expires) + if err == nil { + if t, perr := time.Parse(time.RFC3339, created); perr == nil && now.Sub(t) < emailVerificationCooldown { + return "", ErrResetTooSoon + } + } else { + expires = now.Add(EmailVerificationTTL).Format(time.RFC3339) + } + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + raw := hex.EncodeToString(buf) + _, err = s.db.Exec(`INSERT INTO email_verifications (user_id, token_hash, created_at, expires_at) VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET token_hash = excluded.token_hash, created_at = excluded.created_at`, + userID, hashResetToken(raw), now.Format(time.RFC3339), expires) + return raw, err +} + +// ConfirmEmailVerification checks the token, ends the verification and returns the account. +func (s *Store) ConfirmEmailVerification(raw string) (string, error) { + var userID, expires string + if err := s.db.QueryRow(`SELECT user_id, expires_at FROM email_verifications WHERE token_hash = ?`, hashResetToken(raw)). + Scan(&userID, &expires); err != nil { + return "", ErrVerificationInvalid + } + if exp, err := time.Parse(time.RFC3339, expires); err != nil || time.Now().UTC().After(exp) { + return "", ErrVerificationInvalid + } + res, err := s.db.Exec(`DELETE FROM email_verifications WHERE user_id = ? AND token_hash = ?`, userID, hashResetToken(raw)) + if err != nil { + return "", err + } + if n, _ := res.RowsAffected(); n == 0 { + return "", ErrVerificationInvalid // used in parallel + } + return userID, nil +} + +func (s *Store) IsEmailUnverified(userID string) bool { + var n int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM email_verifications WHERE user_id = ?`, userID).Scan(&n) + return n > 0 +} + +func (s *Store) ClearEmailVerification(userID string) { + _, _ = s.db.Exec(`DELETE FROM email_verifications WHERE user_id = ?`, userID) +} + +// UnverifiedEmailIDs: accounts whose address is not confirmed yet. +func (s *Store) UnverifiedEmailIDs() map[string]bool { + out := map[string]bool{} + rows, err := s.db.Query(`SELECT user_id FROM email_verifications`) + if err != nil { + return out + } + defer rows.Close() + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + out[id] = true + } + } + return out +} + +// PurgeUnverifiedAccounts deletes accounts whose confirmation link expired unused; returns how many. +func (s *Store) PurgeUnverifiedAccounts(now time.Time) int { + rows, err := s.db.Query(`SELECT user_id FROM email_verifications WHERE expires_at < ?`, now.UTC().Format(time.RFC3339)) + if err != nil { + return 0 + } + var ids []string + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + ids = append(ids, id) + } + } + _ = rows.Close() + n := 0 + for _, id := range ids { + if err := s.DeleteUser(id); err == nil { + n++ + } + s.ClearEmailVerification(id) + s.ClearPendingRegistration(id) + } + return n +} diff --git a/internal/storage/registration_test.go b/internal/storage/registration_test.go new file mode 100644 index 0000000..5dcb38d --- /dev/null +++ b/internal/storage/registration_test.go @@ -0,0 +1,36 @@ +package storage + +import ( + "path/filepath" + "testing" + "time" +) + +// Unconfirmed accounts disappear once their link expired, so the address is free again +func TestUnverifiedAccountsArePurged(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "verify.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + u, _ := store.CreateUser("neu@example.com", "hash", "Neu", "member") + keep, _ := store.CreateUser("bleibt@example.com", "hash", "Bleibt", "member") + if _, err := store.CreateEmailVerification(u.ID); err != nil { + t.Fatal(err) + } + if _, err := store.CreateEmailVerification(u.ID); err != ErrResetTooSoon { + t.Fatalf("second mail within the cooldown must be refused, got %v", err) + } + if n := store.PurgeUnverifiedAccounts(time.Now()); n != 0 { + t.Fatalf("nothing expired yet, purged %d", n) + } + if n := store.PurgeUnverifiedAccounts(time.Now().Add(EmailVerificationTTL + time.Hour)); n != 1 { + t.Fatalf("want 1 purged, got %d", n) + } + if got, _ := store.GetUserByID(u.ID); got != nil { + t.Fatal("unconfirmed account must be gone") + } + if got, _ := store.GetUserByID(keep.ID); got == nil { + t.Fatal("other accounts stay") + } +} diff --git a/internal/storage/system.go b/internal/storage/system.go index 8399667..8d8292d 100644 --- a/internal/storage/system.go +++ b/internal/storage/system.go @@ -142,6 +142,8 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) { settings.RegistrationMode = NormalizeRegistrationMode(v) case "registration_domains": settings.RegistrationDomains = v + case "registration_verify_email": + settings.RegistrationVerifyEmail = v == "true" case "instance_name": settings.InstanceName = v case "smtp_host": @@ -245,7 +247,8 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error { ('backup_time', ?), ('backup_retention_days', ?), ('registration_mode', ?), - ('registration_domains', ?) + ('registration_domains', ?), + ('registration_verify_email', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, settings.SharingMode, regStr, @@ -264,6 +267,7 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error { strconv.Itoa(settings.BackupRetentionDays), settings.RegistrationMode, settings.RegistrationDomains, + strconv.FormatBool(settings.RegistrationVerifyEmail), ) return err }