feat(auth): confirm the e-mail address of new accounts by link
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m21s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m21s
- Setting / REGISTRATION_VERIFY_EMAIL; only with SMTP and BASE_URL; invites skip it - Hashed one-time tokens (48 h), resend with cooldown, unconfirmed accounts purged - Approval modes notify admins only after the address is confirmed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1406650b7c
commit
ab97638361
25 files changed
+621
-56
No files matched your search
@@ -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
|
||||
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
+3
-1
@@ -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 |
|
||||
|
||||
@@ -64,6 +64,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
||||
| `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`) |
|
||||
| `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`) |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](#)
|
||||
[](#-schnellstart)
|
||||
[](#-deine-daten-gehören-dir)
|
||||
[](CHANGELOG.md)
|
||||
[](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) |
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.23.0",
|
||||
"version": "0.24.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -52,6 +52,34 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
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<SetupAndAuthViewProps> = ({
|
||||
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<SetupAndAuthViewProps> = ({
|
||||
}
|
||||
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<string | null>(null);
|
||||
@@ -512,7 +546,17 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
{error && (
|
||||
<div className="mb-4 alert-err flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
<span className="flex-1">{error}</span>
|
||||
{/bestätige zuerst deine E-Mail/i.test(error) && email.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void resendVerification()}
|
||||
disabled={resendState !== 'idle'}
|
||||
className="btn-action h-8 px-2.5 text-[11px] shrink-0 disabled:opacity-50"
|
||||
>
|
||||
{resendState === 'sending' ? 'Sende…' : 'Link erneut senden'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{notice && !error && (
|
||||
@@ -718,8 +762,8 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
|
||||
{(authTab === 'invite' || authTab === 'register') && (
|
||||
<form onSubmit={handleRegisterOrInvite} className="space-y-3.5">
|
||||
{authTab === 'register' && registrationHint && (
|
||||
<p className="text-[11px] text-muted bg-canvas border border-border rounded-btn px-2.5 py-2">{registrationHint}</p>
|
||||
{authTab === 'register' && registrationHintFull && (
|
||||
<p className="text-[11px] text-muted bg-canvas border border-border rounded-btn px-2.5 py-2">{registrationHintFull}</p>
|
||||
)}
|
||||
{authTab === 'invite' && (
|
||||
<div>
|
||||
|
||||
@@ -141,6 +141,9 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
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<AdminSettingsViewProps> = ({
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<UserAvatar user={u} size="sm" className="border border-border/80" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-main truncate">{u.displayName}</p>
|
||||
<p className="font-bold text-main truncate flex items-center gap-2">
|
||||
{u.displayName}
|
||||
{u.emailUnverified && <span className="badge-neutral text-[10px] font-normal">E-Mail unbestätigt</span>}
|
||||
</p>
|
||||
<p className="text-muted text-[11px] truncate">
|
||||
{u.email} · registriert am {new Date(u.createdAt).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
@@ -947,8 +953,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-main">{u.displayName}</span>
|
||||
{!u.isActive && (
|
||||
<span className="badge-err text-[10px]">
|
||||
Gesperrt
|
||||
<span className={`${u.emailUnverified ? 'badge-neutral' : 'badge-err'} text-[10px]`}>
|
||||
{u.emailUnverified ? 'E-Mail unbestätigt' : 'Gesperrt'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1355,6 +1361,33 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
<code className="font-mono">REGISTRATION_DOMAINS</code>) und hier nicht änderbar.
|
||||
</p>
|
||||
)}
|
||||
{mode !== 'closed' && (
|
||||
<div className="pt-3 border-t border-border flex items-start justify-between gap-4">
|
||||
<div className="pr-2">
|
||||
<p className="font-semibold text-main">E-Mail-Adresse bestätigen lassen</p>
|
||||
<p className="text-[11px] text-muted mt-0.5">
|
||||
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.
|
||||
</p>
|
||||
{!settings.emailLinksAvailable && (
|
||||
<p className="text-[11px] text-status-warn mt-1">
|
||||
Wirkt erst mit eingerichtetem Mail-Server und gesetzter <code className="font-mono">BASE_URL</code>.
|
||||
</p>
|
||||
)}
|
||||
{settings.registrationVerifyConfiguredViaEnv && (
|
||||
<p className="text-[11px] text-status-warn mt-1">
|
||||
Über <code className="font-mono">REGISTRATION_VERIFY_EMAIL</code> festgelegt.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={Boolean(settings.registrationVerifyEmail)}
|
||||
disabled={Boolean(settings.registrationVerifyConfiguredViaEnv)}
|
||||
onChange={(registrationVerifyEmail) => handleSaveSystemSettings({ registrationVerifyEmail })}
|
||||
title="E-Mail-Adresse bei der Registrierung bestätigen lassen"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -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<void> {
|
||||
await request('/api/v1/auth/resend-verification', { method: 'POST', body: JSON.stringify({ email }) });
|
||||
},
|
||||
|
||||
async adminSendInvite(id: string, email: string): Promise<UserInvite> {
|
||||
return request<UserInvite>(`/api/v1/admin/invites/${id}/send`, { method: 'POST', body: JSON.stringify({ email }) });
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+40
-26
@@ -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 ""
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user