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
+76 -10
View File
@@ -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()