328 lines
9.6 KiB
Go
328 lines
9.6 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 = ?`,
|
|
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 = ?`, 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.")
|
|
|
|
// loginForm trägt die Eingaben über einen fehlgeschlagenen Versuch hinweg.
|
|
// Bewusst ohne Passwort: das wird nie zurück ins Formular geschrieben.
|
|
type loginForm struct {
|
|
User string
|
|
}
|
|
|
|
// showLogin ist die serverseitig gerenderte Login-Seite -- der Weg für Clients
|
|
// ohne JavaScript (TOR Browser auf "Safest", I2P).
|
|
func showLogin(w http.ResponseWriter, r *http.Request) error {
|
|
if _, ok := getSession(r); ok {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
render(w, r, "login", pageData{Title: "Login"})
|
|
return nil
|
|
}
|
|
|
|
// handleLogin bedient beide Adapter: das JS-Frontend (Accept: application/json)
|
|
// bekommt JSON, ein abgeschicktes Browser-Formular bekommt einen Redirect bzw.
|
|
// das neu gerenderte Formular. Die Logik dazwischen existiert nur einmal.
|
|
func handleLogin(w http.ResponseWriter, r *http.Request) error {
|
|
json := wantsJSON(r)
|
|
|
|
// fail wählt die zum Client passende Fehlerdarstellung. Der Rückgabewert
|
|
// geht an den Wrapper; bei HTML ist die Antwort schon geschrieben, deshalb
|
|
// nil.
|
|
fail := func(username string, err error) error {
|
|
if json {
|
|
return err
|
|
}
|
|
failForm(w, r, "login", pageData{Title: "Login", Form: loginForm{User: username}}, err)
|
|
return nil
|
|
}
|
|
|
|
if _, ok := getSession(r); ok {
|
|
if !json {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "already_logged_in"})
|
|
return nil
|
|
}
|
|
|
|
username := sanitizeUsername(r.FormValue("user"))
|
|
if len(username) < 3 {
|
|
return fail(username, Invalid("user.name_short", "Der Nutzername ist zu kurz.").At("user"))
|
|
}
|
|
|
|
// Gleiche Meldung für "Nutzer existiert nicht" und "Passwort falsch": sonst
|
|
// wird der Endpunkt zum Nutzernamen-Orakel.
|
|
var uid int64
|
|
var hash []byte
|
|
err := db.QueryRow(`SELECT uid, password FROM user WHERE username = ?`, username).Scan(&uid, &hash)
|
|
if err == sql.ErrNoRows {
|
|
return fail(username, errBadCredentials)
|
|
} else if err != nil {
|
|
return Internal(err)
|
|
}
|
|
|
|
if bcrypt.CompareHashAndPassword(hash, []byte(r.FormValue("pass"))) != nil {
|
|
return fail(username, errBadCredentials)
|
|
}
|
|
|
|
if err := startSession(w, r, uid); err != nil {
|
|
return err
|
|
}
|
|
|
|
// POST -> Redirect (303) statt einer gerenderten Seite: sonst zeigt ein
|
|
// Reload den "Formular erneut senden?"-Dialog und legt eine zweite Session
|
|
// an (Post/Redirect/Get).
|
|
if !json {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
return nil
|
|
}
|
|
|
|
// startSession legt eine Session an und setzt den Cookie. Von Login und
|
|
// Registrierung gemeinsam genutzt -- die Registrierung ohne JavaScript muss
|
|
// direkt einloggen, weil es sonst zwei Formulare hintereinander bräuchte.
|
|
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 30 Tage.
|
|
timeoutSec, _ := strconv.ParseInt(r.FormValue("timeout"), 10, 64)
|
|
const maxTimeout = 30 * 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 < ?`, now)
|
|
if _, err := db.Exec(
|
|
`INSERT INTO session (uid, created_at, expires, description, value) VALUES (?, ?, ?, ?, ?)`,
|
|
uid, now, expires, "", token,
|
|
); err != nil {
|
|
return Internal(err)
|
|
}
|
|
db.Exec(`UPDATE user SET last_login = ? WHERE uid = ?`, 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) {
|
|
if s, ok := getSession(r); ok {
|
|
db.Exec(`DELETE FROM session WHERE value = ?`, s.Value)
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "session",
|
|
Value: "",
|
|
Path: "/",
|
|
Expires: time.Unix(0, 0),
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: isHTTPS(r),
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// 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 {
|
|
json := wantsJSON(r)
|
|
fields := map[string]string{}
|
|
|
|
username := sanitizeUsername(r.FormValue("user"))
|
|
if len(username) < 3 {
|
|
fields["user"] = "Der Nutzername ist zu kurz."
|
|
} else if idFromUsername(username) != 0 {
|
|
fields["user"] = "Der Nutzername wird bereits verwendet."
|
|
}
|
|
|
|
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."
|
|
}
|
|
|
|
// fail wählt die zum Client passende Fehlerdarstellung -- wie bei
|
|
// handleLogin: JSON-Clients bekommen den Fehler über den Wrapper, ein
|
|
// Browser-Formular wird mit Meldungen neu gerendert.
|
|
fail := func(err error) error {
|
|
if json {
|
|
return err
|
|
}
|
|
failForm(w, r, "register", pageData{Title: "Registrieren", Form: registerForm{User: username}}, err)
|
|
return nil
|
|
}
|
|
|
|
if len(fields) > 0 {
|
|
e := Invalid("user.invalid", "Die Angaben sind unvollständig oder ungültig.")
|
|
e.Fields = fields
|
|
return fail(e)
|
|
}
|
|
|
|
uid, 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 fail(errUsernameTaken)
|
|
} else if err != nil {
|
|
return Internal(err)
|
|
}
|
|
|
|
// Ohne JavaScript direkt einloggen und weiterleiten: das JS-Frontend macht
|
|
// genau das (zweiter Aufruf auf /auth/login), und ein zweites Formular nur
|
|
// zum Anmelden wäre eine unnötige Hürde.
|
|
if !json {
|
|
if err := startSession(w, r, uid); err != nil {
|
|
return err
|
|
}
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]string{"username": username})
|
|
return nil
|
|
}
|
|
|
|
// registerForm trägt die Eingaben über einen fehlgeschlagenen Versuch hinweg --
|
|
// wieder ohne die Passwörter.
|
|
type registerForm struct {
|
|
User string
|
|
}
|
|
|
|
// showRegister ist die serverseitig gerenderte Registrierungsseite.
|
|
func showRegister(w http.ResponseWriter, r *http.Request) error {
|
|
if _, ok := getSession(r); ok {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
render(w, r, "register", pageData{Title: "Registrieren"})
|
|
return nil
|
|
}
|
|
|
|
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 handleHeaderbar(w http.ResponseWriter, r *http.Request) {
|
|
_, ok := getSession(r)
|
|
writeJSON(w, http.StatusOK, map[string]bool{"loggedin": ok})
|
|
}
|
|
|
|
func sanitizeUsername(u string) string {
|
|
u = strings.TrimSpace(u)
|
|
// Auf 32 Zeichen (Runen) kürzen, nicht 32 Bytes -- sonst kann der Schnitt
|
|
// mitten in eine Multibyte-Rune fallen und kaputtes UTF-8 erzeugen.
|
|
if r := []rune(u); len(r) > 32 {
|
|
u = string(r[: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
|
|
}
|