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 }