Public Access
feat(password): add reset and change password flows
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:
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");
|
||||
|
||||
Reference in new issue
Block a user