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.GroupDetail;
import com.cameleer3.server.core.rbac.GroupRepository; import com.cameleer3.server.core.rbac.GroupRepository;
import com.cameleer3.server.core.rbac.GroupSummary; 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.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping; 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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -39,10 +43,13 @@ public class GroupAdminController {
private final GroupRepository groupRepository; private final GroupRepository groupRepository;
private final AuditService auditService; 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.groupRepository = groupRepository;
this.auditService = auditService; this.auditService = auditService;
this.rbacService = rbacService;
} }
@GetMapping @GetMapping
@@ -152,6 +159,10 @@ public class GroupAdminController {
if (groupRepository.findById(id).isEmpty()) { if (groupRepository.findById(id).isEmpty()) {
return ResponseEntity.notFound().build(); 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); groupRepository.removeRole(id, roleId);
auditService.log("remove_role_from_group", AuditCategory.RBAC, id.toString(), auditService.log("remove_role_from_group", AuditCategory.RBAC, id.toString(),
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest); 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.RbacService;
import com.cameleer3.server.core.rbac.SystemRole; import com.cameleer3.server.core.rbac.SystemRole;
import com.cameleer3.server.core.rbac.UserDetail; 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.UserInfo;
import com.cameleer3.server.core.security.UserRepository; import com.cameleer3.server.core.security.UserRepository;
import io.swagger.v3.oas.annotations.Operation; 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 io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping; 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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import com.cameleer3.server.app.security.SecurityProperties; import com.cameleer3.server.app.security.SecurityProperties;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@@ -33,6 +36,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
/** /**
* Admin endpoints for user management. * Admin endpoints for user management.
* Protected by {@code ROLE_ADMIN}. * Protected by {@code ROLE_ADMIN}.
@@ -95,6 +99,11 @@ public class UserAdminController {
Instant.now()); Instant.now());
userRepository.upsert(user); userRepository.upsert(user);
if (request.password() != null && !request.password().isBlank()) { 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())); userRepository.setPassword(userId, passwordEncoder.encode(request.password()));
} }
rbacService.assignRoleToUser(userId, SystemRole.VIEWER_ID); rbacService.assignRoleToUser(userId, SystemRole.VIEWER_ID);
@@ -175,8 +184,14 @@ public class UserAdminController {
@DeleteMapping("/{userId}") @DeleteMapping("/{userId}")
@Operation(summary = "Delete user") @Operation(summary = "Delete user")
@ApiResponse(responseCode = "204", description = "User deleted") @ApiResponse(responseCode = "204", description = "User deleted")
@ApiResponse(responseCode = "409", description = "Cannot delete the last admin user")
public ResponseEntity<Void> deleteUser(@PathVariable String userId, public ResponseEntity<Void> deleteUser(@PathVariable String userId,
HttpServletRequest httpRequest) { 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); userRepository.delete(userId);
auditService.log("delete_user", AuditCategory.USER_MGMT, userId, auditService.log("delete_user", AuditCategory.USER_MGMT, userId,
null, AuditResult.SUCCESS, httpRequest); null, AuditResult.SUCCESS, httpRequest);
@@ -186,7 +201,7 @@ public class UserAdminController {
@PostMapping("/{userId}/password") @PostMapping("/{userId}/password")
@Operation(summary = "Reset user password") @Operation(summary = "Reset user password")
@ApiResponse(responseCode = "204", description = "Password reset") @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( public ResponseEntity<Void> resetPassword(
@PathVariable String userId, @PathVariable String userId,
@Valid @RequestBody SetPasswordRequest request, @Valid @RequestBody SetPasswordRequest request,
@@ -194,7 +209,16 @@ public class UserAdminController {
if (oidcEnabled) { if (oidcEnabled) {
return ResponseEntity.badRequest().build(); 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())); 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); auditService.log("reset_password", AuditCategory.USER_MGMT, userId, null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build(); 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.RbacService;
import com.cameleer3.server.core.rbac.RbacStats; import com.cameleer3.server.core.rbac.RbacStats;
import com.cameleer3.server.core.rbac.RoleSummary; 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.UserDetail;
import com.cameleer3.server.core.rbac.UserSummary; import com.cameleer3.server.core.rbac.UserSummary;
import com.cameleer3.server.core.security.UserInfo; import com.cameleer3.server.core.security.UserInfo;
import com.cameleer3.server.core.security.UserRepository; import com.cameleer3.server.core.security.UserRepository;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.*; import java.util.*;
@@ -63,6 +66,10 @@ public class RbacServiceImpl implements RbacService {
@Override @Override
public void removeRoleFromUser(String userId, UUID roleId) { 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); 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.rbac.SystemRole;
import com.cameleer3.server.core.security.JwtService; import com.cameleer3.server.core.security.JwtService;
import com.cameleer3.server.core.security.JwtService.JwtValidationResult; 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.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@@ -19,6 +21,7 @@ import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.time.Instant;
import java.util.List; import java.util.List;
/** /**
@@ -41,13 +44,16 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService; private final JwtService jwtService;
private final AgentRegistryService agentRegistryService; private final AgentRegistryService agentRegistryService;
private final JwtDecoder oidcDecoder; private final JwtDecoder oidcDecoder;
private final UserRepository userRepository;
public JwtAuthenticationFilter(JwtService jwtService, public JwtAuthenticationFilter(JwtService jwtService,
AgentRegistryService agentRegistryService, AgentRegistryService agentRegistryService,
JwtDecoder oidcDecoder) { JwtDecoder oidcDecoder,
UserRepository userRepository) {
this.jwtService = jwtService; this.jwtService = jwtService;
this.agentRegistryService = agentRegistryService; this.agentRegistryService = agentRegistryService;
this.oidcDecoder = oidcDecoder; this.oidcDecoder = oidcDecoder;
this.userRepository = userRepository;
} }
@Override @Override
@@ -74,6 +80,16 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
JwtValidationResult result = jwtService.validateAccessToken(token); JwtValidationResult result = jwtService.validateAccessToken(token);
String subject = result.subject(); 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(); List<String> roles = result.roles();
if (!subject.startsWith("user:") && roles.isEmpty()) { if (!subject.startsWith("user:") && roles.isEmpty()) {
roles = List.of("AGENT"); roles = List.of("AGENT");

View File

@@ -148,7 +148,10 @@ public class JwtServiceImpl implements JwtService {
String environment = claims.getStringClaim("env"); 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) { } catch (ParseException e) {
throw new InvalidTokenException("Failed to parse JWT", e); throw new InvalidTokenException("Failed to parse JWT", e);
} catch (JOSEException 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.agent.AgentRegistryService;
import com.cameleer3.server.core.security.JwtService; 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.jwk.source.JWKSource;
import com.nimbusds.jose.proc.JWSVerificationKeySelector; import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext; import com.nimbusds.jose.proc.SecurityContext;
@@ -59,7 +60,8 @@ public class SecurityConfig {
JwtService jwtService, JwtService jwtService,
AgentRegistryService registryService, AgentRegistryService registryService,
SecurityProperties securityProperties, SecurityProperties securityProperties,
CorsConfigurationSource corsConfigurationSource) throws Exception { CorsConfigurationSource corsConfigurationSource,
UserRepository userRepository) throws Exception {
JwtDecoder oidcDecoder = null; JwtDecoder oidcDecoder = null;
String issuer = securityProperties.getOidcIssuerUri(); String issuer = securityProperties.getOidcIssuerUri();
if (issuer != null && !issuer.isBlank()) { if (issuer != null && !issuer.isBlank()) {
@@ -140,7 +142,7 @@ public class SecurityConfig {
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
) )
.addFilterBefore( .addFilterBefore(
new JwtAuthenticationFilter(jwtService, registryService, oidcDecoder), new JwtAuthenticationFilter(jwtService, registryService, oidcDecoder, userRepository),
UsernamePasswordAuthenticationFilter.class UsernamePasswordAuthenticationFilter.class
); );

View File

@@ -72,12 +72,21 @@ public class UiAuthController {
@ApiResponse(responseCode = "200", description = "Login successful") @ApiResponse(responseCode = "200", description = "Login successful")
@ApiResponse(responseCode = "401", description = "Invalid credentials", @ApiResponse(responseCode = "401", description = "Invalid credentials",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))) 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, public ResponseEntity<AuthTokenResponse> login(@RequestBody LoginRequest request,
HttpServletRequest httpRequest) { HttpServletRequest httpRequest) {
String configuredUser = properties.getUiUser(); String configuredUser = properties.getUiUser();
String configuredPassword = properties.getUiPassword(); String configuredPassword = properties.getUiPassword();
String subject = "user:" + request.username(); 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 // Try env-var admin first
boolean envMatch = configuredUser != null && !configuredUser.isBlank() boolean envMatch = configuredUser != null && !configuredUser.isBlank()
&& configuredPassword != null && !configuredPassword.isBlank() && configuredPassword != null && !configuredPassword.isBlank()
@@ -88,6 +97,7 @@ public class UiAuthController {
// Try per-user password // Try per-user password
Optional<String> hash = userRepository.getPasswordHash(subject); Optional<String> hash = userRepository.getPasswordHash(subject);
if (hash.isEmpty() || !passwordEncoder.matches(request.password(), hash.get())) { if (hash.isEmpty() || !passwordEncoder.matches(request.password(), hash.get())) {
userRepository.recordFailedLogin(subject);
log.debug("UI login failed for user: {}", request.username()); log.debug("UI login failed for user: {}", request.username());
auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null, auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null,
Map.of("reason", "Invalid credentials"), AuditResult.FAILURE, httpRequest); 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) { if (envMatch) {
// Env-var admin: upsert and ensure ADMIN role + Admins group // Env-var admin: upsert and ensure ADMIN role + Admins group
try { try {

View File

@@ -5,6 +5,7 @@ import com.cameleer3.server.core.security.UserRepository;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -17,17 +18,20 @@ public class PostgresUserRepository implements UserRepository {
this.jdbc = jdbc; 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 @Override
public Optional<UserInfo> findById(String userId) { public Optional<UserInfo> findById(String userId) {
var results = jdbc.query( 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); (rs, rowNum) -> mapUser(rs), userId);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
} }
@Override @Override
public List<UserInfo> findAll() { 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)); (rs, rowNum) -> mapUser(rs));
} }
@@ -64,12 +68,50 @@ public class PostgresUserRepository implements UserRepository {
return Optional.of(results.get(0)); 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 { private UserInfo mapUser(java.sql.ResultSet rs) throws java.sql.SQLException {
java.sql.Timestamp ts = rs.getTimestamp("created_at"); java.sql.Timestamp ts = rs.getTimestamp("created_at");
java.time.Instant createdAt = ts != null ? ts.toInstant() : null; 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( return new UserInfo(
rs.getString("user_id"), rs.getString("provider"), rs.getString("user_id"), rs.getString("provider"),
rs.getString("email"), rs.getString("display_name"), 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; package com.cameleer3.server.core.security;
import java.time.Instant;
import java.util.List; import java.util.List;
/** /**
@@ -16,9 +17,16 @@ public interface JwtService {
* *
* @param subject the {@code sub} claim (agent ID or {@code user:<username>}) * @param subject the {@code sub} claim (agent ID or {@code user:<username>})
* @param application the {@code group} claim (application name) * @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 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. * 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. * Represents a persisted user in the system.
* *
* @param userId unique identifier (e.g. OIDC {@code sub} or {@code user:<username>}) * @param userId unique identifier (e.g. OIDC {@code sub} or {@code user:<username>})
* @param provider authentication provider ({@code "local"}, {@code "oidc:<issuer-host>"}) * @param provider authentication provider ({@code "local"}, {@code "oidc:<issuer-host>"})
* @param email user email (may be empty) * @param email user email (may be empty)
* @param displayName display name (may be empty) * @param displayName display name (may be empty)
* @param createdAt first creation timestamp * @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( public record UserInfo(
String userId, String userId,
String provider, String provider,
String email, String email,
String displayName, 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; package com.cameleer3.server.core.security;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -19,4 +20,16 @@ public interface UserRepository {
void setPassword(String userId, String passwordHash); void setPassword(String userId, String passwordHash);
Optional<String> getPasswordHash(String userId); 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);
} }