fix(auth): longer IDs and invite codes, register limits, single-use invites (FLAW-32, FLAW-39, FLAW-40)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s

- NewID uses 96 random bits; invite codes get their own 100 bits in Base32.
- Registration and forgot-password are limited per address via loginGuard.
- The invite is claimed atomically before the account is created and
  released again when the account cannot be created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 19:28:09 +01:00
1 parent 1af3a6e90a
commit 3709282a4f
11 files changed
+220 -15

No files matched your search

+3
View File
@@ -11,6 +11,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
- Sicherheits-Header für alle Antworten: Content-Security-Policy (Scripts nur von MindSift selbst, keine Einbettung in fremde Seiten), `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy` (FLAW-29).
- Passwort und Login-E-Mail lassen sich nur noch mit dem aktuellen Passwort ändern; die bisherige Adresse bekommt eine Hinweismail, wenn SMTP eingerichtet ist (FLAW-30).
- Anmeldungen sind serverseitige Sitzungen: Jedes Gerät lässt sich einzeln abmelden, unbenutzte Sitzungen enden nach 30 Tagen, aktive werden täglich erneuert. Ein neues Passwort meldet alle anderen Geräte ab (FLAW-31).
- IDs neuer Datensätze haben 96 statt 40 Zufallsbits, Einladungscodes eigene 100 Bit (`INV-ABCDE-FGHIJ-KLMNO-PQRST`); vorhandene IDs und Codes bleiben gültig (FLAW-32).
- Registrierung und „Passwort vergessen“ sind pro Adresse begrenzt (20 bzw. 10 Anfragen in 15 Minuten), Einladungscodes lassen sich so nicht durchprobieren (FLAW-39).
- Ein Einladungscode lässt sich auch bei gleichzeitigen Registrierungen nur einmal einlösen (FLAW-40).
### Fixed
- Der Offline-Start der App funktioniert zuverlässig: Ein altes Script in der Startseite hat bei jedem Aufruf alle Caches des Service Workers gelöscht.
+2 -2
View File
@@ -10,9 +10,9 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`) |
| GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist |
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben |
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben; `429` nach 20 Versuchen pro Adresse in 15 Minuten |
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
| POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen |
| POST | `/auth/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 |
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=`. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste |
+3 -2
View File
@@ -60,7 +60,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) |
| `JWT_SECRET` | zufällig erzeugt, gespeichert in `$DATA_DIR/jwt.secret` | Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert |
| `TRUSTED_PROXIES` | `private` | Proxys, deren `X-Forwarded-For`/`X-Real-IP` geglaubt wird (`private`, `all`, `none`, CIDR-Liste); `internal/api/clientip.go` |
| `LOGIN_PROTECTION` | `true` | Login-Sperre nach Fehlversuchen (`internal/api/loginguard.go`), `false` = aus |
| `LOGIN_PROTECTION` | `true` | Login-Sperre nach Fehlversuchen und Limits für Registrierung und „Passwort vergessen“ (`internal/api/loginguard.go`), `false` = aus |
| `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`) |
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
@@ -77,7 +77,8 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
## Authentifizierung
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
- **Login-Sperre:** Fehlversuche zählen im Speicher in drei Töpfen: Konto + Adresse (3), Konto (20), Adresse (30), je innerhalb von 15 Minuten; ist einer voll, gibt es 15 Minuten lang `429` mit `Retry-After`. Unbekannte Namen zählen wie Konten (kein Erraten, welche existieren). Erfolg leert die Kontotöpfe, ein Passwort-Reset (Mail oder Admin) hebt die Sperre auf. Die Client-Adresse kommt nur von vertrauenswürdigen Proxys (`TRUSTED_PROXIES`).
- **Login-Sperre:** Fehlversuche zählen im Speicher in drei Töpfen: Konto + Adresse (3), Konto (20), Adresse (30), je innerhalb von 15 Minuten; ist einer voll, gibt es 15 Minuten lang `429` mit `Retry-After`. Unbekannte Namen zählen wie Konten (kein Erraten, welche existieren). Erfolg leert die Kontotöpfe, ein Passwort-Reset (Mail oder Admin) hebt die Sperre auf. Die Client-Adresse kommt nur von vertrauenswürdigen Proxys (`TRUSTED_PROXIES`). Dieselben Töpfe begrenzen pro Adresse Registrierungen (20) und „Passwort vergessen“ (10) je 15 Minuten; hier zählt jede Anfrage.
- **Einladungscodes:** `INV-` plus 100 Zufallsbits in Base32. Die Registrierung beansprucht den Code, bevor das Konto entsteht (kein doppeltes Einlösen).
- **JWT und Sitzungen:** HS256, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Jede Anmeldung legt eine Zeile in `sessions` an (Gerät, IP, letzte Nutzung); `jti` ist deren ID. Gültig ist ein Token nur, wenn die Sitzung aktiv ist (nicht beendet, höchstens 30 Tage unbenutzt), das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Ist das Token älter als 24 Stunden, schickt der Server im Header `X-Session-Token` ein neues für dieselbe Sitzung; das Frontend übernimmt es (`request()` in `lib/api.ts`). Aktive Geräte bleiben so angemeldet, unbenutzte fallen nach 30 Tagen heraus (`internal/api/sessions.go`). Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
- **Logout** beendet die Sitzung des Geräts; `GET /auth/sessions` listet die Geräte, `DELETE /auth/sessions/{id}` meldet eines ab. Passwort und Login-E-Mail ändern sich nur mit dem aktuellen Passwort (`currentPassword`, falsche Eingaben zählen für die Login-Sperre); ein neues Passwort meldet alle anderen Geräte ab, dieses Gerät erhält per `X-Session-Token` ein neues Token. Ist SMTP eingerichtet, geht eine Hinweismail an die bisherige Adresse.
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und darf nur Daten anliefern (`POST /inbox`, `POST /webhooks/ingest`); alle anderen Endpunkte antworten mit `403`.
+1 -1
View File
@@ -1,6 +1,6 @@
# Datenbank
SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`).
SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`) und 96 Zufallsbits in Hex; ältere Datensätze haben kürzere IDs (40 Bit).
## Tabellen
+6 -3
View File
@@ -14,15 +14,15 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
| FLAW-29 | Netzwerk | ~~Keine Sicherheits-Header (CSP, Clickjacking-Schutz, `nosniff`)~~ | **Behoben** |
| FLAW-30 | Konto | ~~Passwort und E-Mail lassen sich ohne das aktuelle Passwort ändern~~ | **Behoben** |
| FLAW-31 | Auth | ~~Sitzungs-Token 30 Tage gültig und im `localStorage`~~ | **Teilweise behoben** |
| FLAW-32 | Daten | IDs und Einladungscodes haben nur 40 Bit Zufall | Mittel |
| FLAW-32 | Daten | ~~IDs und Einladungscodes haben nur 40 Bit Zufall~~ | **Behoben** |
| FLAW-33 | Demo | Öffentliche Demo als Absender missbrauchbar (Alarm-Webhooks, Testmails, Server-Einstellungen) | Mittel |
| FLAW-34 | Admin | SMTP-Passwort im Klartext an die Oberfläche, in DB und Datenbank-Backups | Niedrig |
| FLAW-35 | Netzwerk | Webhook-Ingest nimmt Sitzungs-Token und Keys auch als `?token=` | Niedrig |
| FLAW-36 | Backend | Kein allgemeines Größenlimit für JSON-Anfragen | Niedrig |
| FLAW-37 | Datenschutz | Konto löschen entfernt die Server-Backups des Kontos nicht | Niedrig |
| FLAW-38 | Betrieb | `WriteTimeout` von 30 s bricht große Downloads ab | Niedrig |
| FLAW-39 | Auth | Kein Rate-Limit für Registrierung, Einladungscodes und „Passwort vergessen“ je Adresse | Niedrig |
| FLAW-40 | Auth | Einladungscode bei gleichzeitiger Nutzung zweimal einlösbar | Niedrig |
| FLAW-39 | Auth | ~~Kein Rate-Limit für Registrierung, Einladungscodes und „Passwort vergessen“ je Adresse~~ | **Behoben** |
| FLAW-40 | Auth | ~~Einladungscode bei gleichzeitiger Nutzung zweimal einlösbar~~ | **Behoben** |
| FLAW-41 | Qualität | Sehr große Frontend-Dateien | Niedrig |
---
@@ -57,6 +57,7 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
- **Fundstelle:** `NewID` in [domain/models.go](../internal/domain/models.go) (5 Zufallsbytes); Einladungscodes `INV-…` in `CreateUserInvite` ([users.go](../internal/storage/users.go)) leiten sich davon ab.
- **Problem:** Bei 40 Bit liegt die Wahrscheinlichkeit einer Kollision ab etwa einer Million Datensätze einer Tabelle bei rund 40 % (Geburtstagsproblem); eine Kollision lässt das Anlegen fehlschlagen oder überschreibt bei Upserts. Einladungscodes sind ohne Rate-Limit (FLAW-39) online rate-, aber nicht praktisch erratbar; 40 Bit sind für ein Geheimnis trotzdem zu wenig.
- **Maßnahme:** `NewID` auf 12 Bytes (96 Bit) anheben (Spalten sind `TEXT`, alte IDs bleiben gültig); Einladungscodes mit eigenen 16 Zufallsbytes.
- **Status: Behoben.** `NewID` nutzt 12 Zufallsbytes, alte IDs bleiben gültig. Einladungscodes haben eigene 100 Bit in Base32 (`INV-ABCDE-FGHIJ-KLMNO-PQRST`, `newInviteCode`); bereits ausgegebene Codes funktionieren weiter.
### FLAW-33: Öffentliche Demo als Absender missbrauchbar
- **Fundstelle:** Admin-Routen in [server.go](../internal/api/server.go); Demo-Konto `admin/admin` ([users.go](../internal/storage/users.go)).
@@ -93,11 +94,13 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
- **Fundstelle:** `handleRegister`, `handleForgotPassword` (Sperre nur je Konto), Einladungscode-Prüfung.
- **Problem:** Massenhaftes Anlegen von Konten (bei offener Registrierung), Durchprobieren von Einladungscodes und Mail-Flut über „Passwort vergessen“ an viele Adressen sind nicht gebremst.
- **Maßnahme:** Den vorhandenen `loginGuard` um Töpfe pro Adresse für diese Endpunkte erweitern.
- **Status: Behoben.** `loginGuard.throttle` zählt jede Anfrage pro Adresse: Registrierung (und damit Einladungscode-Versuche) 20, „Passwort vergessen“ 10 je 15 Minuten, danach `429` mit `Retry-After`. Gilt nicht für Anfragen ohne bekannte Client-Adresse; `LOGIN_PROTECTION=false` schaltet es mit ab. Tests: `TestRegisterThrottledPerAddress`, `TestThrottleWindowAndSwitch`.
### FLAW-40: Einladungscode doppelt einlösbar
- **Fundstelle:** `handleRegister`: `GetUserInviteByCode` → Konto anlegen → `MarkUserInviteUsed`.
- **Problem:** Zwei gleichzeitige Registrierungen mit demselben Code kommen beide durch.
- **Maßnahme:** Code in einer Transaktion bzw. per `UPDATE … WHERE used_by = ''` beanspruchen, bevor das Konto entsteht.
- **Status: Behoben.** `ClaimUserInvite` beansprucht den Code per `UPDATE … WHERE used_by = ''` vor dem Anlegen des Kontos; scheitert das Konto (Adresse vergeben), gibt `ReleaseUserInvite` ihn wieder frei. Tests: `TestInviteCodeRedeemedOnlyOnce`, `TestInviteReleasedWhenAccountFails`.
### FLAW-41: Sehr große Frontend-Dateien
- **Fundstelle:** `WorkspaceView.tsx` (≈ 2.300 Zeilen), `TasksView.tsx` (≈ 2.500), `lib/api.ts` (≈ 1.700).
+17 -2
View File
@@ -168,6 +168,10 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
if wait := s.loginGuard.throttle("register", clientIPFrom(r), limitRegisterPerIP); wait > 0 {
writeThrottled(w, wait)
return
}
if strings.TrimSpace(req.Email) == "" {
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
return
@@ -212,15 +216,26 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
// The invite is claimed before the account exists, so it cannot be redeemed twice at once
var claim string
if validInvite != nil {
if claim, err = s.store.ClaimUserInvite(validInvite.ID); err != nil {
writeError(w, http.StatusForbidden, err.Error())
return
}
}
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, role)
if err != nil {
if validInvite != nil {
s.store.ReleaseUserInvite(validInvite.ID, claim)
}
writeError(w, http.StatusConflict, "E-Mail-Adresse existiert bereits")
return
}
if validInvite != nil {
// Otherwise the code could be used again
if err := s.store.MarkUserInviteUsed(validInvite.ID, user.ID); err != nil {
if err := s.store.FinishUserInvite(validInvite.ID, claim, user.ID); err != nil {
slog.Error("Einladungscode konnte nicht als benutzt markiert werden", "invite", validInvite.ID, "error", err)
}
if validInvite.OrganizationID != "" {
+40
View File
@@ -135,6 +135,46 @@ func (g *loginGuard) cleanupLocked(now time.Time) {
}
}
// Other endpoints that can be abused in bulk are limited per address in the same buckets (FLAW-39).
// Every request counts here, not only failures: registration (also guessing invite codes) and
// "forgot password" (mail flood to many addresses).
const (
limitRegisterPerIP = 20
limitForgotPerIP = 10
)
// throttle counts one request of this kind from the visitor's address and returns how long further
// requests are refused (0 = this one may go ahead). Unknown addresses are not limited.
func (g *loginGuard) throttle(kind string, ip clientIPInfo, limit int) time.Duration {
if !g.enabled || ip.viaProxyWithoutHeader || ip.ip == "" {
return 0
}
g.mu.Lock()
defer g.mu.Unlock()
now := g.now()
g.cleanupLocked(now)
key := kind + ":" + ip.ip
b := g.buckets[key]
if b != nil && b.lockedUntil.After(now) {
return b.lockedUntil.Sub(now)
}
if b == nil || now.Sub(b.windowStart) > loginLockWindow {
b = &loginBucket{windowStart: now}
g.buckets[key] = b
}
b.count++
if b.count >= limit {
b.lockedUntil = now.Add(loginLockWindow)
}
return 0
}
func writeThrottled(w http.ResponseWriter, wait time.Duration) {
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
writeError(w, http.StatusTooManyRequests,
fmt.Sprintf("Zu viele Anfragen von dieser Adresse. Bitte versuche es in %d Minuten erneut.", int(math.Ceil(wait.Minutes()))))
}
func writeLoginLocked(w http.ResponseWriter, wait time.Duration) {
minutes := int(math.Ceil(wait.Minutes()))
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
+4
View File
@@ -29,6 +29,10 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusServiceUnavailable, "Passwort-Zurücksetzen ist auf diesem Server nicht eingerichtet. Bitte wende dich an die Administration.")
return
}
if wait := s.loginGuard.throttle("forgot", clientIPFrom(r), limitForgotPerIP); wait > 0 {
writeThrottled(w, wait)
return
}
var req struct {
Email string `json:"email"`
}
+106
View File
@@ -0,0 +1,106 @@
package api
import (
"net/http"
"regexp"
"strings"
"sync"
"testing"
"time"
)
func TestInviteCodeRedeemedOnlyOnce(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
inv, err := store.CreateUserInvite("", "", "member", "", 7)
if err != nil {
t.Fatal(err)
}
if !regexp.MustCompile(`^INV-[A-Z2-7]{5}(-[A-Z2-7]{5}){3}$`).MatchString(inv.Code) {
t.Fatalf("unexpected invite code format %q", inv.Code)
}
// Two registrations race for the same code: exactly one account
var wg sync.WaitGroup
codes := make([]int, 2)
for i := range codes {
wg.Add(1)
go func(i int) {
defer wg.Done()
body := `{"email":"race` + string(rune('a'+i)) + `@example.com","password":"password123","inviteCode":"` + strings.ToLower(inv.Code) + `"}`
codes[i] = sessionCall(router, "POST", "/api/v1/auth/register", "", body, "").Code
}(i)
}
wg.Wait()
created := 0
for _, c := range codes {
if c == http.StatusCreated {
created++
}
}
if created != 1 {
t.Fatalf("want exactly one account, got codes %v", codes)
}
}
func TestInviteReleasedWhenAccountFails(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
inv, _ := store.CreateUserInvite("", "", "member", "", 7)
_, _ = store.CreateUser("taken@example.com", "hash", "Taken", "member")
body := func(email string) string {
return `{"email":"` + email + `","password":"password123","inviteCode":"` + inv.Code + `"}`
}
if rec := sessionCall(router, "POST", "/api/v1/auth/register", "", body("taken@example.com"), ""); rec.Code != http.StatusConflict {
t.Fatalf("existing address: want 409, got %d", rec.Code)
}
if rec := sessionCall(router, "POST", "/api/v1/auth/register", "", body("free@example.com"), ""); rec.Code != http.StatusCreated {
t.Fatalf("code must still work after a failed attempt, got %d %s", rec.Code, rec.Body.String())
}
}
func TestRegisterThrottledPerAddress(t *testing.T) {
srv, _, _ := setupTestServer(t)
router := srv.Router()
for i := 0; i < limitRegisterPerIP; i++ {
rec := sessionCall(router, "POST", "/api/v1/auth/register", "", `{"email":"x@example.com","password":"password123","inviteCode":"INV-NOPE"}`, "")
if rec.Code != http.StatusForbidden {
t.Fatalf("attempt %d: want 403, got %d", i+1, rec.Code)
}
}
rec := sessionCall(router, "POST", "/api/v1/auth/register", "", `{"email":"x@example.com","password":"password123","inviteCode":"INV-NOPE"}`, "")
if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") == "" {
t.Fatalf("want 429 after %d attempts, got %d", limitRegisterPerIP, rec.Code)
}
}
func TestThrottleWindowAndSwitch(t *testing.T) {
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
g := newLoginGuard(true)
g.now = func() time.Time { return now }
ip := clientIPInfo{ip: "203.0.113.9"}
for i := 0; i < 3; i++ {
if g.throttle("forgot", ip, 3) != 0 {
t.Fatalf("request %d must pass", i+1)
}
}
if g.throttle("forgot", ip, 3) == 0 {
t.Fatal("fourth request must be refused")
}
if g.throttle("register", ip, 3) != 0 {
t.Fatal("other kinds have their own bucket")
}
if g.throttle("forgot", clientIPInfo{ip: "198.51.100.1"}, 3) != 0 {
t.Fatal("other addresses are not affected")
}
if g.throttle("forgot", clientIPInfo{ip: "172.18.0.2", viaProxyWithoutHeader: true}, 0) != 0 {
t.Fatal("unknown visitors are not limited")
}
now = now.Add(loginLockWindow + time.Minute)
if g.throttle("forgot", ip, 3) != 0 {
t.Fatal("lock must end after the window")
}
if off := newLoginGuard(false); off.throttle("forgot", ip, 0) != 0 {
t.Fatal("LOGIN_PROTECTION=false switches throttling off")
}
}
+3 -2
View File
@@ -10,9 +10,10 @@ import (
"time"
)
// NewID generates a readable prefixed ID as specified in BRAIN.md (e.g. th_4x8z19a0b1)
// NewID generates a readable prefixed ID (e.g. th_4f1c09e2b7a35d68c0e1f2a3). 96 random bits keep
// collisions out of reach; older IDs with 40 bits stay valid (FLAW-32).
func NewID(prefix string) string {
b := make([]byte, 5)
b := make([]byte, 12)
_, _ = rand.Read(b)
return fmt.Sprintf("%s_%s", prefix, hex.EncodeToString(b))
}
+35 -3
View File
@@ -1,6 +1,8 @@
package storage
import (
"crypto/rand"
"encoding/base32"
"errors"
"fmt"
"strings"
@@ -332,7 +334,7 @@ func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays
if validDays <= 0 || validDays > 365 {
validDays = 7
}
code := "INV-" + strings.ToUpper(domain.NewID("c")[2:])
code := newInviteCode()
now := time.Now().UTC()
inv := &domain.UserInvite{
ID: domain.NewID("inv"),
@@ -377,6 +379,15 @@ func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
return invites, nil
}
// newInviteCode returns INV- plus 100 random bits in Base32, grouped for typing:
// INV-ABCDE-FGHIJ-KLMNO-PQRST (FLAW-32).
func newInviteCode() string {
b := make([]byte, 13)
_, _ = rand.Read(b)
raw := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)[:20]
return "INV-" + raw[0:5] + "-" + raw[5:10] + "-" + raw[10:15] + "-" + raw[15:20]
}
func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) {
code = strings.ToUpper(strings.TrimSpace(code))
var inv domain.UserInvite
@@ -397,11 +408,32 @@ func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) {
return &inv, nil
}
func (s *Store) MarkUserInviteUsed(inviteID, usedByUserID string) error {
_, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ?`, usedByUserID, inviteID)
// ClaimUserInvite reserves an unused invite before the account is created, so two registrations with
// the same code cannot both succeed (FLAW-40). It returns the claim marker for FinishUserInvite or
// ReleaseUserInvite.
func (s *Store) ClaimUserInvite(inviteID string) (string, error) {
claim := domain.NewID("claim")
res, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ? AND used_by = ''`, claim, inviteID)
if err != nil {
return "", err
}
if n, _ := res.RowsAffected(); n != 1 {
return "", errors.New("Dieser Einladungscode wurde bereits eingelöst")
}
return claim, nil
}
// FinishUserInvite records the new account on a claimed invite.
func (s *Store) FinishUserInvite(inviteID, claim, userID string) error {
_, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ? AND used_by = ?`, userID, inviteID, claim)
return err
}
// ReleaseUserInvite frees a claimed invite again when the account could not be created.
func (s *Store) ReleaseUserInvite(inviteID, claim string) {
_, _ = s.db.Exec(`UPDATE user_invites SET used_by = '' WHERE id = ? AND used_by = ?`, inviteID, claim)
}
func (s *Store) DeleteUserInvite(inviteID string) error {
_, err := s.db.Exec(`DELETE FROM user_invites WHERE id = ?`, inviteID)
return err