From a94b1d3e52db6212e2ca2a639c7fddca55671ff5 Mon Sep 17 00:00:00 2001 From: sysops Date: Sat, 14 Mar 2026 23:04:45 +0100 Subject: [PATCH] fix(PROJ-1): bcrypt Cost 12, Rate-Limiting, last_login_at, User Update/Delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bcrypt cost erhöht von DefaultCost (10) auf 12 - Rate-Limiting: max 5 Fehlversuche in 15 Min → HTTP 429 - last_login_at in DB gespeichert und bei jedem Login aktualisiert - login_attempts Tabelle für Fehlversuche - PATCH /api/users/{id}: Passwort-Reset, Rolle, E-Mail, Active - DELETE /api/users/{id}: Löschen mit Schutz für letzten Admin Co-Authored-By: Claude Sonnet 4.6 --- internal/api/server.go | 99 +++++++++++++++++++++++++++++++++ internal/userstore/userstore.go | 86 ++++++++++++++++++++++++---- 2 files changed, 175 insertions(+), 10 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 2f3c527..79e9aa0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -88,6 +88,8 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/auth/logout", s.authMiddleware(s.handleLogout)) s.mux.HandleFunc("GET /api/users", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListUsers))) s.mux.HandleFunc("POST /api/users", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleCreateUser))) + s.mux.HandleFunc("PATCH /api/users/{id}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUpdateUser))) + s.mux.HandleFunc("DELETE /api/users/{id}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleDeleteUser))) s.mux.HandleFunc("GET /api/search", s.authMiddleware(s.handleSearch)) s.mux.HandleFunc("GET /api/audit", s.authMiddleware(s.requireRole(userstore.RoleAuditor, s.handleAuditLog))) s.mux.HandleFunc("GET /api/admin/smtp/status", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSMTPStatus))) @@ -124,6 +126,11 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } +const ( + loginMaxFailures = 5 + loginWindow = 15 * time.Minute +) + func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` @@ -134,8 +141,23 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { return } + // Rate-limiting: block after too many recent failures + failures, err := s.users.CountRecentFailures(req.Username, loginWindow) + if err == nil && failures >= loginMaxFailures { + s.audlog.Log(audit.Entry{ + EventType: audit.EventLogin, + Username: req.Username, + IPAddress: remoteIP(r), + Success: false, + Detail: "rate limited", + }) + writeError(w, http.StatusTooManyRequests, "too many failed login attempts, try again later") + return + } + token, user, err := s.authMgr.Login(req.Username, req.Password) if err != nil { + _ = s.users.RecordLoginAttempt(req.Username, remoteIP(r)) s.audlog.Log(audit.Entry{ EventType: audit.EventLogin, Username: req.Username, @@ -147,6 +169,8 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { return } + _ = s.users.UpdateLastLogin(user.ID) + s.audlog.Log(audit.Entry{ EventType: audit.EventLogin, Username: user.Username, @@ -268,6 +292,81 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user id") + return + } + + var req struct { + Email *string `json:"email"` + Role *string `json:"role"` + Active *bool `json:"active"` + Password *string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + updated, err := s.users.Update(id, userstore.UpdateUserRequest{ + Email: req.Email, + Role: req.Role, + Active: req.Active, + Password: req.Password, + }) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + sess := sessionFromCtx(r.Context()) + s.audlog.Log(audit.Entry{ + EventType: audit.EventUserMgmt, + Username: sess.Username, + IPAddress: remoteIP(r), + Detail: fmt.Sprintf("updated user %d", id), + Success: true, + }) + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "id": updated.ID, + "username": updated.Username, + "email": updated.Email, + "role": updated.Role, + "active": updated.Active, + }) +} + +func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user id") + return + } + + if err := s.users.DeleteSafe(id); err != nil { + if err.Error() == "userstore: cannot delete last admin" { + writeError(w, http.StatusConflict, "cannot delete the last active admin") + return + } + writeError(w, http.StatusNotFound, err.Error()) + return + } + + sess := sessionFromCtx(r.Context()) + s.audlog.Log(audit.Entry{ + EventType: audit.EventUserMgmt, + Username: sess.Username, + IPAddress: remoteIP(r), + Detail: fmt.Sprintf("deleted user %d", id), + Success: true, + }) + + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") fromFilter := r.URL.Query().Get("from") diff --git a/internal/userstore/userstore.go b/internal/userstore/userstore.go index 068e23d..5769497 100644 --- a/internal/userstore/userstore.go +++ b/internal/userstore/userstore.go @@ -15,6 +15,8 @@ const ( RoleUser = "user" RoleAdmin = "admin" RoleAuditor = "auditor" + + bcryptCost = 12 ) // User represents a user account in the system. @@ -69,19 +71,27 @@ func New(dsn string) (*Store, error) { func (s *Store) initSchema(ctx context.Context) error { _, err := s.pool.Exec(ctx, ` CREATE TABLE IF NOT EXISTS users ( - id BIGSERIAL PRIMARY KEY, - username VARCHAR(100) UNIQUE NOT NULL, - email VARCHAR(255) UNIQUE NOT NULL, - password_hash VARCHAR(255) NOT NULL DEFAULT '', - role VARCHAR(20) NOT NULL CHECK (role IN ('user','auditor','admin')), - source VARCHAR(20) NOT NULL DEFAULT 'local', - active BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + id BIGSERIAL PRIMARY KEY, + username VARCHAR(100) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL DEFAULT '', + role VARCHAR(20) NOT NULL CHECK (role IN ('user','auditor','admin')), + source VARCHAR(20) NOT NULL DEFAULT 'local', + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ ); + ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ; CREATE TABLE IF NOT EXISTS token_blacklist ( jti VARCHAR(255) PRIMARY KEY, expires_at TIMESTAMPTZ NOT NULL ); + CREATE TABLE IF NOT EXISTS login_attempts ( + username VARCHAR(100) NOT NULL, + ip VARCHAR(45) NOT NULL, + attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_login_attempts_username_time ON login_attempts (username, attempted_at); `) return err } @@ -94,7 +104,7 @@ func (s *Store) Close() error { // Create inserts a new local user with a bcrypt-hashed password. func (s *Store) Create(req CreateUserRequest) (*User, error) { - hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost) if err != nil { return nil, fmt.Errorf("userstore: bcrypt: %w", err) } @@ -182,7 +192,7 @@ func (s *Store) Update(id int64, req UpdateUserRequest) (*User, error) { } } if req.Password != nil { - hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) + hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcryptCost) if err != nil { return nil, fmt.Errorf("userstore: bcrypt: %w", err) } @@ -257,6 +267,62 @@ func (s *Store) IsBlacklisted(jti string) (bool, error) { return count > 0, err } +// UpdateLastLogin sets last_login_at to now for the given user. +func (s *Store) UpdateLastLogin(id int64) error { + ctx := context.Background() + _, err := s.pool.Exec(ctx, `UPDATE users SET last_login_at = NOW() WHERE id = $1`, id) + return err +} + +// RecordLoginAttempt inserts a failed login attempt record. +func (s *Store) RecordLoginAttempt(username, ip string) error { + ctx := context.Background() + _, err := s.pool.Exec(ctx, + `INSERT INTO login_attempts (username, ip, attempted_at) VALUES ($1, $2, NOW())`, + username, ip, + ) + return err +} + +// CountRecentFailures returns the number of failed attempts for username in the last window. +func (s *Store) CountRecentFailures(username string, window time.Duration) (int, error) { + ctx := context.Background() + var count int + err := s.pool.QueryRow(ctx, + `SELECT COUNT(*) FROM login_attempts WHERE username = $1 AND attempted_at > NOW() - $2::interval`, + username, window.String(), + ).Scan(&count) + return count, err +} + +// AdminCount returns the number of active admin users. +func (s *Store) AdminCount() (int, error) { + ctx := context.Background() + var count int + err := s.pool.QueryRow(ctx, + `SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = true`, + ).Scan(&count) + return count, err +} + +// DeleteSafe removes a user but refuses if they are the last active admin. +func (s *Store) DeleteSafe(id int64) error { + user, err := s.GetByID(id) + if err != nil { + return err + } + if user.Role == RoleAdmin { + count, err := s.AdminCount() + if err != nil { + return fmt.Errorf("userstore: admin count: %w", err) + } + if count <= 1 { + return fmt.Errorf("userstore: cannot delete last admin") + } + } + return s.Delete(id) +} + // CleanExpiredTokens removes blacklist entries whose expiry has passed. func (s *Store) CleanExpiredTokens() error { ctx := context.Background()