feat(auth): lock logins after repeated failures, trust proxies explicitly (FLAW-09)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
Failed logins are counted per account+address (3), per account (20) and per address (30) within 15 minutes; a full bucket answers 429 with Retry-After for 15 minutes. Locking the account+address bucket first means an attacker cannot lock the owner out from elsewhere. Unknown names count like accounts. Success clears the account buckets, a password reset (mail or admin) lifts the lock. LOGIN_PROTECTION=false switches it off. chi's RealIP believed X-Forwarded-For from anyone. The client address now comes from the headers only when the connection is from a trusted proxy: TRUSTED_PROXIES=private (default: loopback, Docker, LAN), all (old behaviour), none, or CIDRs. A proxy that sends no client address is logged once and per-address limits are skipped for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6b9eb62caf
commit
11c14fa9ff
12 files changed
+478
-5
No files matched your search
@@ -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:<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 |
|
||||
@@ -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_<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` ([FLAW-07](flaws.md)).
|
||||
|
||||
+3
-2
@@ -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.
|
||||
|
||||
+16
-1
@@ -11,6 +11,20 @@ Dieses Dokument beschreibt die empfohlene Konfiguration von Reverse-Proxies und
|
||||
| **WebSockets** | 443 (WSS) / 80 (WS) | `Upgrade $http_upgrade`<br>`Connection "Upgrade"`<br>`proxy_read_timeout 3600s` | Bidirektionale Live-Updates (Aufgaben, Listen, Notizen) |
|
||||
| **Server-Sent Events (SSE)** | 443 (HTTPS) / 80 (HTTP) | `X-Accel-Buffering: no`<br>`proxy_buffering off`<br>`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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -30,6 +30,7 @@ type Server struct {
|
||||
notifier *collab.Notifier // wakes long-poll requests on note changes
|
||||
|
||||
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)
|
||||
|
||||
@@ -2,11 +2,11 @@ package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"strconv"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -25,6 +25,11 @@ type Config struct {
|
||||
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
|
||||
@@ -165,6 +170,8 @@ func Load() *Config {
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user