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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user