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:
irrlicht
2026-10-01 21:56:05 +02:00
co-authored by Claude Opus 5.5
parent b6cf8c7fa0
commit 81a74375b7
12 changed files with 408 additions and 14 deletions
+17 -12
View File
@@ -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
View File
@@ -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
}
+11
View File
@@ -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)
+83
View File
@@ -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
+2
View File
@@ -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 /> },
], ],
}, },
]); ]);
+65
View File
@@ -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>
);
}
+4
View File
@@ -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
+15
View File
@@ -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
View File
@@ -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
+14
View File
@@ -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
View File
@@ -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
+147
View File
@@ -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
}