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")
|
||||
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 {
|
||||
|
||||
+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
|
||||
);
|
||||
|
||||
-- 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: <EntryPage /> },
|
||||
{ path: "/u/:username", element: <UserPage /> },
|
||||
{ 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).
|
||||
#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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+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
|
||||
`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 <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
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
- 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
|
||||
|
||||
@@ -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