Files
Kontrollverlust/auth.go
T
irrlichtandClaude Opus 5.5 81a74375b7 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>
2026-10-01 21:56:05 +02:00

247 lines
6.9 KiB
Go

package main
import (
crand "crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
type Session struct {
UID int64
CreatedAt int64
Expires int64
Description string
Value string
}
var (
// \p{L}\p{N} statt \w: Go-RE2-\w ist ASCII-only und würde Unicode-Namen
// (z. B. "佳紫达尔") komplett entfernen -> Login/Rename unmöglich. \p{L}\p{N}
// lässt Buchstaben und Ziffern aller Schriften zu.
reNonWord = regexp.MustCompile(`[^._\p{L}\p{N}\s-]`)
reSpace = regexp.MustCompile(`\s+`)
)
// getSession liest den session-Cookie und liefert die zugehörige, noch gültige Session.
func getSession(r *http.Request) (*Session, bool) {
c, err := r.Cookie("session")
if err != nil || c.Value == "" {
return nil, false
}
s := Session{Value: c.Value}
err = db.QueryRow(
`SELECT uid, created_at, expires, description FROM session WHERE value = $1`,
c.Value,
).Scan(&s.UID, &s.CreatedAt, &s.Expires, &s.Description)
if err != nil {
return nil, false
}
if s.Expires < time.Now().Unix() {
db.Exec(`DELETE FROM session WHERE value = $1`, c.Value)
return nil, false
}
return &s, true
}
// errBadCredentials ist für "Nutzer existiert nicht" und "Passwort falsch"
// absichtlich derselbe Fehler -- sonst wird der Endpunkt zum Nutzernamen-Orakel.
var errBadCredentials = Unauth("auth.bad_credentials", "Nutzername oder Passwort ist falsch.")
func handleLogin(w http.ResponseWriter, r *http.Request) error {
if _, ok := getSession(r); ok {
writeJSON(w, http.StatusOK, map[string]string{"status": "already_logged_in"})
return nil
}
username := sanitizeUsername(r.FormValue("user"))
if len(username) < 3 {
return Invalid("user.name_short", "Der Nutzername ist zu kurz.").At("user")
}
var uid int64
var hash []byte
err := db.QueryRow(`SELECT uid, password FROM account WHERE username = $1`, username).Scan(&uid, &hash)
if err == sql.ErrNoRows {
return errBadCredentials
} else if err != nil {
return Internal(err)
}
if bcrypt.CompareHashAndPassword(hash, []byte(r.FormValue("pass"))) != nil {
return errBadCredentials
}
if err := startSession(w, r, uid); err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return nil
}
// startSession legt eine Session an und setzt den Cookie.
func startSession(w http.ResponseWriter, r *http.Request, uid int64) error {
now := time.Now().Unix()
// timeout ist clientgesteuert -> serverseitig deckeln, damit niemand eine
// quasi-unbegrenzte Session anlegen kann. Default 1 Tag, Maximum 1 Jahr
// (größte Option im Login-Formular).
timeoutSec, _ := strconv.ParseInt(r.FormValue("timeout"), 10, 64)
const maxTimeout = 365 * 86400
if timeoutSec <= 0 {
timeoutSec = 86400
}
if timeoutSec > maxTimeout {
timeoutSec = maxTimeout
}
expires := now + timeoutSec
token, err := newToken()
if err != nil {
return Internal(err)
}
db.Exec(`DELETE FROM session WHERE expires < $1`, now)
if _, err := db.Exec(
`INSERT INTO session (uid, created_at, expires, description, value) VALUES ($1, $2, $3, $4, $5)`,
uid, now, expires, "", token,
); err != nil {
return Internal(err)
}
db.Exec(`UPDATE account SET last_login = $1 WHERE uid = $2`, now, uid)
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Expires: time.Unix(expires, 0),
Path: "/",
HttpOnly: true,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
})
return nil
}
func handleLogout(w http.ResponseWriter, r *http.Request) error {
// Scheitert das Löschen, bliebe die Session serverseitig gültig -- das
// muss der Client erfahren, statt ein "ok" zu sehen.
if s, ok := getSession(r); ok {
if _, err := db.Exec(`DELETE FROM session WHERE value = $1`, s.Value); err != nil {
return Internal(err)
}
}
clearSessionCookie(w, r)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return nil
}
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
})
}
// handleNewUser sammelt alle Validierungsfehler ein, statt beim ersten
// abzubrechen -- der Nutzer soll nicht Feld für Feld durchprobieren müssen.
// Deshalb hier Fields (Feld -> Meldung) statt eines einzelnen Field.
func handleNewUser(w http.ResponseWriter, r *http.Request) error {
fields := map[string]string{}
username := sanitizeUsername(r.FormValue("user"))
if len(username) < 3 {
fields["user"] = "Der Nutzername ist zu kurz."
} else if usernameTaken(username) {
fields["user"] = "Der Nutzername wird bereits verwendet."
}
pass1 := r.FormValue("pass1")
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
}
_, err := createUser(username, pass1)
if errors.Is(err, errUsernameTaken) {
// Rennen zwischen Vorab-Prüfung und INSERT: der UNIQUE-Constraint hat
// gegriffen -> als normale Validierungsmeldung statt 500 melden.
return errUsernameTaken
} else if err != nil {
return Internal(err)
}
writeJSON(w, http.StatusCreated, map[string]string{"username": username})
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 {
return Unauth("auth.no_session", "Keine gültige Sitzung.")
}
writeJSON(w, http.StatusOK, map[string]any{
"uid": s.UID,
"created_at": s.CreatedAt,
"expires": s.Expires,
"description": s.Description,
})
return nil
}
func sanitizeUsername(u string) string {
u = truncateRunes(strings.TrimSpace(u), 32)
u = reNonWord.ReplaceAllString(u, "")
u = reSpace.ReplaceAllString(u, "_")
return u
}
// isHTTPS erkennt, ob die Anfrage über TLS kam — direkt oder über einen
// TLS-terminierenden Reverse-Proxy (X-Forwarded-Proto). Steuert das Secure-Flag,
// ohne lokale Tests über http zu brechen.
func isHTTPS(r *http.Request) bool {
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
}
func newToken() (string, error) {
b := make([]byte, 32)
if _, err := crand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}