diff --git a/src/main/java/com/ericbouchut/learndev/admin/AccountAdminService.java b/src/main/java/com/ericbouchut/learndev/admin/AccountAdminService.java index 2944358..5c2f1f2 100644 --- a/src/main/java/com/ericbouchut/learndev/admin/AccountAdminService.java +++ b/src/main/java/com/ericbouchut/learndev/admin/AccountAdminService.java @@ -1,6 +1,7 @@ package com.ericbouchut.learndev.admin; import com.ericbouchut.learndev.audit.AuditService; +import com.ericbouchut.learndev.auth.LoginAttemptListener; import com.ericbouchut.learndev.auth.RegistrationService; import com.ericbouchut.learndev.auth.dto.RegisterForm; import com.ericbouchut.learndev.user.entity.User; @@ -80,6 +81,15 @@ public void archive(User target, User actor, String ipAddress) { "Account " + target.getUserId() + " archived"); } + /** Unlock an account locked by failed logins and reset its counter. */ + @Transactional + public void unlock(User target, User actor, String ipAddress) { + LoginAttemptListener.unlock(target); + users.save(target); + audit.record("ACCOUNT_UNLOCKED", actor, ipAddress, true, + "Account " + target.getUserId() + " unlocked"); + } + /** Reactivate an archived account: the user can log in again. */ @Transactional public void reactivate(User target, User actor, String ipAddress) { diff --git a/src/main/java/com/ericbouchut/learndev/admin/AdminController.java b/src/main/java/com/ericbouchut/learndev/admin/AdminController.java index ed0ec0d..3e1ee87 100644 --- a/src/main/java/com/ericbouchut/learndev/admin/AdminController.java +++ b/src/main/java/com/ericbouchut/learndev/admin/AdminController.java @@ -132,6 +132,26 @@ public String archiveAccount( return "redirect:/admin/users?archived"; } + /** + * Unlock an account locked by failed logins (PRG with an + * {@code unlocked} flag). + * + * @param userId the account to unlock + * @param principal the logged-in admin + * @param request provides the client IP for the audit trail + * @return a redirect to the account list + */ + @PostMapping("/users/{userId}/unlock") + public String unlockAccount( + @PathVariable UUID userId, + Principal principal, + HttpServletRequest request + ) { + accounts.unlock(accounts.account(userId), + currentUser(principal), request.getRemoteAddr()); + return "redirect:/admin/users?unlocked"; + } + /** * Reactivate an account (PRG with a {@code reactivated} flag). * diff --git a/src/main/java/com/ericbouchut/learndev/auth/LoginAttemptListener.java b/src/main/java/com/ericbouchut/learndev/auth/LoginAttemptListener.java new file mode 100644 index 0000000..19eaa8f --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/auth/LoginAttemptListener.java @@ -0,0 +1,80 @@ +package com.ericbouchut.learndev.auth; + +import com.ericbouchut.learndev.audit.AuditService; +import com.ericbouchut.learndev.user.entity.User; +import com.ericbouchut.learndev.user.repository.UserRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.EventListener; +import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent; +import org.springframework.security.authentication.event.AuthenticationSuccessEvent; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; + +/** + * Account lockout: counts consecutive failed logins per account and locks + * the account at the configured threshold + * ({@code learndev.lockout.max-attempts}). A successful login resets the + * counter and stamps {@code last_login_at}. {@code is_locked} is already + * mapped to Spring Security's {@code accountLocked}, so a locked account is + * rejected at authentication time. Unlocking happens through the admin area + * or by completing a password reset (which proves control of the mailbox). + * Failures for unknown usernames are ignored: no row, no counter, and no + * behavior difference that could leak whether an account exists. + */ +@Component +public class LoginAttemptListener { + + private final UserRepository users; + private final AuditService audit; + private final int maxAttempts; + + public LoginAttemptListener( + UserRepository users, + AuditService audit, + @Value("${learndev.lockout.max-attempts:5}") int maxAttempts + ) { + this.users = users; + this.audit = audit; + this.maxAttempts = maxAttempts; + } + + /** Wrong password: count it, and lock the account at the threshold. */ + @EventListener + @Transactional + public void onFailure(AuthenticationFailureBadCredentialsEvent event) { + String username = event.getAuthentication().getName(); + users.findByUsername(username).ifPresent(user -> { + if (user.isLocked()) { + return; + } + user.setFailedLoginAttempts(user.getFailedLoginAttempts() + 1); + if (user.getFailedLoginAttempts() >= maxAttempts) { + user.setLocked(true); + audit.record("ACCOUNT_LOCKED", user, null, true, + "Locked after " + user.getFailedLoginAttempts() + + " failed login attempts"); + } + users.save(user); + }); + } + + /** Successful login: reset the counter and stamp the login time. */ + @EventListener + @Transactional + public void onSuccess(AuthenticationSuccessEvent event) { + String username = event.getAuthentication().getName(); + users.findByUsername(username).ifPresent(user -> { + user.setFailedLoginAttempts(0); + user.setLastLoginAt(OffsetDateTime.now()); + users.save(user); + }); + } + + /** Clear the lock and the counter (admin unlock, password reset). */ + public static void unlock(User user) { + user.setLocked(false); + user.setFailedLoginAttempts(0); + } +} diff --git a/src/main/java/com/ericbouchut/learndev/auth/PasswordResetService.java b/src/main/java/com/ericbouchut/learndev/auth/PasswordResetService.java index c0ad816..feccd2d 100644 --- a/src/main/java/com/ericbouchut/learndev/auth/PasswordResetService.java +++ b/src/main/java/com/ericbouchut/learndev/auth/PasswordResetService.java @@ -160,6 +160,9 @@ public boolean resetPassword(String rawToken, String newPassword, String ipAddre user.setPassword(passwordEncoder.encode(newPassword)); user.setPasswordChangedAt(OffsetDateTime.now()); + // Completing a reset proves control of the mailbox: clear any + // failed-login lock along with the counter. + LoginAttemptListener.unlock(user); token.setUsedAt(OffsetDateTime.now()); // Strict single active link: consuming one kills the others too. invalidateOutstandingTokens(user); diff --git a/src/main/resources/templates/admin/users.html b/src/main/resources/templates/admin/users.html index b5847c5..f007fe5 100644 --- a/src/main/resources/templates/admin/users.html +++ b/src/main/resources/templates/admin/users.html @@ -43,8 +43,13 @@