fix(PROJ-1): bcrypt Cost 12, Rate-Limiting, last_login_at, User Update/Delete

- 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 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-14 23:04:45 +01:00
parent 3c722d0987
commit a94b1d3e52
2 changed files with 175 additions and 10 deletions
+99
View File
@@ -88,6 +88,8 @@ func (s *Server) routes() {
s.mux.HandleFunc("POST /api/auth/logout", s.authMiddleware(s.handleLogout)) 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("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("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/search", s.authMiddleware(s.handleSearch))
s.mux.HandleFunc("GET /api/audit", s.authMiddleware(s.requireRole(userstore.RoleAuditor, s.handleAuditLog))) 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))) 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"}) 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) { func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Username string `json:"username"` Username string `json:"username"`
@@ -134,8 +141,23 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return 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) token, user, err := s.authMgr.Login(req.Username, req.Password)
if err != nil { if err != nil {
_ = s.users.RecordLoginAttempt(req.Username, remoteIP(r))
s.audlog.Log(audit.Entry{ s.audlog.Log(audit.Entry{
EventType: audit.EventLogin, EventType: audit.EventLogin,
Username: req.Username, Username: req.Username,
@@ -147,6 +169,8 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
_ = s.users.UpdateLastLogin(user.ID)
s.audlog.Log(audit.Entry{ s.audlog.Log(audit.Entry{
EventType: audit.EventLogin, EventType: audit.EventLogin,
Username: user.Username, 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) { func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q") q := r.URL.Query().Get("q")
fromFilter := r.URL.Query().Get("from") fromFilter := r.URL.Query().Get("from")
+69 -3
View File
@@ -15,6 +15,8 @@ const (
RoleUser = "user" RoleUser = "user"
RoleAdmin = "admin" RoleAdmin = "admin"
RoleAuditor = "auditor" RoleAuditor = "auditor"
bcryptCost = 12
) )
// User represents a user account in the system. // User represents a user account in the system.
@@ -76,12 +78,20 @@ func (s *Store) initSchema(ctx context.Context) error {
role VARCHAR(20) NOT NULL CHECK (role IN ('user','auditor','admin')), role VARCHAR(20) NOT NULL CHECK (role IN ('user','auditor','admin')),
source VARCHAR(20) NOT NULL DEFAULT 'local', source VARCHAR(20) NOT NULL DEFAULT 'local',
active BOOLEAN NOT NULL DEFAULT true, active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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 ( CREATE TABLE IF NOT EXISTS token_blacklist (
jti VARCHAR(255) PRIMARY KEY, jti VARCHAR(255) PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL 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 return err
} }
@@ -94,7 +104,7 @@ func (s *Store) Close() error {
// Create inserts a new local user with a bcrypt-hashed password. // Create inserts a new local user with a bcrypt-hashed password.
func (s *Store) Create(req CreateUserRequest) (*User, error) { 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 { if err != nil {
return nil, fmt.Errorf("userstore: bcrypt: %w", err) 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 { if req.Password != nil {
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcryptCost)
if err != nil { if err != nil {
return nil, fmt.Errorf("userstore: bcrypt: %w", err) return nil, fmt.Errorf("userstore: bcrypt: %w", err)
} }
@@ -257,6 +267,62 @@ func (s *Store) IsBlacklisted(jti string) (bool, error) {
return count > 0, err 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. // CleanExpiredTokens removes blacklist entries whose expiry has passed.
func (s *Store) CleanExpiredTokens() error { func (s *Store) CleanExpiredTokens() error {
ctx := context.Background() ctx := context.Background()