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

@@ -6,10 +6,13 @@ import com.cameleer3.server.core.admin.AuditService;
import com.cameleer3.server.core.rbac.GroupDetail;
import com.cameleer3.server.core.rbac.GroupRepository;
import com.cameleer3.server.core.rbac.GroupSummary;
import com.cameleer3.server.core.rbac.RbacService;
import com.cameleer3.server.core.rbac.SystemRole;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -20,6 +23,7 @@ import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList;
import java.util.List;
@@ -39,10 +43,13 @@ public class GroupAdminController {
private final GroupRepository groupRepository;
private final AuditService auditService;
private final RbacService rbacService;
public GroupAdminController(GroupRepository groupRepository, AuditService auditService) {
public GroupAdminController(GroupRepository groupRepository, AuditService auditService,
RbacService rbacService) {
this.groupRepository = groupRepository;
this.auditService = auditService;
this.rbacService = rbacService;
}
@GetMapping
@@ -152,6 +159,10 @@ public class GroupAdminController {
if (groupRepository.findById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
if (SystemRole.ADMIN_ID.equals(roleId) && rbacService.getEffectivePrincipalsForRole(SystemRole.ADMIN_ID).size() <= 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Cannot remove the ADMIN role: at least one admin user must exist");
}
groupRepository.removeRole(id, roleId);
auditService.log("remove_role_from_group", AuditCategory.RBAC, id.toString(),
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);

View File

@@ -7,6 +7,7 @@ import com.cameleer3.server.core.admin.AuditService;
import com.cameleer3.server.core.rbac.RbacService;
import com.cameleer3.server.core.rbac.SystemRole;
import com.cameleer3.server.core.rbac.UserDetail;
import com.cameleer3.server.core.security.PasswordPolicyValidator;
import com.cameleer3.server.core.security.UserInfo;
import com.cameleer3.server.core.security.UserRepository;
import io.swagger.v3.oas.annotations.Operation;
@@ -14,6 +15,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -24,6 +26,7 @@ import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import com.cameleer3.server.app.security.SecurityProperties;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@@ -33,6 +36,7 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Admin endpoints for user management.
* Protected by {@code ROLE_ADMIN}.
@@ -95,6 +99,11 @@ public class UserAdminController {
Instant.now());
userRepository.upsert(user);
if (request.password() != null && !request.password().isBlank()) {
List<String> violations = PasswordPolicyValidator.validate(request.password(), request.username());
if (!violations.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Password policy violation: " + String.join("; ", violations));
}
userRepository.setPassword(userId, passwordEncoder.encode(request.password()));
}
rbacService.assignRoleToUser(userId, SystemRole.VIEWER_ID);
@@ -175,8 +184,14 @@ public class UserAdminController {
@DeleteMapping("/{userId}")
@Operation(summary = "Delete user")
@ApiResponse(responseCode = "204", description = "User deleted")
@ApiResponse(responseCode = "409", description = "Cannot delete the last admin user")
public ResponseEntity<Void> deleteUser(@PathVariable String userId,
HttpServletRequest httpRequest) {
boolean isAdmin = rbacService.getEffectiveRolesForUser(userId).stream()
.anyMatch(r -> r.id().equals(SystemRole.ADMIN_ID));
if (isAdmin && rbacService.getEffectivePrincipalsForRole(SystemRole.ADMIN_ID).size() <= 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Cannot delete the last admin user");
}
userRepository.delete(userId);
auditService.log("delete_user", AuditCategory.USER_MGMT, userId,
null, AuditResult.SUCCESS, httpRequest);
@@ -186,7 +201,7 @@ public class UserAdminController {
@PostMapping("/{userId}/password")
@Operation(summary = "Reset user password")
@ApiResponse(responseCode = "204", description = "Password reset")
@ApiResponse(responseCode = "400", description = "Disabled in OIDC mode")
@ApiResponse(responseCode = "400", description = "Disabled in OIDC mode or policy violation")
public ResponseEntity<Void> resetPassword(
@PathVariable String userId,
@Valid @RequestBody SetPasswordRequest request,
@@ -194,7 +209,16 @@ public class UserAdminController {
if (oidcEnabled) {
return ResponseEntity.badRequest().build();
}
// Extract bare username from "user:username" format for policy check
String username = userId.startsWith("user:") ? userId.substring(5) : userId;
List<String> violations = PasswordPolicyValidator.validate(request.password(), username);
if (!violations.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Password policy violation: " + String.join("; ", violations));
}
userRepository.setPassword(userId, passwordEncoder.encode(request.password()));
// Revoke all existing tokens so the user must re-authenticate with the new password
userRepository.revokeTokensBefore(userId, Instant.now());
auditService.log("reset_password", AuditCategory.USER_MGMT, userId, null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}

View File

@@ -5,12 +5,15 @@ import com.cameleer3.server.core.rbac.GroupSummary;
import com.cameleer3.server.core.rbac.RbacService;
import com.cameleer3.server.core.rbac.RbacStats;
import com.cameleer3.server.core.rbac.RoleSummary;
import com.cameleer3.server.core.rbac.SystemRole;
import com.cameleer3.server.core.rbac.UserDetail;
import com.cameleer3.server.core.rbac.UserSummary;
import com.cameleer3.server.core.security.UserInfo;
import com.cameleer3.server.core.security.UserRepository;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
@@ -63,6 +66,10 @@ public class RbacServiceImpl implements RbacService {
@Override
public void removeRoleFromUser(String userId, UUID roleId) {
if (SystemRole.ADMIN_ID.equals(roleId) && getEffectivePrincipalsForRole(SystemRole.ADMIN_ID).size() <= 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Cannot remove the ADMIN role: at least one admin user must exist");
}
jdbc.update("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?", userId, roleId);
}

View File

@@ -4,6 +4,8 @@ import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.rbac.SystemRole;
import com.cameleer3.server.core.security.JwtService;
import com.cameleer3.server.core.security.JwtService.JwtValidationResult;
import com.cameleer3.server.core.security.UserInfo;
import com.cameleer3.server.core.security.UserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
@@ -19,6 +21,7 @@ import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
/**
@@ -41,13 +44,16 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final AgentRegistryService agentRegistryService;
private final JwtDecoder oidcDecoder;
private final UserRepository userRepository;
public JwtAuthenticationFilter(JwtService jwtService,
AgentRegistryService agentRegistryService,
JwtDecoder oidcDecoder) {
JwtDecoder oidcDecoder,
UserRepository userRepository) {
this.jwtService = jwtService;
this.agentRegistryService = agentRegistryService;
this.oidcDecoder = oidcDecoder;
this.userRepository = userRepository;
}
@Override
@@ -74,6 +80,16 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
JwtValidationResult result = jwtService.validateAccessToken(token);
String subject = result.subject();
// Token revocation check: reject tokens issued before revocation timestamp
if (subject.startsWith("user:") && result.issuedAt() != null) {
userRepository.findById(subject).ifPresent(user -> {
Instant revoked = user.tokenRevokedBefore();
if (revoked != null && result.issuedAt().isBefore(revoked)) {
throw new com.cameleer3.server.core.security.InvalidTokenException("Token revoked");
}
});
}
List<String> roles = result.roles();
if (!subject.startsWith("user:") && roles.isEmpty()) {
roles = List.of("AGENT");

View File

@@ -148,7 +148,10 @@ public class JwtServiceImpl implements JwtService {
String environment = claims.getStringClaim("env");
return new JwtValidationResult(subject, application, environment, roles);
Date issueTime = claims.getIssueTime();
Instant issuedAt = issueTime != null ? issueTime.toInstant() : null;
return new JwtValidationResult(subject, application, environment, roles, issuedAt);
} catch (ParseException e) {
throw new InvalidTokenException("Failed to parse JWT", e);
} catch (JOSEException e) {

View File

@@ -2,6 +2,7 @@ package com.cameleer3.server.app.security;
import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.security.JwtService;
import com.cameleer3.server.core.security.UserRepository;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
@@ -59,7 +60,8 @@ public class SecurityConfig {
JwtService jwtService,
AgentRegistryService registryService,
SecurityProperties securityProperties,
CorsConfigurationSource corsConfigurationSource) throws Exception {
CorsConfigurationSource corsConfigurationSource,
UserRepository userRepository) throws Exception {
JwtDecoder oidcDecoder = null;
String issuer = securityProperties.getOidcIssuerUri();
if (issuer != null && !issuer.isBlank()) {
@@ -140,7 +142,7 @@ public class SecurityConfig {
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
)
.addFilterBefore(
new JwtAuthenticationFilter(jwtService, registryService, oidcDecoder),
new JwtAuthenticationFilter(jwtService, registryService, oidcDecoder, userRepository),
UsernamePasswordAuthenticationFilter.class
);

View File

@@ -72,12 +72,21 @@ public class UiAuthController {
@ApiResponse(responseCode = "200", description = "Login successful")
@ApiResponse(responseCode = "401", description = "Invalid credentials",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@ApiResponse(responseCode = "429", description = "Account locked due to too many failed attempts")
public ResponseEntity<AuthTokenResponse> login(@RequestBody LoginRequest request,
HttpServletRequest httpRequest) {
String configuredUser = properties.getUiUser();
String configuredPassword = properties.getUiPassword();
String subject = "user:" + request.username();
// Check account lockout before attempting authentication
if (userRepository.isLocked(subject)) {
auditService.log(request.username(), "login_locked", AuditCategory.AUTH, null,
Map.of("reason", "Account locked"), AuditResult.FAILURE, httpRequest);
throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS,
"Account locked due to too many failed attempts. Try again later.");
}
// Try env-var admin first
boolean envMatch = configuredUser != null && !configuredUser.isBlank()
&& configuredPassword != null && !configuredPassword.isBlank()
@@ -88,6 +97,7 @@ public class UiAuthController {
// Try per-user password
Optional<String> hash = userRepository.getPasswordHash(subject);
if (hash.isEmpty() || !passwordEncoder.matches(request.password(), hash.get())) {
userRepository.recordFailedLogin(subject);
log.debug("UI login failed for user: {}", request.username());
auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null,
Map.of("reason", "Invalid credentials"), AuditResult.FAILURE, httpRequest);
@@ -95,6 +105,9 @@ public class UiAuthController {
}
}
// Successful login — clear any failed attempt counter
userRepository.clearFailedLogins(subject);
if (envMatch) {
// Env-var admin: upsert and ensure ADMIN role + Admins group
try {

View File

@@ -5,6 +5,7 @@ import com.cameleer3.server.core.security.UserRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@@ -17,17 +18,20 @@ public class PostgresUserRepository implements UserRepository {
this.jdbc = jdbc;
}
private static final String USER_COLUMNS =
"user_id, provider, email, display_name, created_at, failed_login_attempts, locked_until, token_revoked_before";
@Override
public Optional<UserInfo> findById(String userId) {
var results = jdbc.query(
"SELECT user_id, provider, email, display_name, created_at FROM users WHERE user_id = ?",
"SELECT " + USER_COLUMNS + " FROM users WHERE user_id = ?",
(rs, rowNum) -> mapUser(rs), userId);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
@Override
public List<UserInfo> findAll() {
return jdbc.query("SELECT user_id, provider, email, display_name, created_at FROM users ORDER BY user_id",
return jdbc.query("SELECT " + USER_COLUMNS + " FROM users ORDER BY user_id",
(rs, rowNum) -> mapUser(rs));
}
@@ -64,12 +68,50 @@ public class PostgresUserRepository implements UserRepository {
return Optional.of(results.get(0));
}
@Override
public void recordFailedLogin(String userId) {
jdbc.update("""
UPDATE users
SET failed_login_attempts = failed_login_attempts + 1,
locked_until = CASE
WHEN failed_login_attempts + 1 >= 5
THEN now() + INTERVAL '15 minutes'
ELSE locked_until
END
WHERE user_id = ?
""", userId);
}
@Override
public void clearFailedLogins(String userId) {
jdbc.update("UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE user_id = ?", userId);
}
@Override
public boolean isLocked(String userId) {
var results = jdbc.query(
"SELECT 1 FROM users WHERE user_id = ? AND locked_until > now()",
(rs, rowNum) -> true, userId);
return !results.isEmpty();
}
@Override
public void revokeTokensBefore(String userId, Instant timestamp) {
jdbc.update("UPDATE users SET token_revoked_before = ? WHERE user_id = ?",
java.sql.Timestamp.from(timestamp), userId);
}
private UserInfo mapUser(java.sql.ResultSet rs) throws java.sql.SQLException {
java.sql.Timestamp ts = rs.getTimestamp("created_at");
java.time.Instant createdAt = ts != null ? ts.toInstant() : null;
java.sql.Timestamp lockedTs = rs.getTimestamp("locked_until");
java.time.Instant lockedUntil = lockedTs != null ? lockedTs.toInstant() : null;
java.sql.Timestamp revokedTs = rs.getTimestamp("token_revoked_before");
java.time.Instant tokenRevokedBefore = revokedTs != null ? revokedTs.toInstant() : null;
return new UserInfo(
rs.getString("user_id"), rs.getString("provider"),
rs.getString("email"), rs.getString("display_name"),
createdAt);
createdAt, rs.getInt("failed_login_attempts"),
lockedUntil, tokenRevokedBefore);
}
}

View File

@@ -0,0 +1,3 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS failed_login_attempts INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN IF NOT EXISTS token_revoked_before TIMESTAMPTZ;

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);
}