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 @@

Accounts

username email STUDENT - Active + Active +
+ +
Named with the {@code Test} suffix (not {@code IT}) so Surefire runs it + * as part of {@code mvn test}; this project does not use the Failsafe plugin. + */ +@SpringBootTest(properties = { + // This feature does not use MongoDB; keep the test context Postgres-only. + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration", + // A low threshold keeps the test readable; production uses 5. + "learndev.lockout.max-attempts=3" +}) +@AutoConfigureMockMvc +class AccountLockoutFlowTest extends AbstractPostgresIT { + + @Autowired + MockMvc mvc; + + @Autowired + UserRepository userRepository; + + private User registeredUser(String username) throws Exception { + if (userRepository.findByUsername(username).isEmpty()) { + mvc.perform(post("/auth/register").with(csrf()) + .param("username", username) + .param("email", username + "@example.com") + .param("password", "secret12")) + .andExpect(status().is3xxRedirection()); + } + return userRepository.findByUsername(username).orElseThrow(); + } + + @Test + void repeated_failures_lock_the_account_and_the_admin_unlocks_it() throws Exception { + User victim = registeredUser("clumsy"); + registeredUser("boss"); + + // Two wrong passwords: counted, not locked yet. + for (int i = 0; i < 2; i++) { + mvc.perform(formLogin("/auth/login").user("clumsy").password("wrong")) + .andExpect(unauthenticated()); + } + assertThat(userRepository.findByUsername("clumsy").orElseThrow() + .getFailedLoginAttempts()).isEqualTo(2); + + // Third strike: locked; even the correct password is now rejected. + mvc.perform(formLogin("/auth/login").user("clumsy").password("wrong")) + .andExpect(unauthenticated()); + assertThat(userRepository.findByUsername("clumsy").orElseThrow().isLocked()).isTrue(); + mvc.perform(formLogin("/auth/login").user("clumsy").password("secret12")) + .andExpect(unauthenticated()); + + // The admin unlocks the account: lock and counter are cleared. + mvc.perform(post("/admin/users/{id}/unlock", victim.getUserId()) + .with(user("boss").roles("ADMIN")).with(csrf())) + .andExpect(redirectedUrl("/admin/users?unlocked")); + User unlocked = userRepository.findByUsername("clumsy").orElseThrow(); + assertThat(unlocked.isLocked()).isFalse(); + assertThat(unlocked.getFailedLoginAttempts()).isZero(); + + // The correct password works again, resets the counter, and stamps + // the login time. + mvc.perform(formLogin("/auth/login").user("clumsy").password("secret12")) + .andExpect(authenticated().withUsername("clumsy")); + assertThat(userRepository.findByUsername("clumsy").orElseThrow().getLastLoginAt()) + .isNotNull(); + } + + @Test + void failures_for_unknown_usernames_change_nothing() throws Exception { + // No account row, no counter: the response is indistinguishable. + mvc.perform(formLogin("/auth/login").user("ghost").password("whatever")) + .andExpect(unauthenticated()); + assertThat(userRepository.findByUsername("ghost")).isEmpty(); + } +}