diff --git a/Doku/architecture.md b/Doku/architecture.md index 29ce533..19160f7 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -59,6 +59,8 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `DATA_DIR` | `./data` (Container: `/data`) | Verzeichnis der Datenbank | | `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) | | `JWT_SECRET` | zufällig erzeugt, gespeichert in `$DATA_DIR/jwt.secret` | Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert ([FLAW-04](flaws.md)) | +| `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 | | `BASE_URL` | `http://localhost:` | Basis-URL der Instanz; explizit gesetzt Voraussetzung für das Passwort-Zurücksetzen per Mail | | `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme; die Zeitzonendaten sind ins Binary eingebettet (`time/tzdata`) | | `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang | @@ -75,6 +77,7 @@ 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`). - **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht. - **Logout** schreibt den Token-Hash in `revoked_tokens`. - **API-Keys:** `mindsift_sec_`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und darf nur Daten anliefern (`POST /inbox`, `POST /webhooks/ingest`); alle anderen Endpunkte antworten mit `403` ([FLAW-07](flaws.md)). diff --git a/Doku/flaws.md b/Doku/flaws.md index 51237a1..f4c58cb 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -18,7 +18,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-06 | Aufgaben | ~~Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben~~ | **Behoben** | | FLAW-07 | Auth | ~~Ein API-Key ist ein Vollzugriff~~ – Keys dürfen nur noch einliefern; Scopes fehlen noch | Niedrig | | FLAW-08 | Auth | ~~Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active`~~ | **Behoben** | -| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen; ~~Mindestpasswort 4 Zeichen~~ | Mittel | +| FLAW-09 | Auth | ~~Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen~~ | **Behoben** | | FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** | | FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** | | FLAW-12 | Benachrichtigungen | ~~`notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder; endlose Wiederholung bei Fehlern~~ | **Behoben** | @@ -90,7 +90,8 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** Dieselbe Prüfkette wie in `authMiddleware` verwenden (gemeinsame Funktion statt Kopien; siehe auch `authenticateUserFromReq`). ### FLAW-09: Kein Schutz gegen Brute-Force -- **Teilweise behoben:** Neue Passwörter brauchen überall mindestens 8 Zeichen (Setup, Registrierung, Profil, Admin-Anlage und -Reset, Reset per Mail), geprüft zentral in `auth.ValidatePassword` und vorab im Browser (`lib/password.ts`). Bestehende kürzere Passwörter funktionieren beim Anmelden weiter. Offen: Rate-Limit und Sperre. +- **Status: Behoben.** Login-Sperre nach Fehlversuchen (Konto + Adresse 3, Konto 20, Adresse 30, je 15 Minuten, `429` mit `Retry-After`); `X-Forwarded-For` wird nur noch von vertrauenswürdigen Proxys übernommen (`TRUSTED_PROXIES`). Abschaltbar mit `LOGIN_PROTECTION=false`. Tests: `internal/api/loginguard_test.go`. +- Neue Passwörter brauchen überall mindestens 8 Zeichen (Setup, Registrierung, Profil, Admin-Anlage und -Reset, Reset per Mail), geprüft zentral in `auth.ValidatePassword` und vorab im Browser (`lib/password.ts`). Bestehende kürzere Passwörter funktionieren beim Anmelden weiter. - **Fundstellen:** `handleLogin`, `handleRegister`, `handleFirstBootSetup`. - **Problem:** Weder Rate-Limit noch Sperre; `middleware.RealIP` vertraut `X-Forwarded-For` ungeprüft. Mindestpasswortlänge ist 4 Zeichen. Der Login-Pfad ist so über das Internet raten- und durchprobierbar. - **Maßnahme:** Rate-Limit je IP und Konto, längeres Mindestpasswort. diff --git a/Doku/reverse-proxy.md b/Doku/reverse-proxy.md index 864449c..05af823 100644 --- a/Doku/reverse-proxy.md +++ b/Doku/reverse-proxy.md @@ -11,6 +11,20 @@ Dieses Dokument beschreibt die empfohlene Konfiguration von Reverse-Proxies und | **WebSockets** | 443 (WSS) / 80 (WS) | `Upgrade $http_upgrade`
`Connection "Upgrade"`
`proxy_read_timeout 3600s` | Bidirektionale Live-Updates (Aufgaben, Listen, Notizen) | | **Server-Sent Events (SSE)** | 443 (HTTPS) / 80 (HTTP) | `X-Accel-Buffering: no`
`proxy_buffering off`
`proxy_cache off` | Fallback bei blockiertem WebSocket-Upgrade | | **REST API & Uploads** | 443 (HTTPS) / 80 (HTTP) | `client_max_body_size 64M` | Notizen, Import/Export (JSON) | +| **Client-IP** | – | `X-Real-IP` / `X-Forwarded-For` | Login-Sperre nach Fehlversuchen trifft den richtigen Absender | + +### Client-IP und `TRUSTED_PROXIES` + +MindSift sperrt Anmeldungen nach Fehlversuchen auch pro IP-Adresse. Hinter einem Proxy kommt jede Verbindung vom Proxy; die echte Adresse steht in `X-Forwarded-For` bzw. `X-Real-IP`. MindSift glaubt diesen Kopfzeilen nur, wenn die Verbindung von einem vertrauenswürdigen Proxy kommt, sonst könnte jeder, der den Port direkt erreicht, eine beliebige Adresse vortäuschen. + +| `TRUSTED_PROXIES` | Wirkung | +|---|---| +| `private` (Standard) | Proxys aus localhost, Docker- und Heimnetzen (`10.x`, `172.16–31.x`, `192.168.x`, `fd…`, `100.64.x`). Passt für Proxy im selben Docker-Netz oder auf demselben Host – nichts einzustellen. | +| `private,198.51.100.0/24` | zusätzlich eigene Bereiche, z. B. ein externer Proxy-Server oder die [IP-Bereiche von Cloudflare](https://www.cloudflare.com/ips/) | +| `all` | Kopfzeilen immer glauben (früheres Verhalten) – Notausgang, wenn die Erkennung Probleme macht | +| `none` | Kopfzeilen nie glauben | + +Schickt ein vertrauenswürdiger Proxy gar keine Client-IP mit, steht einmal eine Warnung im Log, und für diese Anfragen gilt nur noch die Sperre pro Konto. Die ganze Login-Sperre lässt sich mit `LOGIN_PROTECTION=false` abschalten. --- @@ -151,7 +165,8 @@ Wird MindSift hinter dem Cloudflare CDN betrieben: - Deaktiviere "Rocket Loader" für MindSift, da asynchrone Skripteingriffe den Start der Single-Page-App stören können. 3. **Cloudflare WAF / Bot Fight Mode:** - Stelle sicher, dass `GET /api/v1/events` und `GET /api/v1/ws*` nicht von "Challenge"-Seiten blockiert werden. -4. **SSE-Unterstützung:** +4. **Client-IP:** Cloudflare liefert die Besucheradresse in `CF-Connecting-IP`/`X-Forwarded-For`. Spricht Cloudflare MindSift direkt an (ohne eigenen Proxy dazwischen), die Cloudflare-Bereiche in `TRUSTED_PROXIES` ergänzen. +5. **SSE-Unterstützung:** - Cloudflare unterstützt Server-Sent Events nativ über HTTP/2 und HTTP/3. Dank der vom MindSift-Backend gesetzten Header `X-Accel-Buffering: no` und `Cache-Control: no-cache` streamt Cloudflare Echtzeit-Events verzögerungsfrei an die Clients. --- diff --git a/README.md b/README.md index 9e2c5ce..53400eb 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,8 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d | `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` | | `JWT_SECRET` | zufällig erzeugt (`/data/jwt.secret`) | Signaturschlüssel der Anmelde-Tokens; Platzhalter werden ignoriert | | `BASE_URL` | `http://localhost:8080` | Öffentliche URL. Nur wenn gesetzt (und SMTP eingerichtet ist), gibt es „Passwort vergessen?“ – die Links in den Mails werden ausschließlich daraus gebaut | +| `TRUSTED_PROXIES` | `private` | Wessen `X-Forwarded-For`/`X-Real-IP` geglaubt wird: `private` (Proxy im Docker-/Heimnetz), `all` (immer, früheres Verhalten), `none` oder eigene CIDR-Bereiche ([Doku/reverse-proxy.md](Doku/reverse-proxy.md#client-ip-und-trusted_proxies)) | +| `LOGIN_PROTECTION` | `true` | Sperre nach Fehlversuchen: 3 pro Konto und Adresse, 20 pro Konto, 30 pro Adresse, jeweils 15 Minuten. `false` schaltet sie ab | | `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme (ganztägige Aufgaben melden sich um 9:00) | | `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) | | `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging | diff --git a/internal/api/clientip.go b/internal/api/clientip.go new file mode 100644 index 0000000..b6dafbf --- /dev/null +++ b/internal/api/clientip.go @@ -0,0 +1,144 @@ +package api + +import ( + "context" + "log/slog" + "net" + "net/http" + "strings" + "sync" +) + +// Client IP behind a reverse proxy. The proxy connects to MindSift and passes the visitor's address in +// X-Forwarded-For / X-Real-IP. Those headers are only believed when the connection comes from a trusted +// proxy (TRUSTED_PROXIES), otherwise anyone reaching the port directly could claim any address and dodge +// the login protection. +// private (default) loopback and private networks (Docker, LAN, CGNAT/Tailscale) +// all always believe the headers (old behaviour, switches the check off) +// none never believe them +// CIDR list e.g. "private,203.0.113.0/24"; Cloudflare and other external proxies go here + +type clientIPKey struct{} + +type clientIPInfo struct { + ip string + // viaProxyWithoutHeader: the connection came from a trusted proxy that did not say who the + // visitor is, so all visitors look the same. Per-IP limits must not apply then. + viaProxyWithoutHeader bool +} + +type proxyTrust struct { + all bool + networks []*net.IPNet +} + +var privateProxyNets = mustCIDRs("127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7", "100.64.0.0/10") + +func parseProxyTrust(spec string) proxyTrust { + spec = strings.ToLower(strings.TrimSpace(spec)) + if spec == "" { + spec = "private" + } + var t proxyTrust + for _, part := range strings.Split(spec, ",") { + switch part = strings.TrimSpace(part); part { + case "", "none": + case "all", "*": + t.all = true + case "private": + t.networks = append(t.networks, privateProxyNets...) + default: + if !strings.Contains(part, "/") { + if strings.Contains(part, ":") { + part += "/128" + } else { + part += "/32" + } + } + if _, n, err := net.ParseCIDR(part); err == nil { + t.networks = append(t.networks, n) + } else { + slog.Warn("TRUSTED_PROXIES: Eintrag ignoriert", "entry", part) + } + } + } + return t +} + +func (t proxyTrust) trusts(ip net.IP) bool { + if t.all { + return true + } + for _, n := range t.networks { + if n.Contains(ip) { + return true + } + } + return false +} + +func peerIP(remoteAddr string) string { + if host, _, err := net.SplitHostPort(remoteAddr); err == nil { + return host + } + return remoteAddr +} + +// resolveClientIP returns the visitor's address for a request coming from peer. +func (t proxyTrust) resolveClientIP(peer string, h http.Header) clientIPInfo { + peerAddr := net.ParseIP(peer) + if peerAddr == nil || !t.trusts(peerAddr) { + return clientIPInfo{ip: peer} + } + // X-Forwarded-For: client, proxy1, proxy2 – the visitor is the right-most address that is not one of + // our proxies (everything left of it could be made up by the visitor) + if xff := h.Get("X-Forwarded-For"); xff != "" { + var chain []net.IP + for _, part := range strings.Split(xff, ",") { + if ip := net.ParseIP(strings.TrimSpace(part)); ip != nil { + chain = append(chain, ip) + } + } + if len(chain) > 0 { + if t.all { + return clientIPInfo{ip: chain[0].String()} // former behaviour: first address counts + } + for i := len(chain) - 1; i >= 0; i-- { + if !t.trusts(chain[i]) { + return clientIPInfo{ip: chain[i].String()} + } + } + return clientIPInfo{ip: chain[0].String()} // only proxies in the chain: the visitor is in our network + } + } + for _, name := range []string{"X-Real-IP", "True-Client-IP", "CF-Connecting-IP"} { + if ip := net.ParseIP(strings.TrimSpace(h.Get(name))); ip != nil { + return clientIPInfo{ip: ip.String()} + } + } + return clientIPInfo{ip: peer, viaProxyWithoutHeader: !peerAddr.IsLoopback()} +} + +var warnProxyHeaderOnce sync.Once + +// clientIPMiddleware replaces chi's RealIP: r.RemoteAddr becomes the visitor's address. +func (s *Server) clientIPMiddleware(next http.Handler) http.Handler { + trust := parseProxyTrust(s.cfg.TrustedProxies) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + info := trust.resolveClientIP(peerIP(r.RemoteAddr), r.Header) + if info.viaProxyWithoutHeader { + warnProxyHeaderOnce.Do(func() { + slog.Warn("Reverse-Proxy übergibt keine Client-IP (X-Forwarded-For/X-Real-IP fehlt); Login-Sperren pro IP sind für diese Anfragen aus", "proxy", info.ip) + }) + } + r.RemoteAddr = info.ip + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), clientIPKey{}, info))) + }) +} + +func clientIPFrom(r *http.Request) clientIPInfo { + if info, ok := r.Context().Value(clientIPKey{}).(clientIPInfo); ok { + return info + } + return clientIPInfo{ip: peerIP(r.RemoteAddr)} +} diff --git a/internal/api/handlers_admin.go b/internal/api/handlers_admin.go index a51476b..b7c884c 100644 --- a/internal/api/handlers_admin.go +++ b/internal/api/handlers_admin.go @@ -217,6 +217,7 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen") return } + s.loginGuard.unlockAccount(targetUserID) if err := s.store.UpdateUserPassword(targetUserID, hash); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 4810456..bdbf922 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -261,16 +261,30 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { } user, err := s.store.GetUserByLogin(identifier) + // Unknown names are counted under the typed login, so locking looks the same as for real accounts + account := "login:" + strings.ToLower(identifier) + if err == nil { + account = user.ID + } + ip := clientIPFrom(r) + if wait := s.loginGuard.lockedFor(account, ip); wait > 0 { + slog.Warn("Login gesperrt nach Fehlversuchen", "login", identifier, "remote_addr", r.RemoteAddr, "wait", wait.Round(time.Second)) + writeLoginLocked(w, wait) + return + } if err != nil { + s.loginGuard.recordFailure(account, ip) slog.Warn("Login fehlgeschlagen: Benutzer existiert nicht", "login", identifier, "remote_addr", r.RemoteAddr) writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig") return } if !auth.CheckPassword(req.Password, user.PasswordHash) { + s.loginGuard.recordFailure(account, ip) slog.Warn("Login fehlgeschlagen: Falsches Passwort", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr) writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig") return } + s.loginGuard.recordSuccess(account, ip) if !user.IsActive { slog.Warn("Login verweigert: Benutzerkonto ist deaktiviert", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr) writeError(w, http.StatusForbidden, "Dieses Benutzerkonto ist deaktiviert") diff --git a/internal/api/loginguard.go b/internal/api/loginguard.go new file mode 100644 index 0000000..b5897c8 --- /dev/null +++ b/internal/api/loginguard.go @@ -0,0 +1,147 @@ +package api + +import ( + "fmt" + "math" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +// Brute-force protection for the login (FLAW-09). Failed attempts are counted in memory in three +// buckets; when one is full, logins for it are refused with 429 for the lock time: +// account + IP 3 failures the attacker's address is locked out of this account, the real owner +// logging in from elsewhere is not +// account 20 failures stops attacks spread over many addresses +// IP 30 failures stops one address trying many accounts +// A bucket counts failures within the lock window and starts over afterwards. A successful login +// clears the account buckets; a password reset clears the account completely. LOGIN_PROTECTION=false +// switches all of it off. Counters are lost on restart, which is fine for this purpose. + +const ( + loginLockWindow = 15 * time.Minute + loginLimitAccountIP = 3 + loginLimitAccount = 20 + loginLimitIP = 30 + loginGuardCleanupEach = 5 * time.Minute +) + +type loginBucket struct { + count int + windowStart time.Time + lockedUntil time.Time +} + +type loginGuard struct { + enabled bool + now func() time.Time + + mu sync.Mutex + buckets map[string]*loginBucket + lastCleanup time.Time +} + +func newLoginGuard(enabled bool) *loginGuard { + return &loginGuard{enabled: enabled, now: time.Now, buckets: map[string]*loginBucket{}} +} + +type loginKey struct { + key string + limit int +} + +// loginKeys: account is the user ID (or the typed login for unknown names, so both look the same). +// The IP buckets are left out when the IP is not known (proxy without client header). +func loginKeys(account string, ip clientIPInfo) []loginKey { + keys := []loginKey{{"acct:" + account, loginLimitAccount}} + if !ip.viaProxyWithoutHeader && ip.ip != "" { + keys = append(keys, loginKey{"acctip:" + account + "|" + ip.ip, loginLimitAccountIP}, loginKey{"ip:" + ip.ip, loginLimitIP}) + } + return keys +} + +// lockedFor returns how long the login is still refused (0 = allowed). +func (g *loginGuard) lockedFor(account string, ip clientIPInfo) time.Duration { + if !g.enabled { + return 0 + } + g.mu.Lock() + defer g.mu.Unlock() + now := g.now() + var wait time.Duration + for _, k := range loginKeys(account, ip) { + if b := g.buckets[k.key]; b != nil && b.lockedUntil.After(now) && b.lockedUntil.Sub(now) > wait { + wait = b.lockedUntil.Sub(now) + } + } + return wait +} + +func (g *loginGuard) recordFailure(account string, ip clientIPInfo) { + if !g.enabled { + return + } + g.mu.Lock() + defer g.mu.Unlock() + now := g.now() + g.cleanupLocked(now) + for _, k := range loginKeys(account, ip) { + b := g.buckets[k.key] + if b == nil || now.Sub(b.windowStart) > loginLockWindow { + b = &loginBucket{windowStart: now} + g.buckets[k.key] = b + } + b.count++ + if b.count >= k.limit { + b.lockedUntil = now.Add(loginLockWindow) + } + } +} + +// recordSuccess clears the counters of this account (the IP bucket stays, so one valid login cannot +// be used to keep trying other accounts). +func (g *loginGuard) recordSuccess(account string, ip clientIPInfo) { + if !g.enabled { + return + } + g.mu.Lock() + defer g.mu.Unlock() + delete(g.buckets, "acct:"+account) + delete(g.buckets, "acctip:"+account+"|"+ip.ip) +} + +// unlockAccount lifts every lock of the account, e.g. after a password reset by mail or by an admin. +func (g *loginGuard) unlockAccount(account string) { + g.mu.Lock() + defer g.mu.Unlock() + for k := range g.buckets { + if k == "acct:"+account || strings.HasPrefix(k, "acctip:"+account+"|") { + delete(g.buckets, k) + } + } +} + +func (g *loginGuard) cleanupLocked(now time.Time) { + if now.Sub(g.lastCleanup) < loginGuardCleanupEach { + return + } + g.lastCleanup = now + for k, b := range g.buckets { + if now.Sub(b.windowStart) > loginLockWindow && !b.lockedUntil.After(now) { + delete(g.buckets, k) + } + } +} + +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())))) + unit := "Minuten" + if minutes == 1 { + unit = "Minute" + } + writeError(w, http.StatusTooManyRequests, + fmt.Sprintf("Zu viele fehlgeschlagene Anmeldeversuche. Die Anmeldung ist für noch %d %s gesperrt.", minutes, unit)) +} diff --git a/internal/api/loginguard_test.go b/internal/api/loginguard_test.go new file mode 100644 index 0000000..596b751 --- /dev/null +++ b/internal/api/loginguard_test.go @@ -0,0 +1,136 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/sandrodach/mindsift/internal/auth" +) + +func TestResolveClientIP(t *testing.T) { + h := func(kv ...string) http.Header { + out := http.Header{} + for i := 0; i < len(kv); i += 2 { + out.Set(kv[i], kv[i+1]) + } + return out + } + private := parseProxyTrust("private") + cases := []struct { + name string + trust proxyTrust + peer string + hdr http.Header + want string + noHdr bool + }{ + {"proxy in docker network passes the visitor", private, "172.18.0.2", h("X-Forwarded-For", "203.0.113.7"), "203.0.113.7", false}, + {"spoofed left part of the chain is ignored", private, "172.18.0.2", h("X-Forwarded-For", "1.2.3.4, 203.0.113.7, 10.0.0.5"), "203.0.113.7", false}, + {"X-Real-IP from a trusted proxy", private, "127.0.0.1", h("X-Real-IP", "198.51.100.9"), "198.51.100.9", false}, + {"direct visitor cannot fake an address", private, "203.0.113.50", h("X-Forwarded-For", "1.2.3.4"), "203.0.113.50", false}, + {"proxy without header is flagged", private, "172.18.0.2", h(), "172.18.0.2", true}, + {"all: headers always believed", parseProxyTrust("all"), "203.0.113.50", h("X-Forwarded-For", "1.2.3.4, 5.6.7.8"), "1.2.3.4", false}, + {"none: headers never believed", parseProxyTrust("none"), "172.18.0.2", h("X-Forwarded-For", "1.2.3.4"), "172.18.0.2", false}, + {"external proxy by CIDR", parseProxyTrust("private,198.51.100.0/24"), "198.51.100.1", h("CF-Connecting-IP", "203.0.113.8"), "203.0.113.8", false}, + } + for _, c := range cases { + got := c.trust.resolveClientIP(c.peer, c.hdr) + if got.ip != c.want || got.viaProxyWithoutHeader != c.noHdr { + t.Errorf("%s: got %+v, want %s (noHeader=%v)", c.name, got, c.want, c.noHdr) + } + } +} + +func TestLoginGuardBuckets(t *testing.T) { + g := newLoginGuard(true) + now := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC) + g.now = func() time.Time { return now } + attacker := clientIPInfo{ip: "203.0.113.66"} + owner := clientIPInfo{ip: "198.51.100.10"} + + for i := 0; i < loginLimitAccountIP; i++ { + g.recordFailure("usr_admin", attacker) + } + if g.lockedFor("usr_admin", attacker) != loginLockWindow { + t.Fatalf("3 failures from one address must lock that address out of the account") + } + if g.lockedFor("usr_admin", owner) != 0 { + t.Fatalf("the owner logging in from elsewhere must not be locked out") + } + + // Spread over many addresses: the account itself locks at 20 + for i := 0; i < loginLimitAccount; i++ { + g.recordFailure("usr_admin", clientIPInfo{ip: "192.0.2." + string(rune('0'+i%10)) + string(rune('0'+i/10))}) + } + if g.lockedFor("usr_admin", owner) == 0 { + t.Fatalf("20 failures across addresses must lock the account") + } + g.unlockAccount("usr_admin") + if g.lockedFor("usr_admin", owner) != 0 || g.lockedFor("usr_admin", attacker) != 0 { + t.Fatalf("unlockAccount must lift account and account+IP locks") + } + + // The lock ends by itself + for i := 0; i < loginLimitAccountIP; i++ { + g.recordFailure("usr_ben", attacker) + } + now = now.Add(loginLockWindow + time.Second) + if g.lockedFor("usr_ben", attacker) != 0 { + t.Fatalf("lock must expire after the window") + } + + // Without a known client IP only the account bucket counts + proxied := clientIPInfo{ip: "172.18.0.2", viaProxyWithoutHeader: true} + for i := 0; i < 10; i++ { + g.recordFailure("usr_carla", proxied) + } + if g.lockedFor("usr_carla", proxied) != 0 { + t.Fatalf("per-IP limits must not apply when the proxy hides the client IP") + } + + off := newLoginGuard(false) + for i := 0; i < 50; i++ { + off.recordFailure("usr_admin", attacker) + } + if off.lockedFor("usr_admin", attacker) != 0 { + t.Fatalf("LOGIN_PROTECTION=false must never lock") + } +} + +func TestLoginLockedAfterThreeFailures(t *testing.T) { + srv, store, _ := setupTestServer(t) + router := srv.Router() + _, _ = store.CreateUser("lock@example.com", mustHash(t, "richtig123"), "Lock", "member") + login := func(pw, from string) *httptest.ResponseRecorder { + req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(`{"login":"lock@example.com","password":"`+pw+`"}`)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = from + ":4711" + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec + } + for i := 0; i < 3; i++ { + if rec := login("falsch", "203.0.113.66"); rec.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d: want 401, got %d", i+1, rec.Code) + } + } + rec := login("richtig123", "203.0.113.66") + if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") == "" || !strings.Contains(rec.Body.String(), "15 Minuten") { + t.Fatalf("locked login: want 429 with Retry-After, got %d %s", rec.Code, rec.Body.String()) + } + if rec := login("richtig123", "198.51.100.10"); rec.Code != http.StatusOK { + t.Fatalf("owner from another address must still log in, got %d", rec.Code) + } +} + +func mustHash(t *testing.T, pw string) string { + t.Helper() + h, err := auth.HashPassword(pw) + if err != nil { + t.Fatal(err) + } + return h +} diff --git a/internal/api/password_reset.go b/internal/api/password_reset.go index 4840929..f4f4767 100644 --- a/internal/api/password_reset.go +++ b/internal/api/password_reset.go @@ -86,6 +86,7 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden") return } + s.loginGuard.unlockAccount(userID) slog.Info("Passwort per E-Mail-Link zurückgesetzt", "user_id", userID) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } diff --git a/internal/api/server.go b/internal/api/server.go index 644f875..6d8ba14 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -29,7 +29,8 @@ type Server struct { leases *collab.LeaseManager // block leases and open sessions of notes notifier *collab.Notifier // wakes long-poll requests on note changes - demo demoState // periodic reset in demo mode (demo.go) + demo demoState // periodic reset in demo mode (demo.go) + loginGuard *loginGuard // brute-force protection (loginguard.go) } func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server { @@ -41,6 +42,7 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS leases: collab.NewLeaseManager(nil), notifier: collab.NewNotifier(), } + srv.loginGuard = newLoginGuard(!cfg.DisableLoginProtection) wsHub.SetRoomAuthorizer(srv.canJoinRoom) // Next occurrences of repeating tasks appear live on every connected client store.OnTaskCreated = func(th *domain.Thought) { @@ -74,7 +76,7 @@ func (s *Server) taskAudience(th *domain.Thought) []string { func (s *Server) Router() http.Handler { r := chi.NewRouter() - r.Use(middleware.RealIP) + r.Use(s.clientIPMiddleware) r.Use(s.demoResetGate) r.Use(middleware.Recoverer) r.Use(s.apiLoggingMiddleware) diff --git a/internal/config/config.go b/internal/config/config.go index 98853ac..35ea501 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,29 +2,34 @@ package config import ( "crypto/rand" - "strconv" "encoding/hex" "log/slog" "os" "path/filepath" + "strconv" "strings" ) type Config struct { - Port string - DataDir string - DatabasePath string - JWTSecret string - BaseURL string + Port string + DataDir string + DatabasePath string + JWTSecret string + BaseURL string // BaseURLSet: BASE_URL was configured explicitly. Links in mails (password reset) are only built // from it, never from the request's Host header. - BaseURLSet bool + BaseURLSet bool AllowRegistration bool 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) + TrustedProxies string + // DisableLoginProtection: LOGIN_PROTECTION=false switches off locking logins after repeated + // failures (the zero value keeps it on) + DisableLoginProtection bool // Optional SMTP environment variables (can also be overridden/configured in DB system_settings) SMTPHost string @@ -154,24 +159,26 @@ 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, - LogLevel: logLevel, - Debug: debug, - DemoMode: demoMode, - DemoResetMinutes: demoResetMinutes(), - SMTPHost: smtpHost, - SMTPPort: smtpPort, - SMTPUser: smtpUser, - SMTPPass: smtpPass, - SMTPFrom: smtpFrom, - SMTPTLS: smtpTLS, - SMTPSkipVerify: smtpSkipVerify, + Port: port, + DataDir: dataDir, + DatabasePath: dbPath, + JWTSecret: jwtSecret, + BaseURL: baseURL, + BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "", + AllowRegistration: allowReg, + 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, + SMTPTLS: smtpTLS, + SMTPSkipVerify: smtpSkipVerify, Legal: LegalTexts{ ImprintURL: legalURL("LEGAL_IMPRINT_URL"), ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"),