feat(password): add reset and change password flows
Build, Test & Push Auth Service / quality-check (push) Successful in 53s
Build, Test & Push Auth Service / docker (push) Successful in 1m30s

Forgot password with a single-use reset link (1 hour, ACTIVE users
only, same answer for every address), change password (current session
stays, others end), admin reset mail with audit row, 'password
changed' mail. Login and refresh wait for a running password change.
This commit is contained in:
lobo committed 2026-10-02 10:35:08 +02:00
1 parent 6e7fb1e066
commit 5a2552ddf6
37 files changed
+1256 -34

No files matched your search

@@ -2,6 +2,7 @@ package auth_service.configuration.security;
import auth_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
@@ -9,6 +10,7 @@ import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -47,4 +49,20 @@ class PublicEndpointsTest extends AbstractIntegrationTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.commit").isString());
}
@Test
void changePasswordNeedsALogin() throws Exception {
mockMvc.perform(post("/api/auth/password").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"currentPassword\":\"x\",\"newPassword\":\"12345678\"}"))
.andExpect(status().isUnauthorized());
}
@Test
void forgotPasswordIsPublic() throws Exception {
mockMvc.perform(post("/api/auth/password-reset").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.header("X-Forwarded-For", freshClientIp()).contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"public-endpoints-nobody@example.com\"}"))
.andExpect(status().isNoContent());
}
}
@@ -0,0 +1,230 @@
package auth_service.modules.account.api;
import auth_service.configuration.security.ClientHeaderFilter;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.test.AbstractIntegrationTest;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Forgot password, change password and the admin reset against a real database. Each test uses its own address,
* because the mail limit per address lives in memory.
*/
class PasswordResetFlowTest extends AbstractIntegrationTest {
private static final String PASSWORD = "correct horse battery";
private static final String NEW_PASSWORD = "brand new password";
private static final Pattern LINK = Pattern.compile("/reset-password#(\\S+)");
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private PasswordEncoder passwordEncoder;
@BeforeEach
void clean() {
jdbc.update("DELETE FROM mail_outbox");
jdbc.update("DELETE FROM users WHERE email LIKE 'reset-flow-%'");
}
private long insertUser(String email, String role, String status) {
Long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, role, status, display_name) "
+ "VALUES (?, ?, true, ?, ?, ?) RETURNING id", Long.class, email, passwordEncoder.encode(PASSWORD),
role, status, "Flow" + System.nanoTime() % 100000);
addSettingsRows();
return id;
}
private MockHttpServletResponse login(String email, String password) throws Exception {
return mockMvc.perform(post("/api/auth/login").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.header("X-Forwarded-For", freshClientIp()).contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + email + "\",\"password\":\"" + password + "\"}"))
.andReturn().getResponse();
}
private ResultActions refresh(MockHttpServletResponse session) throws Exception {
return mockMvc.perform(post("/api/auth/refresh").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(session.getCookie(AuthCookies.REFRESH_COOKIE)));
}
private ResultActions requestReset(String email) throws Exception {
return mockMvc.perform(post("/api/auth/password-reset").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.header("X-Forwarded-For", freshClientIp()).contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + email + "\"}"));
}
private ResultActions confirm(String token, String password) throws Exception {
return mockMvc.perform(post("/api/auth/password-reset/confirm").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.header("X-Forwarded-For", freshClientIp()).contentType(MediaType.APPLICATION_JSON)
.content("{\"token\":\"" + token + "\",\"password\":\"" + password + "\"}"));
}
private ResultActions change(MockHttpServletResponse session, String current, String next) throws Exception {
return mockMvc.perform(post("/api/auth/password").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(session.getCookie(AuthCookies.ACCESS_COOKIE), session.getCookie(AuthCookies.REFRESH_COOKIE))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"currentPassword\":\"" + current + "\",\"newPassword\":\"" + next + "\"}"));
}
private List<String> resetBodies() {
return jdbc.queryForList("SELECT body FROM mail_outbox WHERE kind = 'PASSWORD_RESET' ORDER BY id", String.class);
}
private String lastToken() {
List<String> bodies = resetBodies();
Matcher link = LINK.matcher(bodies.get(bodies.size() - 1));
assertThat(link.find()).isTrue();
return link.group(1);
}
private long mails(String kind) {
return jdbc.queryForObject("SELECT COUNT(*) FROM mail_outbox WHERE kind = ?", Long.class, kind);
}
@Test
@ExtendWith(OutputCaptureExtension.class)
void forgotPassword_MailsALinkThatSetsANewPasswordOnceAndEndsAllLogins(CapturedOutput output) throws Exception {
String email = "reset-flow-main@example.com";
insertUser(email, "USER", "ACTIVE");
MockHttpServletResponse sessionA = login(email, PASSWORD);
MockHttpServletResponse sessionB = login(email, PASSWORD);
requestReset(email).andExpect(status().isNoContent());
assertThat(resetBodies()).hasSize(1);
String token = lastToken();
confirm(token, NEW_PASSWORD).andExpect(status().isNoContent());
refresh(sessionA).andExpect(status().isUnauthorized());
refresh(sessionB).andExpect(status().isUnauthorized());
assertThat(login(email, PASSWORD).getStatus()).isEqualTo(401);
assertThat(login(email, NEW_PASSWORD).getStatus()).isEqualTo(200);
assertThat(mails("PASSWORD_CHANGED")).isEqualTo(1);
confirm(token, NEW_PASSWORD).andExpect(status().isGone()).andExpect(jsonPath("$.code").value("RESET_INVALID"));
assertThat(output.getAll()).doesNotContain(token);
}
@Test
void forgotPassword_SameAnswerAndNoMailForAnUnknownOrNotActiveAddress() throws Exception {
insertUser("reset-flow-pending@example.com", "USER", "PENDING");
requestReset("reset-flow-unknown@example.com").andExpect(status().isNoContent());
requestReset("reset-flow-pending@example.com").andExpect(status().isNoContent());
assertThat(mails("PASSWORD_RESET")).isZero();
}
@Test
void twoRequests_OnlyTheNewestLinkWorks() throws Exception {
String email = "reset-flow-two@example.com";
insertUser(email, "USER", "ACTIVE");
requestReset(email).andExpect(status().isNoContent());
String first = lastToken();
requestReset(email).andExpect(status().isNoContent());
String second = lastToken();
confirm(first, NEW_PASSWORD).andExpect(status().isGone());
confirm(second, NEW_PASSWORD).andExpect(status().isNoContent());
}
@Test
void confirm_RefusesATooShortOrTooLongPassword() throws Exception {
String email = "reset-flow-rules@example.com";
insertUser(email, "USER", "ACTIVE");
requestReset(email);
String token = lastToken();
confirm(token, "1234567").andExpect(status().isBadRequest());
confirm(token, "a".repeat(73)).andExpect(status().isBadRequest());
confirm(token, NEW_PASSWORD).andExpect(status().isNoContent());
}
@Test
void changePassword_KeepsTheCurrentLoginAndEndsTheOthers() throws Exception {
String email = "reset-flow-change@example.com";
insertUser(email, "USER", "ACTIVE");
MockHttpServletResponse sessionA = login(email, PASSWORD);
MockHttpServletResponse sessionB = login(email, PASSWORD);
change(sessionA, PASSWORD, NEW_PASSWORD).andExpect(status().isNoContent());
refresh(sessionA).andExpect(status().isNoContent());
refresh(sessionB).andExpect(status().isUnauthorized());
assertThat(login(email, NEW_PASSWORD).getStatus()).isEqualTo(200);
assertThat(mails("PASSWORD_CHANGED")).isEqualTo(1);
}
@Test
void changePassword_WrongCurrentPasswordIs403() throws Exception {
String email = "reset-flow-wrong@example.com";
insertUser(email, "USER", "ACTIVE");
change(login(email, PASSWORD), "wrong password", NEW_PASSWORD).andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("INVALID_PASSWORD"));
assertThat(login(email, PASSWORD).getStatus()).isEqualTo(200);
}
@Test
void changePassword_WithoutLoginIs401() throws Exception {
mockMvc.perform(post("/api/auth/password").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"currentPassword\":\"" + PASSWORD + "\",\"newPassword\":\"" + NEW_PASSWORD + "\"}"))
.andExpect(status().isUnauthorized());
}
private ResultActions adminReset(MockHttpServletResponse session, long userId) throws Exception {
return mockMvc.perform(post("/api/admin/users/" + userId + "/password-reset")
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.getCookie(AuthCookies.ACCESS_COOKIE).getValue())));
}
@Test
void adminReset_MailsTheLinkAndWritesTheAudit() throws Exception {
insertUser("reset-flow-admin@example.com", "PLATFORM_ADMIN", "ACTIVE");
long player = insertUser("reset-flow-player@example.com", "USER", "ACTIVE");
long pending = insertUser("reset-flow-pend@example.com", "USER", "PENDING");
MockHttpServletResponse admin = login("reset-flow-admin@example.com", PASSWORD);
adminReset(admin, player).andExpect(status().isNoContent());
assertThat(resetBodies()).hasSize(1);
confirm(lastToken(), NEW_PASSWORD).andExpect(status().isNoContent());
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM admin_audit WHERE action = 'SEND_PASSWORD_RESET' "
+ "AND target_user_id = ?", Long.class, player)).isEqualTo(1);
adminReset(admin, pending).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("NOT_ACTIVE"));
}
@Test
void adminReset_AsAPlainUserIs403() throws Exception {
long other = insertUser("reset-flow-other@example.com", "USER", "ACTIVE");
insertUser("reset-flow-plain@example.com", "USER", "ACTIVE");
adminReset(login("reset-flow-plain@example.com", PASSWORD), other).andExpect(status().isForbidden());
assertThat(mails("PASSWORD_RESET")).isZero();
}
}
@@ -0,0 +1,152 @@
package auth_service.modules.account.api;
import auth_service.configuration.security.ClientHeaderFilter;
import auth_service.modules.account.service.PasswordResetService;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.test.AbstractIntegrationTest;
import jakarta.servlet.http.Cookie;
import javax.sql.DataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.springframework.test.web.servlet.MockMvc;
import java.sql.Connection;
import java.sql.Statement;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* A reset that commits while a login with the old password is between its password check and its new refresh token.
* The spy runs the reset exactly in that gap, so the order is fixed, not timed.
*/
class PasswordResetRaceTest extends AbstractIntegrationTest {
private static final String PASSWORD = "correct horse battery";
private static final String NEW_PASSWORD = "brand new password";
private static final Pattern LINK = Pattern.compile("/reset-password#(\\S+)");
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private DataSource dataSource;
@MockitoSpyBean
private PasswordEncoder passwordEncoder;
@Autowired
private PasswordResetService passwordResetService;
@BeforeEach
void clean() {
jdbc.update("DELETE FROM mail_outbox");
jdbc.update("DELETE FROM users WHERE email LIKE 'reset-race-%'");
}
@Test
void loginRacingAResetGetsNoSessionThatOutlivesTheReset() throws Exception {
String email = "reset-race-login@example.com";
jdbc.update("INSERT INTO users (email, password, enabled, role, status, display_name) "
+ "VALUES (?, ?, true, 'USER', 'ACTIVE', ?)", email, passwordEncoder.encode(PASSWORD),
"Race" + System.nanoTime() % 100000);
addSettingsRows();
mockMvc.perform(post("/api/auth/password-reset").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.header("X-Forwarded-For", freshClientIp()).contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + email + "\"}"))
.andExpect(status().isNoContent());
Matcher link = LINK.matcher(jdbc.queryForObject(
"SELECT body FROM mail_outbox WHERE kind = 'PASSWORD_RESET' ORDER BY id DESC LIMIT 1", String.class));
assertThat(link.find()).isTrue();
String token = link.group(1);
// The attacker knows the old password and logs in; the owner's reset commits during that login's bcrypt check.
AtomicReference<String> pendingToken = new AtomicReference<>(token);
doAnswer(invocation -> {
boolean matches = (boolean) invocation.callRealMethod();
String resetToken = pendingToken.getAndSet(null);
if (resetToken != null) {
passwordResetService.resetPassword(resetToken, NEW_PASSWORD, "198.51.100.7");
}
return matches;
}).when(passwordEncoder).matches(any(), any());
MockHttpServletResponse attacker = mockMvc.perform(post("/api/auth/login")
.header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + email + "\",\"password\":\"" + PASSWORD + "\"}"))
.andReturn().getResponse();
assertThat(pendingToken.get()).as("the reset ran inside the login").isNull();
int afterReset = attacker.getStatus() != 200 ? 401 : mockMvc.perform(post("/api/auth/refresh")
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(attacker.getCookie(AuthCookies.REFRESH_COOKIE)))
.andReturn().getResponse().getStatus();
assertThat(afterReset).as("refresh with the session of the old password, after the reset").isEqualTo(401);
}
/**
* A password change holds the user row, uncommitted. A refresh must wait for it, then find its token revoked.
* Without the user lock the refresh would rotate at once and the revoke could miss the new token.
*/
@Test
void refreshRacingAPasswordChangeWaitsForItAndGetsNoLiveToken() throws Exception {
String email = "reset-race-refresh@example.com";
jdbc.update("INSERT INTO users (email, password, enabled, role, status, display_name) "
+ "VALUES (?, ?, true, 'USER', 'ACTIVE', ?)", email, passwordEncoder.encode(PASSWORD),
"Race" + System.nanoTime() % 100000);
addSettingsRows();
long userId = jdbc.queryForObject("SELECT id FROM users WHERE email = ?", Long.class, email);
MockHttpServletResponse login = mockMvc.perform(post("/api/auth/login")
.header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + email + "\",\"password\":\"" + PASSWORD + "\"}"))
.andExpect(status().isOk()).andReturn().getResponse();
Cookie cookie = login.getCookie(AuthCookies.REFRESH_COOKIE);
ExecutorService executor = Executors.newSingleThreadExecutor();
try (Connection owner = dataSource.getConnection()) {
owner.setAutoCommit(false);
try (Statement statement = owner.createStatement()) {
statement.executeUpdate("UPDATE users SET password = 'changed' WHERE id = " + userId);
Future<Integer> refresh = executor.submit(() -> mockMvc.perform(post("/api/auth/refresh")
.header(ClientHeaderFilter.CLIENT_HEADER, "web").cookie(cookie))
.andReturn().getResponse().getStatus());
long deadline = System.currentTimeMillis() + 10_000;
while (jdbc.queryForObject("SELECT count(*) FROM pg_stat_activity WHERE wait_event_type = 'Lock'",
Integer.class) == 0) {
assertThat(System.currentTimeMillis()).as("the refresh waits for the password change")
.isLessThan(deadline);
Thread.sleep(20);
}
statement.executeUpdate("UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = " + userId
+ " AND revoked_at IS NULL");
owner.commit();
assertThat(refresh.get(10, TimeUnit.SECONDS)).isEqualTo(401);
}
} finally {
executor.shutdownNow();
}
assertThat(jdbc.queryForObject("SELECT count(*) FROM refresh_tokens WHERE user_id = ? AND revoked_at IS NULL",
Integer.class, userId)).isZero();
}
}
@@ -12,6 +12,7 @@ import java.time.Instant;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@@ -132,4 +133,95 @@ class AccountRepositoryTest extends AbstractPersistenceTest {
assertThat(count("select count(*) from users where id = ?", oldWithValidLink)).isEqualTo(1);
}
private void setStatus(long userId, String set) {
jdbc.update("update users set " + set + " where id = ?", userId);
}
@Test
void passwordReset_OnlyAnActiveUserGetsALink() {
assertThat(repository.findActiveIdByEmail("Alice@Example.com ")).contains(alice);
assertThat(repository.storePasswordReset(alice, "h1", NOW.plusSeconds(3600))).isTrue();
for (String notActive : new String[]{"status = 'UNCONFIRMED'", "status = 'PENDING'", "status = 'DENIED'",
"enabled = false", "deleted_at = now()"}) {
setStatus(bob, notActive);
assertThat(repository.findActiveIdByEmail("bob@example.com")).as(notActive).isEmpty();
assertThat(repository.storePasswordReset(bob, "h2", NOW.plusSeconds(3600))).as(notActive).isFalse();
setStatus(bob, "status = 'ACTIVE', enabled = true, deleted_at = NULL");
}
}
@Test
void resetPassword_ReturnsTheIdOnceAndSetsThePassword() {
repository.storePasswordReset(alice, "h1", NOW.plusSeconds(3600));
assertThat(repository.resetPassword("h1", "new-hash", NOW)).isEqualTo(Optional.of(alice));
assertThat(repository.resetPassword("h1", "newer-hash", NOW)).isEmpty();
assertThat(repository.findPasswordHashOfActive(alice)).contains("new-hash");
assertThat(jdbc.queryForObject("select password_reset_hash from users where id = ?", String.class, alice)).isNull();
}
@Test
void resetPassword_ExpiredOrReplacedOrBlockedLinkIsEmpty() {
repository.storePasswordReset(alice, "expired", NOW.minusSeconds(1));
assertThat(repository.resetPassword("expired", "x", NOW)).isEmpty();
repository.storePasswordReset(alice, "first", NOW.plusSeconds(3600));
repository.storePasswordReset(alice, "second", NOW.plusSeconds(3600));
assertThat(repository.resetPassword("first", "x", NOW)).as("newest link only").isEmpty();
setStatus(alice, "enabled = false");
assertThat(repository.resetPassword("second", "x", NOW)).as("blocked after the mail").isEmpty();
assertThat(repository.findPasswordHashOfActive(alice)).contains("hash-alice@example.com");
}
@Test
void updatePassword_EndsAnOpenResetLink() {
repository.storePasswordReset(alice, "h1", NOW.plusSeconds(3600));
repository.updatePassword(alice, "changed");
assertThat(repository.findPasswordHashOfActive(alice)).contains("changed");
assertThat(repository.resetPassword("h1", "x", NOW)).isEmpty();
}
private void insertToken(long userId, String hash, UUID family) {
jdbc.update("""
insert into refresh_tokens (user_id, token_hash, family_id, expires_at, created_at)
values (?, ?, ?, now() + interval '30 days', now())""", userId, hash, family);
}
private long activeTokens(long userId) {
return count("select count(*) from refresh_tokens where user_id = ? and revoked_at is null", userId);
}
@Test
void revokeSessions_KeepsTheFamilyOfTheGivenHashAlsoWhenItWasRotated() {
UUID kept = UUID.randomUUID();
insertToken(alice, "kept-old", kept);
insertToken(alice, "kept-new", kept);
jdbc.update("update refresh_tokens set revoked_at = now() where token_hash = 'kept-old'");
insertToken(bob, "bobs", UUID.randomUUID());
repository.revokeSessions(alice, "kept-old", NOW);
assertThat(count("select count(*) from refresh_tokens where token_hash = 'kept-new' and revoked_at is null")).isEqualTo(1);
assertThat(activeTokens(alice)).as("the seed login is gone").isEqualTo(1);
assertThat(activeTokens(bob)).as("another user").isEqualTo(2);
}
@Test
void revokeSessions_WithoutAHashRevokesAllAndAnotherUsersHashKeepsNothing() {
insertToken(bob, "bobs-hash", UUID.randomUUID());
repository.revokeSessions(alice, "bobs-hash", NOW);
assertThat(activeTokens(alice)).isZero();
insertToken(alice, "again", UUID.randomUUID());
repository.revokeSessions(alice, null, NOW);
assertThat(activeTokens(alice)).isZero();
assertThat(activeTokens(bob)).isEqualTo(2);
}
}
@@ -1,9 +1,10 @@
package auth_service.modules.account.service;
import auth_service.modules.account.exception.InvalidPasswordException;
import auth_service.modules.account.exception.TooManyDeleteAttemptsException;
import auth_service.modules.account.exception.TooManyAttemptsException;
import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.mail.AccountMailer;
import auth_service.shared.security.OpaqueToken;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
@@ -97,7 +98,7 @@ class AccountServiceTest extends AbstractUnitTest {
assertThatThrownBy(() -> service.deleteAccount(USER_ID, "wrong")).isInstanceOf(InvalidPasswordException.class);
}
assertThatThrownBy(() -> service.deleteAccount(USER_ID, "right")).isInstanceOf(TooManyDeleteAttemptsException.class);
assertThatThrownBy(() -> service.deleteAccount(USER_ID, "right")).isInstanceOf(TooManyAttemptsException.class);
verify(accountRepository, never()).flagDeleted(anyLong(), any());
}
@@ -131,4 +132,46 @@ class AccountServiceTest extends AbstractUnitTest {
verifyNoInteractions(coreAccountService);
verifyNoInteractions(accountMailer);
}
@Test
void changePassword_StoresTheNewHashKeepsTheLoginOfTheCookieAndMails() {
when(passwordEncoder.encode("new password")).thenReturn("new-hash");
service.changePassword(USER_ID, "right", "new password", "refresh-cookie");
verify(accountRepository).updatePassword(USER_ID, "new-hash");
verify(accountRepository).revokeSessions(eq(USER_ID), eq(OpaqueToken.hash("refresh-cookie")), any());
verify(accountMailer).passwordChanged(USER_ID);
}
@Test
void changePassword_WithoutACookieEndsAllLogins() {
service.changePassword(USER_ID, "right", "new password", null);
verify(accountRepository).revokeSessions(eq(USER_ID), eq(null), any());
}
@Test
void changePassword_WrongCurrentPasswordChangesNothing() {
assertThatThrownBy(() -> service.changePassword(USER_ID, "wrong", "new password", "cookie"))
.isInstanceOf(InvalidPasswordException.class);
verify(accountRepository, never()).updatePassword(anyLong(), any());
verifyNoInteractions(accountMailer);
}
@Test
void changePassword_SixthWrongPasswordIsRateLimitedAlsoAfterWrongDeletes() {
for (int i = 0; i < 3; i++) {
assertThatThrownBy(() -> service.deleteAccount(USER_ID, "wrong")).isInstanceOf(InvalidPasswordException.class);
}
for (int i = 0; i < 2; i++) {
assertThatThrownBy(() -> service.changePassword(USER_ID, "wrong", "new password", null))
.isInstanceOf(InvalidPasswordException.class);
}
assertThatThrownBy(() -> service.changePassword(USER_ID, "right", "new password", null))
.isInstanceOf(TooManyAttemptsException.class);
verify(accountRepository, never()).updatePassword(anyLong(), any());
}
}
@@ -0,0 +1,168 @@
package auth_service.modules.account.service;
import auth_service.modules.account.exception.PasswordResetInvalidException;
import auth_service.modules.account.exception.TooManyAttemptsException;
import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.mail.AccountMailer;
import auth_service.shared.security.OpaqueToken;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class PasswordResetServiceTest extends AbstractUnitTest {
private static final long USER_ID = 42L;
private static final String EMAIL = "alice@example.com";
private static final String IP = "198.51.100.7";
@Mock
private AccountRepository accountRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private AccountMailer accountMailer;
private PasswordResetService service;
@BeforeEach
void setUp() {
service = new PasswordResetService(accountRepository, passwordEncoder, accountMailer);
}
@Test
void requestReset_StoresTheHashOfTheMailedTokenForOneHour() {
when(accountRepository.findActiveIdByEmail(EMAIL)).thenReturn(Optional.of(USER_ID));
when(accountRepository.storePasswordReset(eq(USER_ID), anyString(), any())).thenReturn(true);
Instant before = Instant.now();
service.requestReset(EMAIL, IP);
ArgumentCaptor<String> raw = ArgumentCaptor.forClass(String.class);
verify(accountMailer).passwordReset(eq(USER_ID), raw.capture());
ArgumentCaptor<Instant> expires = ArgumentCaptor.forClass(Instant.class);
verify(accountRepository).storePasswordReset(eq(USER_ID), eq(OpaqueToken.hash(raw.getValue())), expires.capture());
assertThat(expires.getValue()).isBetween(before.plus(Duration.ofHours(1)), Instant.now().plus(Duration.ofHours(1)));
}
@Test
void requestReset_ForAnUnknownOrInactiveAddressMailsNothingAndDoesNotThrow() {
when(accountRepository.findActiveIdByEmail(EMAIL)).thenReturn(Optional.empty());
assertThatNoException().isThrownBy(() -> service.requestReset(EMAIL, IP));
verifyNoInteractions(accountMailer);
verify(accountRepository, never()).storePasswordReset(anyLong(), any(), any());
}
@Test
void requestReset_FourthRequestForOneAddressInTheHourStoresAndMailsNothing() {
when(accountRepository.findActiveIdByEmail(EMAIL)).thenReturn(Optional.of(USER_ID));
when(accountRepository.storePasswordReset(eq(USER_ID), anyString(), any())).thenReturn(true);
for (int i = 0; i < 4; i++) {
service.requestReset(EMAIL, "198.51.100." + i);
}
verify(accountRepository, org.mockito.Mockito.times(3)).storePasswordReset(eq(USER_ID), anyString(), any());
verify(accountMailer, org.mockito.Mockito.times(3)).passwordReset(eq(USER_ID), anyString());
}
@Test
void requestReset_SixthRequestFromOneIpIs429() {
for (int i = 0; i < 5; i++) {
service.requestReset("user" + i + "@example.com", IP);
}
assertThatThrownBy(() -> service.requestReset("other@example.com", IP))
.isInstanceOf(TooManyAttemptsException.class);
}
@Test
void resetPassword_SetsThePasswordEndsAllLoginsAndMails() {
when(passwordEncoder.encode("new password")).thenReturn("new-hash");
when(accountRepository.resetPassword(eq(OpaqueToken.hash("raw")), eq("new-hash"), any()))
.thenReturn(Optional.of(USER_ID));
service.resetPassword("raw", "new password", IP);
verify(accountRepository).revokeSessions(eq(USER_ID), eq(null), any());
verify(accountMailer).passwordChanged(USER_ID);
}
@Test
void resetPassword_SuccessGivesTheFailureBack() {
when(accountRepository.resetPassword(eq(OpaqueToken.hash("good")), any(), any())).thenReturn(Optional.of(USER_ID));
when(accountRepository.resetPassword(eq(OpaqueToken.hash("bad")), any(), any())).thenReturn(Optional.empty());
for (int i = 0; i < 9; i++) {
assertThatThrownBy(() -> service.resetPassword("bad", "new password", IP))
.isInstanceOf(PasswordResetInvalidException.class);
}
service.resetPassword("good", "new password", IP);
assertThatThrownBy(() -> service.resetPassword("bad", "new password", IP))
.isInstanceOf(PasswordResetInvalidException.class);
}
@Test
void resetPassword_InvalidTokenEndsNoLoginAndSendsNoMail() {
when(accountRepository.resetPassword(any(), any(), any())).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.resetPassword("raw", "new password", IP))
.isInstanceOf(PasswordResetInvalidException.class);
verify(accountRepository, never()).revokeSessions(anyLong(), any(), any());
verifyNoInteractions(accountMailer);
}
@Test
void resetPassword_EleventhFailureFromOneIpIs429() {
when(accountRepository.resetPassword(any(), any(), any())).thenReturn(Optional.empty());
for (int i = 0; i < 10; i++) {
assertThatThrownBy(() -> service.resetPassword("raw", "new password", IP))
.isInstanceOf(PasswordResetInvalidException.class);
}
assertThatThrownBy(() -> service.resetPassword("raw", "new password", IP))
.isInstanceOf(TooManyAttemptsException.class);
}
@Test
void sendByAdmin_IsFalseAndMailsNothingWhenTheStoreIsRefused() {
when(accountRepository.storePasswordReset(eq(USER_ID), anyString(), any())).thenReturn(false);
assertThat(service.sendByAdmin(USER_ID)).isFalse();
verifyNoInteractions(accountMailer);
}
@Test
void sendByAdmin_MailsTheLinkForAnActiveUser() {
when(accountRepository.storePasswordReset(eq(USER_ID), anyString(), any())).thenReturn(true);
assertThat(service.sendByAdmin(USER_ID)).isTrue();
verify(accountMailer).passwordReset(eq(USER_ID), anyString());
}
}
@@ -6,6 +6,7 @@ import auth_service.modules.admin.model.TargetState;
import auth_service.modules.admin.repo.AdminUserRepository;
import auth_service.shared.account.AccountDeleter;
import auth_service.shared.account.AccountStatePublisher;
import auth_service.shared.account.PasswordResetSender;
import auth_service.shared.audit.AdminAuditLog;
import auth_service.shared.mail.AccountMailer;
import auth_service.shared.security.Role;
@@ -47,12 +48,15 @@ class AdminUserServiceTest extends AbstractUnitTest {
@Mock
private AccountStatePublisher accountStatePublisher;
@Mock
private PasswordResetSender passwordResetSender;
private AdminUserService service;
@BeforeEach
void setUp() {
service = new AdminUserService(adminUserRepository, adminAuditLog, accountDeleter, accountStatePublisher,
accountMailer);
accountMailer, passwordResetSender);
}
private void target(Role role, boolean deleted) {
@@ -86,6 +90,34 @@ class AdminUserServiceTest extends AbstractUnitTest {
verify(accountMailer, never()).accountBlocked(anyLong());
}
@Test
void sendPasswordReset_SendsAndWritesTheAudit() {
target(Role.USER, false);
when(passwordResetSender.sendByAdmin(TARGET)).thenReturn(true);
service.sendPasswordReset(ADMIN, TARGET);
verify(adminAuditLog).record(ADMIN, "SEND_PASSWORD_RESET", TARGET, null);
}
@Test
void sendPasswordReset_OfANonActiveUserIsRefusedWithoutAudit() {
target(Role.USER, false);
when(passwordResetSender.sendByAdmin(TARGET)).thenReturn(false);
assertThat(refusal(() -> service.sendPasswordReset(ADMIN, TARGET))).isEqualTo("NOT_ACTIVE");
verifyNoInteractions(adminAuditLog);
}
@Test
void sendPasswordReset_ToYourselfOrAnotherAdminIsRefusedAndSendsNothing() {
assertThat(refusal(() -> service.sendPasswordReset(ADMIN, ADMIN))).isEqualTo("SELF_ACTION");
target(Role.PLATFORM_ADMIN, false);
assertThat(refusal(() -> service.sendPasswordReset(ADMIN, TARGET))).isEqualTo("TARGET_IS_ADMIN");
verifyNoInteractions(passwordResetSender);
}
@Test
void block_BlocksAndWritesTheAudit() {
target(Role.USER, false);
@@ -85,7 +85,7 @@ class LoginServiceTest extends AbstractUnitTest {
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
when(tokenGenerator.generateToken("815", null, Role.USER)).thenReturn("mock-token");
when(refreshTokenService.startFamily(815L)).thenReturn("mock-refresh");
when(refreshTokenService.startFamily(815L, dbUser.getPassword())).thenReturn("mock-refresh");
// Act
var result = loginService.login(user, IP, null);
@@ -252,6 +252,6 @@ class LoginServiceTest extends AbstractUnitTest {
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
verify(refreshTokenService, never()).startFamily(anyLong());
verify(refreshTokenService, never()).startFamily(anyLong(), any());
}
}
@@ -63,6 +63,16 @@ class MailTemplateTest extends AbstractUnitTest {
assertThat(MailTemplate.date(null, date)).isEqualTo("5 November 2026");
}
@ParameterizedTest
@EnumSource(value = MailTemplate.class, names = {"PASSWORD_RESET", "PASSWORD_CHANGED"})
void render_PasswordMails_CarryTheLinkInBothLanguages(MailTemplate template) {
for (String language : new String[]{"de", "en"}) {
Mail mail = template.render(language, "Alice", "https://tbp.example/reset-password#tok");
assertThat(mail.body()).contains("https://tbp.example/reset-password#tok");
}
}
@Test
void render_ConfirmEmail_CarriesTheLinkInBothLanguages() {
for (String language : new String[]{"de", "en"}) {
@@ -65,6 +65,27 @@ class MailServiceTest extends AbstractUnitTest {
assertThat(mail.body()).startsWith("Hallo Alice,").contains("https://tbp.example/confirm-email#raw-token");
}
@Test
void passwordReset_MailsTheLinkWithTheTokenInTheFragment() {
user("Alice", "en");
service(ADMIN).passwordReset(USER, "raw-token");
Mail mail = inserted(MailTemplate.PASSWORD_RESET, "alice@example.com");
assertThat(mail.body()).startsWith("Hello Alice,").contains("https://tbp.example/reset-password#raw-token");
}
@Test
void passwordChanged_MailsTheUserInTheirLanguageWithTheResetPageLink() {
user("Alice", "de");
service(ADMIN).passwordChanged(USER);
Mail mail = inserted(MailTemplate.PASSWORD_CHANGED, "alice@example.com");
assertThat(mail.subject()).contains("geändert");
assertThat(mail.body()).contains("https://tbp.example/reset-password").doesNotContain("#");
}
@Test
void registrationRequested_NamesTheUserWithoutADisplayNameAsDash() {
user(null, "de");