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
+503
-30
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 |
|
| `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) |
|
| `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)) |
|
| `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 |
|
| `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`) |
|
| `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 |
|
| `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
|
## Authentifizierung
|
||||||
|
|
||||||
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
|
- **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.
|
- **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`.
|
- **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)).
|
- **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-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-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-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-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-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** |
|
| 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`).
|
- **Maßnahme:** Dieselbe Prüfkette wie in `authMiddleware` verwenden (gemeinsame Funktion statt Kopien; siehe auch `authenticateUserFromReq`).
|
||||||
|
|
||||||
### FLAW-09: Kein Schutz gegen Brute-Force
|
### 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`.
|
- **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.
|
- **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.
|
- **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) |
|
| **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 |
|
| **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) |
|
| **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.
|
- 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:**
|
3. **Cloudflare WAF / Bot Fight Mode:**
|
||||||
- Stelle sicher, dass `GET /api/v1/events` und `GET /api/v1/ws*` nicht von "Challenge"-Seiten blockiert werden.
|
- 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.
|
- 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` |
|
| `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 |
|
| `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 |
|
| `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) |
|
| `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) |
|
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
||||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
| `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")
|
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.loginGuard.unlockAccount(targetUserID)
|
||||||
if err := s.store.UpdateUserPassword(targetUserID, hash); err != nil {
|
if err := s.store.UpdateUserPassword(targetUserID, hash); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -261,16 +261,30 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
user, err := s.store.GetUserByLogin(identifier)
|
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 {
|
if err != nil {
|
||||||
|
s.loginGuard.recordFailure(account, ip)
|
||||||
slog.Warn("Login fehlgeschlagen: Benutzer existiert nicht", "login", identifier, "remote_addr", r.RemoteAddr)
|
slog.Warn("Login fehlgeschlagen: Benutzer existiert nicht", "login", identifier, "remote_addr", r.RemoteAddr)
|
||||||
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
|
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !auth.CheckPassword(req.Password, user.PasswordHash) {
|
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)
|
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")
|
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.loginGuard.recordSuccess(account, ip)
|
||||||
if !user.IsActive {
|
if !user.IsActive {
|
||||||
slog.Warn("Login verweigert: Benutzerkonto ist deaktiviert", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
|
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")
|
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")
|
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.loginGuard.unlockAccount(userID)
|
||||||
slog.Info("Passwort per E-Mail-Link zurückgesetzt", "user_id", userID)
|
slog.Info("Passwort per E-Mail-Link zurückgesetzt", "user_id", userID)
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,8 @@ type Server struct {
|
|||||||
leases *collab.LeaseManager // block leases and open sessions of notes
|
leases *collab.LeaseManager // block leases and open sessions of notes
|
||||||
notifier *collab.Notifier // wakes long-poll requests on note changes
|
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 {
|
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),
|
leases: collab.NewLeaseManager(nil),
|
||||||
notifier: collab.NewNotifier(),
|
notifier: collab.NewNotifier(),
|
||||||
}
|
}
|
||||||
|
srv.loginGuard = newLoginGuard(!cfg.DisableLoginProtection)
|
||||||
wsHub.SetRoomAuthorizer(srv.canJoinRoom)
|
wsHub.SetRoomAuthorizer(srv.canJoinRoom)
|
||||||
// Next occurrences of repeating tasks appear live on every connected client
|
// Next occurrences of repeating tasks appear live on every connected client
|
||||||
store.OnTaskCreated = func(th *domain.Thought) {
|
store.OnTaskCreated = func(th *domain.Thought) {
|
||||||
@@ -74,7 +76,7 @@ func (s *Server) taskAudience(th *domain.Thought) []string {
|
|||||||
func (s *Server) Router() http.Handler {
|
func (s *Server) Router() http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
r.Use(middleware.RealIP)
|
r.Use(s.clientIPMiddleware)
|
||||||
r.Use(s.demoResetGate)
|
r.Use(s.demoResetGate)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(s.apiLoggingMiddleware)
|
r.Use(s.apiLoggingMiddleware)
|
||||||
|
|||||||
+32
-25
@@ -2,29 +2,34 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"strconv"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port string
|
Port string
|
||||||
DataDir string
|
DataDir string
|
||||||
DatabasePath string
|
DatabasePath string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
BaseURL string
|
BaseURL string
|
||||||
// BaseURLSet: BASE_URL was configured explicitly. Links in mails (password reset) are only built
|
// BaseURLSet: BASE_URL was configured explicitly. Links in mails (password reset) are only built
|
||||||
// from it, never from the request's Host header.
|
// from it, never from the request's Host header.
|
||||||
BaseURLSet bool
|
BaseURLSet bool
|
||||||
AllowRegistration bool
|
AllowRegistration bool
|
||||||
LogLevel string
|
LogLevel string
|
||||||
Debug bool
|
Debug bool
|
||||||
DemoMode bool
|
DemoMode bool
|
||||||
// DemoResetMinutes: in demo mode the database is reset to the sample data this often (0 = never)
|
// DemoResetMinutes: in demo mode the database is reset to the sample data this often (0 = never)
|
||||||
DemoResetMinutes int
|
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)
|
// Optional SMTP environment variables (can also be overridden/configured in DB system_settings)
|
||||||
SMTPHost string
|
SMTPHost string
|
||||||
@@ -154,24 +159,26 @@ func Load() *Config {
|
|||||||
smtpSkipVerify := strings.ToLower(getEnv("SMTP_SKIP_VERIFY", "false")) == "true"
|
smtpSkipVerify := strings.ToLower(getEnv("SMTP_SKIP_VERIFY", "false")) == "true"
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
Port: port,
|
Port: port,
|
||||||
DataDir: dataDir,
|
DataDir: dataDir,
|
||||||
DatabasePath: dbPath,
|
DatabasePath: dbPath,
|
||||||
JWTSecret: jwtSecret,
|
JWTSecret: jwtSecret,
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "",
|
BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "",
|
||||||
AllowRegistration: allowReg,
|
AllowRegistration: allowReg,
|
||||||
LogLevel: logLevel,
|
LogLevel: logLevel,
|
||||||
Debug: debug,
|
Debug: debug,
|
||||||
DemoMode: demoMode,
|
DemoMode: demoMode,
|
||||||
DemoResetMinutes: demoResetMinutes(),
|
DemoResetMinutes: demoResetMinutes(),
|
||||||
SMTPHost: smtpHost,
|
TrustedProxies: getEnv("TRUSTED_PROXIES", "private"),
|
||||||
SMTPPort: smtpPort,
|
DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false",
|
||||||
SMTPUser: smtpUser,
|
SMTPHost: smtpHost,
|
||||||
SMTPPass: smtpPass,
|
SMTPPort: smtpPort,
|
||||||
SMTPFrom: smtpFrom,
|
SMTPUser: smtpUser,
|
||||||
SMTPTLS: smtpTLS,
|
SMTPPass: smtpPass,
|
||||||
SMTPSkipVerify: smtpSkipVerify,
|
SMTPFrom: smtpFrom,
|
||||||
|
SMTPTLS: smtpTLS,
|
||||||
|
SMTPSkipVerify: smtpSkipVerify,
|
||||||
Legal: LegalTexts{
|
Legal: LegalTexts{
|
||||||
ImprintURL: legalURL("LEGAL_IMPRINT_URL"),
|
ImprintURL: legalURL("LEGAL_IMPRINT_URL"),
|
||||||
ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"),
|
ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"),
|
||||||
|
|||||||
Reference in new issue
Block a user