Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
20 changes: 20 additions & 0 deletions src/main/java/com/ericbouchut/learndev/admin/AdminController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 6 additions & 1 deletion src/main/resources/templates/admin/users.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,13 @@ <h1 class="page-title">Accounts</h1>
<td th:text="${account.username}">username</td>
<td th:text="${account.email}">email</td>
<td th:text="${#strings.listJoin(account.roles.![roleName], ', ')}">STUDENT</td>
<td th:text="${account.active} ? 'Active' : 'Archived'">Active</td>
<td th:text="${!account.active} ? 'Archived' : (${account.locked} ? 'Locked' : 'Active')">Active</td>
<td>
<form class="form--inline" th:if="${account.locked}"
th:action="@{/admin/users/{id}/unlock(id=${account.userId})}" method="post">
<button class="button button--primary" type="submit">Unlock
<span class="visually-hidden" th:text="': ' + ${account.username}"></span></button>
</form>
<!-- The self-archive guard lives in the service; hiding the
button for the logged-in admin avoids offering a dead end. -->
<form th:if="${account.active && account.username != #authentication.name}"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package com.ericbouchut.learndev.auth;

import com.ericbouchut.learndev.support.AbstractPostgresIT;
import com.ericbouchut.learndev.user.entity.User;
import com.ericbouchut.learndev.user.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

/**
* End-to-end account lockout: repeated wrong passwords lock the account at
* the configured threshold, the right password no longer helps, an admin
* unlock clears the lock and the counter, and a successful login resets the
* counter and stamps {@code last_login_at}.
*
* <p>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();
}
}