Passwort-Reset per Einmal-Link (kver reset-link)
Ohne E-Mail-System erzeugt der Admin per CLI einen Link (/reset#<token>), gültig 72 h und einmal. Gespeichert wird nur der SHA-256 des Tokens; es steht im Fragment und geht nur im POST-Body an die API. Der Reset meldet alle alten Sessions ab und loggt direkt neu ein. Passwortregeln jetzt gemeinsam in checkNewPassword. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b6cf8c7fa0
commit
81a74375b7
@@ -172,18 +172,7 @@ func handleNewUser(w http.ResponseWriter, r *http.Request) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pass1 := r.FormValue("pass1")
|
pass1 := r.FormValue("pass1")
|
||||||
pass2 := r.FormValue("pass2")
|
checkNewPassword(pass1, r.FormValue("pass2"), fields)
|
||||||
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."
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(fields) > 0 {
|
if len(fields) > 0 {
|
||||||
e := Invalid("user.invalid", "Die Angaben sind unvollständig oder ungültig.")
|
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
|
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 {
|
func handleSessionInfo(w http.ResponseWriter, r *http.Request) error {
|
||||||
s, ok := getSession(r)
|
s, ok := getSession(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runCommand führt einen Admin-Unterbefehl aus (kver <befehl> ...). Im
|
||||||
|
// Container: podman exec kver kver <befehl> ...
|
||||||
|
func runCommand(args []string) error {
|
||||||
|
switch args[0] {
|
||||||
|
case "reset-link":
|
||||||
|
if len(args) != 2 {
|
||||||
|
return errors.New("Verwendung: kver reset-link <nutzername>")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -82,6 +82,17 @@ CREATE TABLE IF NOT EXISTS report (
|
|||||||
handled BIGINT NOT NULL DEFAULT 0
|
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
|
-- 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
|
-- 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)
|
-- wachsender Tabelle zu Full-Table-Scans. username/session.value/vote(uid,pid)
|
||||||
|
|||||||
@@ -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),
|
// postFormFrom schickt wie postForm als API-Client (Accept: application/json),
|
||||||
// zusätzlich mit gesetzter Client-IP.
|
// zusätzlich mit gesetzter Client-IP.
|
||||||
// postFormFrom verhält sich wie postForm, setzt aber X-Real-IP -> middleware.RealIP
|
// postFormFrom verhält sich wie postForm, setzt aber X-Real-IP -> middleware.RealIP
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { FeedPage } from "./pages/FeedPage";
|
|||||||
import { EntryPage } from "./pages/EntryPage";
|
import { EntryPage } from "./pages/EntryPage";
|
||||||
import { UserPage } from "./pages/UserPage";
|
import { UserPage } from "./pages/UserPage";
|
||||||
import { StatsPage } from "./pages/StatsPage";
|
import { StatsPage } from "./pages/StatsPage";
|
||||||
|
import { ResetPage } from "./pages/ResetPage";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -31,6 +32,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: "/e/:pid", element: <EntryPage /> },
|
{ path: "/e/:pid", element: <EntryPage /> },
|
||||||
{ path: "/u/:username", element: <UserPage /> },
|
{ path: "/u/:username", element: <UserPage /> },
|
||||||
{ path: "/stats", element: <StatsPage /> },
|
{ path: "/stats", element: <StatsPage /> },
|
||||||
|
{ path: "/reset", element: <ResetPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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#<token>, 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<HTMLFormElement>) => {
|
||||||
|
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 (
|
||||||
|
<section id="reset">
|
||||||
|
<h2>Passwort neu setzen</h2>
|
||||||
|
{error && <p className="msg">{error}</p>}
|
||||||
|
{check.data && (
|
||||||
|
<form id="reset-form" onSubmit={submit}>
|
||||||
|
<p>für <strong>{check.data.username}</strong></p>
|
||||||
|
<input name="username" type="hidden" value={check.data.username} autoComplete="username" />
|
||||||
|
<input name="pass1" type="password" placeholder="Neues Passwort" autoComplete="new-password" />{" "}
|
||||||
|
<input name="pass2" type="password" placeholder="Passwort wiederholen" autoComplete="new-password" />{" "}
|
||||||
|
<button type="submit" disabled={busy}>Passwort setzen</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<p className="msg" id="reset-msg">{msg}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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).
|
# Optional: Pfad zur GeoLite2-ASN-DB im Container (leer = ASN-Auflösung aus).
|
||||||
#KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb
|
#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:
|
# Selten nötig -- die Container-Defaults passen normalerweise:
|
||||||
#KVER_ADDR=:8080
|
#KVER_ADDR=:8080
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
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
|
// Optionale GeoLite2-ASN-Datenbank für die netzwerktopologische Einordnung
|
||||||
// der Impressions. Inaktiv, wenn KVER_GEOIP_ASN nicht gesetzt ist.
|
// der Impressions. Inaktiv, wenn KVER_GEOIP_ASN nicht gesetzt ist.
|
||||||
initGeoASN()
|
initGeoASN()
|
||||||
@@ -124,6 +134,8 @@ func routes() http.Handler {
|
|||||||
r.Use(limitByIP(20, time.Minute))
|
r.Use(limitByIP(20, time.Minute))
|
||||||
r.Post("/auth/login", api(handleLogin))
|
r.Post("/auth/login", api(handleLogin))
|
||||||
r.Post("/auth/newuser", api(handleNewUser))
|
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-
|
// Logout als POST: SameSite=Lax schickt Cookies bei Top-Level-GET-
|
||||||
// Navigationen mit -- als GET könnte eine fremde Seite Nutzer per Link
|
// 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("/e/{pid}", serveApp)
|
||||||
r.With(trackImpression).Get("/u/{username}", serveApp)
|
r.With(trackImpression).Get("/u/{username}", serveApp)
|
||||||
r.Get("/stats", 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).
|
// Alte Adresse der Statistik-Seite (Lesezeichen, Links von außen).
|
||||||
r.Get("/stats.html", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/stats.html", func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Redirect(w, r, "/stats", http.StatusMovedPermanently)
|
http.Redirect(w, r, "/stats", http.StatusMovedPermanently)
|
||||||
|
|||||||
+8
-1
@@ -42,7 +42,7 @@ Endpunkte antworten ohne gültige Session mit `401 auth.required`.
|
|||||||
**CSRF:** Nicht-GET-Requests mit fremdem `Origin`-Header werden mit
|
**CSRF:** Nicht-GET-Requests mit fremdem `Origin`-Header werden mit
|
||||||
`403 csrf.bad_origin` abgewiesen.
|
`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/newuser` | – | `user` (3–32 Zeichen), `pass1` (min. 10), `pass2` | `201 {"username"}` |
|
||||||
| POST | `/api/auth/logout` | – | – | `200 {"status":"ok"}`, löscht Cookie |
|
| POST | `/api/auth/logout` | – | – | `200 {"status":"ok"}`, löscht Cookie |
|
||||||
| GET | `/api/auth/sessioninfo` | ✓ | – | `200 {"uid","created_at","expires","description"}`, ohne Session `401` |
|
| 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
|
Login-Fehler: `401 auth.bad_credentials` (gleich für unbekannten Nutzer und
|
||||||
falsches Passwort).
|
falsches Passwort).
|
||||||
|
|
||||||
|
Reset-Links erzeugt nur der Admin per CLI (`kver reset-link <nutzername>`,
|
||||||
|
siehe deploy.md). Sie zeigen auf `/reset#<token>`, 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
|
## Beiträge
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ Konfiguration über Umgebungsvariablen:
|
|||||||
| `KVER_ADDR` | `:8080` | Listen-Adresse im Container |
|
| `KVER_ADDR` | `:8080` | Listen-Adresse im Container |
|
||||||
| `KVER_DSN` | _(Pflicht)_ | Postgres-Verbindung, z. B. `postgres://kver:pw@host:5432/kontrollverlust?sslmode=disable` |
|
| `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_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)
|
## 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
|
Die App fährt bei SIGTERM sauber herunter (laufende Requests werden zu Ende
|
||||||
bedient, dann wird die DB-Verbindung geschlossen).
|
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 <nutzername>
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Link (`/reset#<token>`) 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.
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
- Moderation (Melden, Queue, Abhilfe) als eigenständiges Projekt
|
- 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
|
- Suche
|
||||||
- Hashtags / Erwähnungen
|
- Hashtags / Erwähnungen
|
||||||
- Block / Mute
|
- Block / Mute
|
||||||
|
|||||||
@@ -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#<token> -- 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user