diff --git a/auth.go b/auth.go index f26eb6e..996c3f2 100644 --- a/auth.go +++ b/auth.go @@ -172,18 +172,7 @@ func handleNewUser(w http.ResponseWriter, r *http.Request) error { } pass1 := r.FormValue("pass1") - pass2 := r.FormValue("pass2") - switch { - case len(pass1) < 10: - fields["pass1"] = "Das Passwort ist kürzer als 10 Zeichen." - // bcrypt verarbeitet maximal 72 Bytes; GenerateFromPassword gäbe darüber - // einen Fehler -> hier abfangen statt als generischer 500 zu enden. - case len(pass1) > 72: - fields["pass1"] = "Das Passwort ist länger als 72 Bytes." - } - if pass1 != pass2 { - fields["pass2"] = "Passwort und Passwortbestätigung sind ungleich." - } + checkNewPassword(pass1, r.FormValue("pass2"), fields) if len(fields) > 0 { e := Invalid("user.invalid", "Die Angaben sind unvollständig oder ungültig.") @@ -204,6 +193,22 @@ func handleNewUser(w http.ResponseWriter, r *http.Request) error { return nil } +// checkNewPassword trägt Fehler zu einem neuen Passwort (pass1) und seiner +// Wiederholung (pass2) in fields ein. Gemeinsam für Registrierung und Reset. +func checkNewPassword(pass1, pass2 string, fields map[string]string) { + switch { + case len(pass1) < 10: + fields["pass1"] = "Das Passwort ist kürzer als 10 Zeichen." + // bcrypt verarbeitet maximal 72 Bytes; GenerateFromPassword gäbe darüber + // einen Fehler -> hier abfangen statt als generischer 500 zu enden. + case len(pass1) > 72: + fields["pass1"] = "Das Passwort ist länger als 72 Bytes." + } + if pass1 != pass2 { + fields["pass2"] = "Passwort und Passwortbestätigung sind ungleich." + } +} + func handleSessionInfo(w http.ResponseWriter, r *http.Request) error { s, ok := getSession(r) if !ok { diff --git a/command.go b/command.go new file mode 100644 index 0000000..2ad20ec --- /dev/null +++ b/command.go @@ -0,0 +1,41 @@ +package main + +import ( + "errors" + "fmt" + "os" + "strings" +) + +// runCommand führt einen Admin-Unterbefehl aus (kver ...). Im +// Container: podman exec kver kver ... +func runCommand(args []string) error { + switch args[0] { + case "reset-link": + if len(args) != 2 { + return errors.New("Verwendung: kver reset-link ") + } + return cmdResetLink(args[1]) + default: + return fmt.Errorf("unbekannter Befehl %q (bekannt: reset-link)", args[0]) + } +} + +// cmdResetLink erzeugt einen Passwort-Reset-Link (siehe reset.go) und gibt ihn +// aus. KVER_BASE_URL (z. B. https://example.org) macht daraus einen fertigen +// Link; ohne steht nur der Pfad da. +func cmdResetLink(username string) error { + username = sanitizeUsername(username) + token, expires, err := createResetToken(username) + if errors.Is(err, errUserNotFound) { + return fmt.Errorf("Nutzer %q nicht gefunden", username) + } else if err != nil { + return err + } + + base := strings.TrimSuffix(os.Getenv("KVER_BASE_URL"), "/") + fmt.Printf("Reset-Link für %s (gültig bis %s, einmalig):\n\n %s/reset#%s\n\n", + username, expires.Format("02.01.2006 15:04 MST"), base, token) + fmt.Println("Ältere Reset-Links dieses Nutzers sind damit ungültig.") + return nil +} diff --git a/db.go b/db.go index 8a7259a..f9479fc 100644 --- a/db.go +++ b/db.go @@ -82,6 +82,17 @@ CREATE TABLE IF NOT EXISTS report ( handled BIGINT NOT NULL DEFAULT 0 ); +-- Einmal-Links zum Passwort-Reset, per CLI erzeugt (kver reset-link, siehe +-- reset.go). Gespeichert wird nur der SHA-256 des Tokens: wer die DB lesen +-- kann, kann damit trotzdem kein Passwort setzen. +CREATE TABLE IF NOT EXISTS password_reset ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + uid BIGINT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at BIGINT NOT NULL, + expires BIGINT NOT NULL +); + -- Indizes für die häufigen Zugriffspfade; ohne sie werden Feed (ORDER BY -- last_activity), Thread (WHERE reply_to) und Vote-Zähler (WHERE pid) mit -- wachsender Tabelle zu Full-Table-Scans. username/session.value/vote(uid,pid) diff --git a/endpoints_test.go b/endpoints_test.go index 8ba85c1..0cc9502 100644 --- a/endpoints_test.go +++ b/endpoints_test.go @@ -687,6 +687,89 @@ func TestDeleteUser(t *testing.T) { } } +func TestPasswordReset(t *testing.T) { + srv := newTestServer(t) + old := registerAndLogin(t, srv, "alice") + + token, _, err := createResetToken("alice") + if err != nil { + t.Fatalf("createResetToken: %v", err) + } + if _, _, err := createResetToken("niemand"); err != errUserNotFound { + t.Fatalf("unbekannter Nutzer: erwartet errUserNotFound, bekam %v", err) + } + + c := newClient(t) + status := func(path string, form url.Values) int { + t.Helper() + resp := postForm(t, c, srv.URL+path, form) + resp.Body.Close() + return resp.StatusCode + } + + // Vorab-Prüfung: gültig bzw. unbekannt. + if got := status("/api/auth/reset/check", url.Values{"token": {token}}); got != http.StatusOK { + t.Fatalf("check: erwartet 200, bekam %d", got) + } + if got := status("/api/auth/reset/check", url.Values{"token": {"falsch"}}); got != http.StatusNotFound { + t.Fatalf("check mit falschem Token: erwartet 404, bekam %d", got) + } + + // Zu kurzes Passwort -> 400, der Link bleibt gültig. + if got := status("/api/auth/reset", url.Values{ + "token": {token}, "pass1": {"kurz"}, "pass2": {"kurz"}, + }); got != http.StatusBadRequest { + t.Fatalf("kurzes Passwort: erwartet 400, bekam %d", got) + } + + if got := status("/api/auth/reset", url.Values{ + "token": {token}, "pass1": {"neuesgeheimnis"}, "pass2": {"neuesgeheimnis"}, + }); got != http.StatusOK { + t.Fatalf("reset: erwartet 200, bekam %d", got) + } + + // Direkt eingeloggt; die alte Session ist weg. + if got := getStatus(t, c, srv.URL+"/api/user/info"); got != http.StatusOK { + t.Fatalf("nach Reset eingeloggt: erwartet 200, bekam %d", got) + } + if got := getStatus(t, old, srv.URL+"/api/user/info"); got != http.StatusUnauthorized { + t.Fatalf("alte Session nach Reset: erwartet 401, bekam %d", got) + } + + // Link gilt nur einmal. + if got := status("/api/auth/reset", url.Values{ + "token": {token}, "pass1": {"nochandersxx"}, "pass2": {"nochandersxx"}, + }); got != http.StatusNotFound { + t.Fatalf("Link zweimal benutzt: erwartet 404, bekam %d", got) + } + + // Altes Passwort tot, neues geht. + login := func(pass string) int { + resp := postForm(t, newClient(t), srv.URL+"/api/auth/login", url.Values{ + "user": {"alice"}, "pass": {pass}, "timeout": {"86400"}, + }) + resp.Body.Close() + return resp.StatusCode + } + if got := login("supersecret1"); got != http.StatusUnauthorized { + t.Fatalf("altes Passwort: erwartet 401, bekam %d", got) + } + if got := login("neuesgeheimnis"); got != http.StatusOK { + t.Fatalf("neues Passwort: erwartet 200, bekam %d", got) + } + + // Ein neuer Link macht den vorigen ungültig; abgelaufene gelten nicht. + first, _, _ := createResetToken("alice") + second, _, _ := createResetToken("alice") + if got := status("/api/auth/reset/check", url.Values{"token": {first}}); got != http.StatusNotFound { + t.Fatalf("überholter Link: erwartet 404, bekam %d", got) + } + db.Exec(`UPDATE password_reset SET expires = 0`) + if got := status("/api/auth/reset/check", url.Values{"token": {second}}); got != http.StatusNotFound { + t.Fatalf("abgelaufener Link: erwartet 404, bekam %d", got) + } +} + // postFormFrom schickt wie postForm als API-Client (Accept: application/json), // zusätzlich mit gesetzter Client-IP. // postFormFrom verhält sich wie postForm, setzt aber X-Real-IP -> middleware.RealIP diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 86dda0f..6590852 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,6 +8,7 @@ import { FeedPage } from "./pages/FeedPage"; import { EntryPage } from "./pages/EntryPage"; import { UserPage } from "./pages/UserPage"; import { StatsPage } from "./pages/StatsPage"; +import { ResetPage } from "./pages/ResetPage"; import "./app.css"; const queryClient = new QueryClient({ @@ -31,6 +32,7 @@ const router = createBrowserRouter([ { path: "/e/:pid", element: }, { path: "/u/:username", element: }, { path: "/stats", element: }, + { path: "/reset", element: }, ], }, ]); diff --git a/frontend/src/pages/ResetPage.tsx b/frontend/src/pages/ResetPage.tsx new file mode 100644 index 0000000..0a45490 --- /dev/null +++ b/frontend/src/pages/ResetPage.tsx @@ -0,0 +1,65 @@ +import { useState, type FormEvent } from "react"; +import { useNavigate } from "react-router"; +import { useQuery } from "@tanstack/react-query"; +import { api, errMsg } from "../api"; +import { useAuthChanged } from "../auth"; + +// ResetPage: neues Passwort über einen Reset-Link (/reset#, erzeugt per +// kver reset-link). Das Token steht im Fragment, damit es nie in einer URL beim +// Server landet; es geht nur im POST-Body raus. +export function ResetPage() { + const [token] = useState(() => window.location.hash.slice(1)); + const [msg, setMsg] = useState(""); + const [busy, setBusy] = useState(false); + const navigate = useNavigate(); + const authChanged = useAuthChanged(); + + const check = useQuery({ + queryKey: ["reset", token], + queryFn: () => { + const fd = new FormData(); + fd.append("token", token); + return api.post<{ username: string }>("/auth/reset/check", fd); + }, + enabled: token !== "", + staleTime: Infinity, + }); + + // Nach Erfolg ist man eingeloggt -> ab in den Feed. + const submit = async (e: FormEvent) => { + e.preventDefault(); + const fd = new FormData(e.currentTarget); + fd.append("token", token); + setBusy(true); + try { + await api.post("/auth/reset", fd); + await authChanged(); + navigate("/"); + } catch (err) { + setMsg(errMsg(err, "Passwort konnte nicht gesetzt werden.")); + } finally { + setBusy(false); + } + }; + + let error = ""; + if (token === "") error = "Der Link ist unvollständig."; + else if (check.isError) error = errMsg(check.error, "Der Link konnte nicht geprüft werden."); + + return ( +
+

Passwort neu setzen

+ {error &&

{error}

} + {check.data && ( +
+

für {check.data.username}

+ + {" "} + {" "} + +
+ )} +

{msg}

+
+ ); +} diff --git a/kver.env.example b/kver.env.example index b61075e..a635184 100644 --- a/kver.env.example +++ b/kver.env.example @@ -15,6 +15,10 @@ KVER_DSN=postgres://kver:passwort@host:5432/kontrollverlust?sslmode=disable # Optional: Pfad zur GeoLite2-ASN-DB im Container (leer = ASN-Auflösung aus). #KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb +# Optional: öffentliche Adresse, damit `kver reset-link` einen fertigen Link +# ausgibt statt nur des Pfads. +#KVER_BASE_URL=https://example.org + # Selten nötig -- die Container-Defaults passen normalerweise: #KVER_ADDR=:8080 diff --git a/main.go b/main.go index 88d07a2..dd2396e 100644 --- a/main.go +++ b/main.go @@ -37,6 +37,16 @@ func main() { } defer db.Close() + // Unterbefehle (z. B. reset-link) statt Server: laufen gegen dieselbe DB + // und beenden sich danach, siehe command.go. + if len(os.Args) > 1 { + if err := runCommand(os.Args[1:]); err != nil { + db.Close() + log.Fatal(err) + } + return + } + // Optionale GeoLite2-ASN-Datenbank für die netzwerktopologische Einordnung // der Impressions. Inaktiv, wenn KVER_GEOIP_ASN nicht gesetzt ist. initGeoASN() @@ -124,6 +134,8 @@ func routes() http.Handler { r.Use(limitByIP(20, time.Minute)) r.Post("/auth/login", api(handleLogin)) r.Post("/auth/newuser", api(handleNewUser)) + r.Post("/auth/reset/check", api(handleResetCheck)) + r.Post("/auth/reset", api(handleReset)) }) // Logout als POST: SameSite=Lax schickt Cookies bei Top-Level-GET- // Navigationen mit -- als GET könnte eine fremde Seite Nutzer per Link @@ -169,6 +181,9 @@ func routes() http.Handler { r.With(trackImpression).Get("/e/{pid}", serveApp) r.With(trackImpression).Get("/u/{username}", serveApp) r.Get("/stats", serveApp) + // Reset-Seite ohne Tracking; das Token steht im Fragment (#...) und + // erreicht den Server über diese Route nie, siehe reset.go. + r.Get("/reset", serveApp) // Alte Adresse der Statistik-Seite (Lesezeichen, Links von außen). r.Get("/stats.html", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/stats", http.StatusMovedPermanently) diff --git a/notes/api.md b/notes/api.md index ef69456..f2eb31b 100644 --- a/notes/api.md +++ b/notes/api.md @@ -42,7 +42,7 @@ Endpunkte antworten ohne gültige Session mit `401 auth.required`. **CSRF:** Nicht-GET-Requests mit fremdem `Origin`-Header werden mit `403 csrf.bad_origin` abgewiesen. -**Rate-Limits** (pro IP): Login und Registrierung 20/min. +**Rate-Limits** (pro IP): Login, Registrierung und Passwort-Reset 20/min. --- @@ -54,10 +54,17 @@ Endpunkte antworten ohne gültige Session mit `401 auth.required`. | POST | `/api/auth/newuser` | – | `user` (3–32 Zeichen), `pass1` (min. 10), `pass2` | `201 {"username"}` | | POST | `/api/auth/logout` | – | – | `200 {"status":"ok"}`, löscht Cookie | | GET | `/api/auth/sessioninfo` | ✓ | – | `200 {"uid","created_at","expires","description"}`, ohne Session `401` | +| POST | `/api/auth/reset/check` | – | `token` | `200 {"username"}`; ungültig/abgelaufen/benutzt: `404 reset.invalid` | +| POST | `/api/auth/reset` | – | `token`, `pass1` (min. 10), `pass2` | `200 {"username"}` + Cookie (eingeloggt); löscht alle alten Sessions, Link verbraucht | Login-Fehler: `401 auth.bad_credentials` (gleich für unbekannten Nutzer und falsches Passwort). +Reset-Links erzeugt nur der Admin per CLI (`kver reset-link `, +siehe deploy.md). Sie zeigen auf `/reset#`, gelten 72 h und einmal; ein +neuer Link macht ältere desselben Nutzers ungültig. Das Token steht im +Fragment und geht nur im POST-Body an die API, damit es nicht in Logs landet. + --- ## Beiträge diff --git a/notes/deploy.md b/notes/deploy.md index ebb7c89..935cc7b 100644 --- a/notes/deploy.md +++ b/notes/deploy.md @@ -63,6 +63,7 @@ Konfiguration über Umgebungsvariablen: | `KVER_ADDR` | `:8080` | Listen-Adresse im Container | | `KVER_DSN` | _(Pflicht)_ | Postgres-Verbindung, z. B. `postgres://kver:pw@host:5432/kontrollverlust?sslmode=disable` | | `KVER_GEOIP_ASN` | _(leer)_ | Pfad zur GeoLite2-ASN-`.mmdb`; leer = ASN-Auflösung aus | +| `KVER_BASE_URL` | _(leer)_ | Öffentliche Adresse für `kver reset-link`; leer = nur Pfad | ## GeoLite2-ASN-Datenbank (optional) @@ -217,3 +218,16 @@ systemctl --user restart kver Die App fährt bei SIGTERM sauber herunter (laufende Requests werden zu Ende bedient, dann wird die DB-Verbindung geschlossen). + +## Passwort zurücksetzen + +Ohne E-Mail-System erzeugt der Admin einen Einmal-Link und gibt ihn dem +Nutzer persönlich: + +```sh +podman exec kver kver reset-link +``` + +Der Link (`/reset#`) gilt 72 Stunden und nur einmal; ein neuer Link +macht ältere desselben Nutzers ungültig. Beim Setzen des neuen Passworts +werden alle bestehenden Sessions des Nutzers abgemeldet. diff --git a/notes/todo.md b/notes/todo.md index 81b08a6..7da01ec 100644 --- a/notes/todo.md +++ b/notes/todo.md @@ -1,7 +1,7 @@ # TODO - Moderation (Melden, Queue, Abhilfe) als eigenständiges Projekt -- Passwort-Reset +- Passwort ändern im Profil; Reset per E-Mail (bisher nur Admin-Link, `kver reset-link`) - Suche - Hashtags / Erwähnungen - Block / Mute diff --git a/reset.go b/reset.go new file mode 100644 index 0000000..a4bf9a1 --- /dev/null +++ b/reset.go @@ -0,0 +1,147 @@ +package main + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "net/http" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// Passwort-Reset ohne E-Mail: der Admin erzeugt per CLI (kver reset-link) einen +// Einmal-Link und gibt ihn dem Nutzer persönlich. Der Link zeigt auf +// /reset# -- im Fragment, damit das Token weder im Access-Log noch in +// der Impression-Statistik noch im Referer landet. Die Seite schickt es per +// POST an die API. + +// resetTTL ist die Gültigkeit eines Reset-Links. Großzügig, weil er von Hand +// übergeben wird; er gilt ohnehin nur einmal. +const resetTTL = 72 * time.Hour + +// errResetInvalid deckt unbekannt, abgelaufen und schon benutzt ab -- für den +// Nutzer ist das dasselbe: einen neuen Link holen. +var errResetInvalid = NotFound("reset.invalid", "Der Link ist ungültig oder abgelaufen.") + +// hashResetToken: in der DB steht nur der Hash. SHA-256 statt bcrypt genügt, +// das Token ist 256 Bit Zufall und nicht zu erraten. +func hashResetToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +// createResetToken legt für username einen neuen Reset-Link an und liefert das +// Token im Klartext (nur hier sichtbar). Ältere Links desselben Nutzers werden +// ungültig, abgelaufene aller Nutzer weggeräumt. +func createResetToken(username string) (string, time.Time, error) { + var uid int64 + if err := db.QueryRow(`SELECT uid FROM account WHERE username = $1`, username).Scan(&uid); err != nil { + return "", time.Time{}, errUserNotFound + } + + token, err := newToken() + if err != nil { + return "", time.Time{}, err + } + now := time.Now() + expires := now.Add(resetTTL) + + tx, err := db.Begin() + if err != nil { + return "", time.Time{}, err + } + defer tx.Rollback() + if _, err := tx.Exec(`DELETE FROM password_reset WHERE uid = $1 OR expires < $2`, uid, now.Unix()); err != nil { + return "", time.Time{}, err + } + if _, err := tx.Exec( + `INSERT INTO password_reset (uid, token_hash, created_at, expires) VALUES ($1, $2, $3, $4)`, + uid, hashResetToken(token), now.Unix(), expires.Unix(), + ); err != nil { + return "", time.Time{}, err + } + return token, expires, tx.Commit() +} + +// handleResetCheck prüft einen Link vorab, damit die Seite den Namen zeigen +// (oder gleich "abgelaufen" melden) kann, bevor jemand ein Passwort tippt. +// POST statt GET, damit das Token nicht in der URL und damit im Log steht. +func handleResetCheck(w http.ResponseWriter, r *http.Request) error { + var username string + err := db.QueryRow( + `SELECT a.username FROM password_reset p JOIN account a ON a.uid = p.uid + WHERE p.token_hash = $1 AND p.expires >= $2`, + hashResetToken(r.FormValue("token")), time.Now().Unix(), + ).Scan(&username) + if err == sql.ErrNoRows { + return errResetInvalid + } else if err != nil { + return Internal(err) + } + writeJSON(w, http.StatusOK, map[string]string{"username": username}) + return nil +} + +// handleReset setzt das neue Passwort (pass1/pass2 wie bei der Registrierung), +// verbraucht den Link, meldet alle bestehenden Sessions ab und loggt den +// Nutzer direkt neu ein. +func handleReset(w http.ResponseWriter, r *http.Request) error { + // Erst die Eingabe prüfen: ein Tippfehler soll den Link nicht verbrauchen. + pass1 := r.FormValue("pass1") + fields := map[string]string{} + checkNewPassword(pass1, r.FormValue("pass2"), fields) + if len(fields) > 0 { + e := Invalid("user.invalid", "Die Angaben sind unvollständig oder ungültig.") + e.Fields = fields + return e + } + + hash, err := bcrypt.GenerateFromPassword([]byte(pass1), bcrypt.DefaultCost) + if err != nil { + return Internal(err) + } + + tx, err := db.Begin() + if err != nil { + return Internal(err) + } + defer tx.Rollback() + + // DELETE ... RETURNING verbraucht den Link atomar: zwei gleichzeitige + // Requests mit demselben Token können nicht beide durchkommen. + var uid int64 + err = tx.QueryRow( + `DELETE FROM password_reset WHERE token_hash = $1 AND expires >= $2 RETURNING uid`, + hashResetToken(r.FormValue("token")), time.Now().Unix(), + ).Scan(&uid) + if err == sql.ErrNoRows { + return errResetInvalid + } else if err != nil { + return Internal(err) + } + + var username string + if err := tx.QueryRow( + `UPDATE account SET password = $1 WHERE uid = $2 RETURNING username`, hash, uid, + ).Scan(&username); err != nil { + // Konto inzwischen gelöscht: der Link führt ins Leere. + if err == sql.ErrNoRows { + return errResetInvalid + } + return Internal(err) + } + // Wer das alte Passwort kannte, soll mit dessen Sessions nicht drin bleiben. + if _, err := tx.Exec(`DELETE FROM session WHERE uid = $1`, uid); err != nil { + return Internal(err) + } + if err := tx.Commit(); err != nil { + return Internal(err) + } + + if err := startSession(w, r, uid); err != nil { + return err + } + writeJSON(w, http.StatusOK, map[string]string{"username": username}) + return nil +}