feat: last-ADMIN guard and password hardening (#87, #89)
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m57s
CI / docker (push) Successful in 1m48s
CI / deploy (push) Successful in 51s
CI / deploy-feature (push) Has been skipped

- Prevent removal of last ADMIN role via role unassign, user delete,
  or group role removal (returns 409 Conflict)
- Add password policy: min 12 chars, 3/4 character classes, no username
- Add brute-force protection: 5 attempts then 15min lockout, IP rate limit
- Add token revocation on password change via token_revoked_before column
- V9 migration adds failed_login_attempts, locked_until, token_revoked_before

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-09 08:58:03 +02:00
parent 3bf470f83f
commit 827ba3c798
13 changed files with 245 additions and 17 deletions

View File

@@ -1,5 +1,6 @@
package com.cameleer3.server.core.security;
import java.time.Instant;
import java.util.List;
/**
@@ -16,9 +17,16 @@ public interface JwtService {
*
* @param subject the {@code sub} claim (agent ID or {@code user:<username>})
* @param application the {@code group} claim (application name)
* @param environment the {@code env} claim
* @param roles the {@code roles} claim (e.g. {@code ["AGENT"]}, {@code ["ADMIN"]})
* @param issuedAt the {@code iat} claim (token issue time)
*/
record JwtValidationResult(String subject, String application, String environment, List<String> roles) {}
record JwtValidationResult(String subject, String application, String environment, List<String> roles, Instant issuedAt) {
/** Backwards-compatible constructor (issuedAt defaults to null). */
public JwtValidationResult(String subject, String application, String environment, List<String> roles) {
this(subject, application, environment, roles, null);
}
}
/**
* Creates a signed access JWT with the given subject, application, and roles.

View File

@@ -0,0 +1,73 @@
package com.cameleer3.server.core.security;
import java.util.ArrayList;
import java.util.List;
/**
* Validates passwords against a configurable policy.
* <p>
* Rules:
* <ul>
* <li>Minimum 12 characters</li>
* <li>Must contain at least 3 of 4 character classes: uppercase, lowercase, digit, special</li>
* <li>Must not match the username</li>
* </ul>
*/
public final class PasswordPolicyValidator {
private static final int MIN_LENGTH = 12;
private static final int MIN_CLASSES = 3;
private PasswordPolicyValidator() {}
/**
* Validates a password against the policy.
*
* @param password the password to validate
* @param username the username (to reject passwords matching it)
* @return empty list if valid, otherwise a list of violation messages
*/
public static List<String> validate(String password, String username) {
List<String> violations = new ArrayList<>();
if (password == null || password.length() < MIN_LENGTH) {
violations.add("Password must be at least " + MIN_LENGTH + " characters");
}
if (password != null && username != null && password.equalsIgnoreCase(username)) {
violations.add("Password must not match the username");
}
if (password != null) {
int classes = countCharacterClasses(password);
if (classes < MIN_CLASSES) {
violations.add("Password must contain at least " + MIN_CLASSES
+ " of 4 character classes: uppercase, lowercase, digit, special");
}
}
return violations;
}
private static int countCharacterClasses(String password) {
boolean hasUpper = false;
boolean hasLower = false;
boolean hasDigit = false;
boolean hasSpecial = false;
for (int i = 0; i < password.length(); i++) {
char c = password.charAt(i);
if (Character.isUpperCase(c)) hasUpper = true;
else if (Character.isLowerCase(c)) hasLower = true;
else if (Character.isDigit(c)) hasDigit = true;
else hasSpecial = true;
}
int count = 0;
if (hasUpper) count++;
if (hasLower) count++;
if (hasDigit) count++;
if (hasSpecial) count++;
return count;
}
}

View File

@@ -5,16 +5,29 @@ import java.time.Instant;
/**
* Represents a persisted user in the system.
*
* @param userId unique identifier (e.g. OIDC {@code sub} or {@code user:<username>})
* @param provider authentication provider ({@code "local"}, {@code "oidc:<issuer-host>"})
* @param email user email (may be empty)
* @param displayName display name (may be empty)
* @param createdAt first creation timestamp
* @param userId unique identifier (e.g. OIDC {@code sub} or {@code user:<username>})
* @param provider authentication provider ({@code "local"}, {@code "oidc:<issuer-host>"})
* @param email user email (may be empty)
* @param displayName display name (may be empty)
* @param createdAt first creation timestamp
* @param failedLoginAttempts number of consecutive failed login attempts
* @param lockedUntil account locked until this instant (null if not locked)
* @param tokenRevokedBefore tokens issued before this instant are rejected (null if no revocation)
*/
public record UserInfo(
String userId,
String provider,
String email,
String displayName,
Instant createdAt
) {}
Instant createdAt,
int failedLoginAttempts,
Instant lockedUntil,
Instant tokenRevokedBefore
) {
/**
* Convenience constructor for backwards compatibility (new fields default to safe values).
*/
public UserInfo(String userId, String provider, String email, String displayName, Instant createdAt) {
this(userId, provider, email, displayName, createdAt, 0, null, null);
}
}

View File

@@ -1,5 +1,6 @@
package com.cameleer3.server.core.security;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@@ -19,4 +20,16 @@ public interface UserRepository {
void setPassword(String userId, String passwordHash);
Optional<String> getPasswordHash(String userId);
/** Increment failed login attempts; lock the account when >= 5 failures. */
void recordFailedLogin(String userId);
/** Reset failed login counter (call on successful login). */
void clearFailedLogins(String userId);
/** Check whether the account is currently locked. */
boolean isLocked(String userId);
/** Mark all tokens issued before {@code timestamp} as revoked for the given user. */
void revokeTokensBefore(String userId, Instant timestamp);
}