package app import ( "context" "crypto/pbkdf2" "crypto/rand" "crypto/sha256" "crypto/subtle" "database/sql" "encoding/base64" "encoding/hex" "fmt" "net/http" "strconv" "strings" "time" ) const ( passwordIterations = 310_000 passwordSaltBytes = 16 passwordKeyBytes = 32 sessionCookie = "pocketwatch_session" ) // This is deliberately public and never authenticates an account. It only keeps // failed-login password work roughly constant when a username does not exist. const dummyPasswordHash = "pbkdf2-sha256$310000$cG9ja2V0d2F0Y2gtZHVtbXk$y2kfBbJVOW/xpojEt9AAGQfhF+Ul6iGKDKH09PSY7IY" type session struct { User User CSRF string } type contextKey int const sessionKey contextKey = 1 func hashPassword(password string) (string, error) { if len(password) < 10 { return "", fmt.Errorf("password must have at least 10 characters") } salt := make([]byte, passwordSaltBytes) if _, err := rand.Read(salt); err != nil { return "", err } key := pbkdf2SHA256([]byte(password), salt, passwordIterations, passwordKeyBytes) return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", passwordIterations, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil } func verifyPassword(encoded, password string) bool { parts := strings.Split(encoded, "$") if len(parts) != 4 || parts[0] != "pbkdf2-sha256" { return false } iter, err := strconv.Atoi(parts[1]) if err != nil || iter < 100_000 || iter > 2_000_000 { return false } salt, err := base64.RawStdEncoding.DecodeString(parts[2]) if err != nil { return false } want, err := base64.RawStdEncoding.DecodeString(parts[3]) if err != nil || len(want) == 0 { return false } got := pbkdf2SHA256([]byte(password), salt, iter, len(want)) return subtle.ConstantTimeCompare(got, want) == 1 } // pbkdf2SHA256 wraps Go 1.26's standard-library PBKDF2 implementation. func pbkdf2SHA256(password, salt []byte, iterations, keyLen int) []byte { key, err := pbkdf2.Key(sha256.New, string(password), salt, iterations, keyLen) if err != nil { panic(err) // callers use fixed, validated parameters } return key } func randomToken(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } func tokenDigest(token string) []byte { s := sha256.Sum256([]byte(token)) return s[:] } func newID() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { panic(err) } b[6] = (b[6] & 0x0f) | 0x40 b[8] = (b[8] & 0x3f) | 0x80 h := hex.EncodeToString(b) return h[:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:] } func (a *App) createSession(ctx context.Context, userID string) (token, csrf string, expires time.Time, err error) { token, err = randomToken(32) if err != nil { return } csrf, err = randomToken(24) if err != nil { return } now := time.Now() expires = now.Add(a.cfg.SessionTTL) _, err = a.store.db.ExecContext(ctx, `INSERT INTO sessions(token_hash,user_id,csrf_token,created_at_ms,expires_at_ms) VALUES(?,?,?,?,?)`, tokenDigest(token), userID, csrf, now.UnixMilli(), expires.UnixMilli()) return } func (a *App) sessionFromRequest(r *http.Request) (*session, error) { c, err := r.Cookie(sessionCookie) if err != nil || c.Value == "" { return nil, sql.ErrNoRows } var userID, csrf string var expires int64 err = a.store.db.QueryRowContext(r.Context(), `SELECT user_id,csrf_token,expires_at_ms FROM sessions WHERE token_hash=?`, tokenDigest(c.Value)).Scan(&userID, &csrf, &expires) if err != nil { return nil, err } if expires <= time.Now().UnixMilli() { _, _ = a.store.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, tokenDigest(c.Value)) return nil, sql.ErrNoRows } u, err := a.store.userByID(r.Context(), userID) if err != nil || !u.Active { return nil, sql.ErrNoRows } return &session{User: u, CSRF: csrf}, nil } func (a *App) withSession(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s, err := a.sessionFromRequest(r) if err != nil { if strings.HasPrefix(r.URL.Path, "/api/") { jsonError(w, http.StatusUnauthorized, "not_authenticated", "Bitte anmelden.") return } http.Redirect(w, r, "/login", http.StatusSeeOther) return } next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), sessionKey, s))) }) } func sessionOf(r *http.Request) *session { x, _ := r.Context().Value(sessionKey).(*session) return x } func requireCSRF(w http.ResponseWriter, r *http.Request) bool { if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { return true } s := sessionOf(r) if s == nil || subtle.ConstantTimeCompare([]byte(r.Header.Get("X-CSRF-Token")), []byte(s.CSRF)) != 1 { jsonError(w, http.StatusForbidden, "csrf", "Ungültiges CSRF-Token.") return false } return true } func requireAdmin(w http.ResponseWriter, r *http.Request) bool { s := sessionOf(r) if s == nil || s.User.Role != "admin" { jsonError(w, http.StatusForbidden, "forbidden", "Admin-Rechte erforderlich.") return false } return true } func (a *App) deleteCurrentSession(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(sessionCookie); err == nil { _, _ = a.store.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, tokenDigest(c.Value)) } a.clearSessionCookie(w) } func (a *App) setSessionCookie(w http.ResponseWriter, token string, expires time.Time) { http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: token, Path: "/", HttpOnly: true, Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, Expires: expires, MaxAge: int(time.Until(expires).Seconds())}) } func (a *App) clearSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: -1, Expires: time.Unix(1, 0)}) } func isUniqueConstraint(err error) bool { if err == nil { return false } s := strings.ToLower(err.Error()) return strings.Contains(s, "unique constraint") || strings.Contains(s, "constraint failed") }