- 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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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