diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/GroupAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/GroupAdminController.java index d9244f0e..045eb40e 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/GroupAdminController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/GroupAdminController.java @@ -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); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java index 0423521d..b31f8089 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java @@ -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 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 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 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 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(); } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java index 7e955e83..1e81a7d6 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java @@ -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); } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java index 66d4e231..7e1004ea 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java @@ -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 roles = result.roles(); if (!subject.startsWith("user:") && roles.isEmpty()) { roles = List.of("AGENT"); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java index ca4d9745..f8cf86ae 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java @@ -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) { diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java index 369584ae..d7a7a83d 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java @@ -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 ); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java index e508f1d3..65cb4eba 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java @@ -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 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 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 { diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresUserRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresUserRepository.java index c924fb79..eb6848b8 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresUserRepository.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresUserRepository.java @@ -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 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 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); } } diff --git a/cameleer3-server-app/src/main/resources/db/migration/V9__password_hardening.sql b/cameleer3-server-app/src/main/resources/db/migration/V9__password_hardening.sql new file mode 100644 index 00000000..feb395b8 --- /dev/null +++ b/cameleer3-server-app/src/main/resources/db/migration/V9__password_hardening.sql @@ -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; diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java index 17fb73e2..9ea2471b 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java @@ -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:}) * @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 roles) {} + record JwtValidationResult(String subject, String application, String environment, List roles, Instant issuedAt) { + /** Backwards-compatible constructor (issuedAt defaults to null). */ + public JwtValidationResult(String subject, String application, String environment, List roles) { + this(subject, application, environment, roles, null); + } + } /** * Creates a signed access JWT with the given subject, application, and roles. diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/PasswordPolicyValidator.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/PasswordPolicyValidator.java new file mode 100644 index 00000000..491f9213 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/PasswordPolicyValidator.java @@ -0,0 +1,73 @@ +package com.cameleer3.server.core.security; + +import java.util.ArrayList; +import java.util.List; + +/** + * Validates passwords against a configurable policy. + *

+ * Rules: + *

    + *
  • Minimum 12 characters
  • + *
  • Must contain at least 3 of 4 character classes: uppercase, lowercase, digit, special
  • + *
  • Must not match the username
  • + *
+ */ +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 validate(String password, String username) { + List 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; + } +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserInfo.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserInfo.java index 2a3f3613..884b5a8f 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserInfo.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserInfo.java @@ -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:}) - * @param provider authentication provider ({@code "local"}, {@code "oidc:"}) - * @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:}) + * @param provider authentication provider ({@code "local"}, {@code "oidc:"}) + * @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); + } +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java index 4f57dad6..f6c17802 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java @@ -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 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); }