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:
@@ -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")
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -69,19 +71,27 @@ func New(dsn string) (*Store, error) {
|
|||||||
func (s *Store) initSchema(ctx context.Context) error {
|
func (s *Store) initSchema(ctx context.Context) error {
|
||||||
_, err := s.pool.Exec(ctx, `
|
_, err := s.pool.Exec(ctx, `
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
username VARCHAR(100) UNIQUE NOT NULL,
|
username VARCHAR(100) UNIQUE NOT NULL,
|
||||||
email VARCHAR(255) UNIQUE NOT NULL,
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
password_hash VARCHAR(255) NOT NULL DEFAULT '',
|
password_hash VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user