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