Public Access
feat(registration): add email confirmation flow
Registrations become two-phase: first UNCONFIRMED with a 24-hour single-use email link, then PENDING after confirmation and password entry. Prevents registering with someone else's address. Admins see unconfirmed requests and can delete them; they cannot accept them.
This commit is contained in:
1 parent
878ede914b
commit
0af76da9c7
43 files changed
+959
-145
No files matched your search
@@ -192,7 +192,7 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void registration_WaitsAndDoesNotCountUntilTheAccept() throws Exception {
|
||||
void registration_IsUnconfirmedAndDoesNotCount() throws Exception {
|
||||
String newEmail = "delete-flow-registered@example.com";
|
||||
jdbc.update("DELETE FROM users WHERE email = ?", newEmail);
|
||||
long before = totalUsers();
|
||||
@@ -201,12 +201,11 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"" + newEmail + "\",\"password\":\"" + PASSWORD
|
||||
+ "\"}"))
|
||||
.content("{\"email\":\"" + newEmail + "\"}"))
|
||||
.andExpect(status().is2xxSuccessful());
|
||||
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM users WHERE email = ?", String.class, newEmail))
|
||||
.isEqualTo("PENDING");
|
||||
.isEqualTo("UNCONFIRMED");
|
||||
assertThat(totalUsers()).isEqualTo(before);
|
||||
} finally {
|
||||
jdbc.update("DELETE FROM users WHERE email = ?", newEmail);
|
||||
@@ -220,8 +219,7 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD
|
||||
+ "\"}"))
|
||||
.content("{\"email\":\"" + EMAIL + "\"}"))
|
||||
.andExpect(jsonPath("$.code").value("EMAIL_ALREADY_IN_USE"));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -100,4 +101,35 @@ class AccountRepositoryTest extends AbstractPersistenceTest {
|
||||
assertThat(count("select count(*) from users where id = ?", bob)).isEqualTo(1);
|
||||
assertThat(repository.deleteUsers(List.of())).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUnconfirmedBefore_RemovesOldUnconfirmedUsersWithTheirSettingsOnly() {
|
||||
long old = insertUser("old-unconfirmed@example.com");
|
||||
long recent = insertUser("recent-unconfirmed@example.com");
|
||||
long oldPending = insertUser("old-pending@example.com");
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
jdbc.update("update users set status = 'UNCONFIRMED', registration_date = ? where id = ?", now.minusDays(8), old);
|
||||
jdbc.update("update users set status = 'UNCONFIRMED', registration_date = ? where id = ?", now.minusDays(6), recent);
|
||||
jdbc.update("update users set status = 'PENDING', registration_date = ? where id = ?", now.minusDays(8), oldPending);
|
||||
|
||||
assertThat(repository.deleteUnconfirmedBefore(now.minusDays(7))).isEqualTo(1);
|
||||
|
||||
assertThat(count("select count(*) from users where id = ?", old)).isZero();
|
||||
assertThat(count("select count(*) from user_settings where user_id = ?", old)).isZero();
|
||||
assertThat(count("select count(*) from users where id in (?, ?)", recent, oldPending)).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUnconfirmedBefore_KeepsAnOldUnconfirmedUserWhoseResentLinkIsStillValid() {
|
||||
long oldWithValidLink = insertUser("old-valid-link@example.com");
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
jdbc.update("""
|
||||
update users set status = 'UNCONFIRMED', registration_date = ?, email_confirmation_hash = 'h',
|
||||
email_confirmation_expires_at = now() + interval '20 hours' where id = ?""",
|
||||
now.minusDays(8), oldWithValidLink);
|
||||
|
||||
assertThat(repository.deleteUnconfirmedBefore(now.minusDays(7))).isZero();
|
||||
|
||||
assertThat(count("select count(*) from users where id = ?", oldWithValidLink)).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
@@ -6,17 +6,23 @@ import auth_service.modules.account.repo.AccountRepository;
|
||||
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.InOrder;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -140,4 +146,19 @@ class AccountCleanupServiceTest extends AbstractUnitTest {
|
||||
|
||||
verify(accountRepository).deleteUsers(List.of(7L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void runNightly_PurgesUnconfirmedRegistrationsEvenInDryRunAndBeforeTheCoreCalls() {
|
||||
when(coreAccountService.purge(any(), anyBoolean())).thenReturn(core(true));
|
||||
when(coreAccountService.prune(any(), anyBoolean())).thenReturn(core(true));
|
||||
|
||||
service(true).runNightly();
|
||||
|
||||
InOrder order = inOrder(accountRepository, coreAccountService);
|
||||
ArgumentCaptor<LocalDateTime> cutoff = ArgumentCaptor.forClass(LocalDateTime.class);
|
||||
order.verify(accountRepository).deleteUnconfirmedBefore(cutoff.capture());
|
||||
order.verify(coreAccountService).replaceDeleted(any());
|
||||
assertThat(cutoff.getValue()).isBetween(LocalDateTime.now(Clock.systemUTC()).minusDays(7).minusMinutes(1),
|
||||
LocalDateTime.now(Clock.systemUTC()).minusDays(7).plusMinutes(1));
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,46 @@ class AdminFlowTest extends AbstractIntegrationTest {
|
||||
decide(admin, playerId, true).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("NOT_PENDING"));
|
||||
}
|
||||
|
||||
private ResultActions deleteUnconfirmed(Session session, long userId) throws Exception {
|
||||
return mockMvc.perform(delete("/api/admin/users/" + userId + "/unconfirmed")
|
||||
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unconfirmedRequest_IsListedCannotBeAcceptedAndTheAdminDeletesIt() throws Exception {
|
||||
jdbc.update("UPDATE users SET status = 'UNCONFIRMED' WHERE id = ?", playerId);
|
||||
Session admin = Session.of(login(ADMIN));
|
||||
|
||||
mockMvc.perform(get("/api/admin/users").param("status", "UNCONFIRMED")
|
||||
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin.access())))
|
||||
.andExpect(jsonPath("$.content[?(@.email == '" + PLAYER + "')].status").value("UNCONFIRMED"));
|
||||
decide(admin, playerId, true).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("NOT_PENDING"));
|
||||
block(admin, playerId, true).andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("TARGET_IS_UNCONFIRMED"));
|
||||
|
||||
deleteUnconfirmed(admin, playerId).andExpect(status().isNoContent());
|
||||
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE id = ?", Long.class, playerId)).isZero();
|
||||
assertThat(audits("DELETE_UNCONFIRMED", playerId)).isEqualTo(1);
|
||||
deleteUnconfirmed(admin, playerId).andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUnconfirmed_RefusesOtherStatusesAndAUser() throws Exception {
|
||||
Session admin = Session.of(login(ADMIN));
|
||||
Session player = Session.of(login(PLAYER));
|
||||
|
||||
deleteUnconfirmed(player, playerId).andExpect(status().isForbidden());
|
||||
deleteUnconfirmed(admin, playerId).andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("NOT_UNCONFIRMED"));
|
||||
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", playerId);
|
||||
deleteUnconfirmed(admin, playerId).andExpect(status().isConflict());
|
||||
deleteUnconfirmed(admin, otherAdminId).andExpect(status().isConflict());
|
||||
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE id = ?", Long.class, playerId)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deniedRequest_CannotLogInAndWaitsForTheCleanup() throws Exception {
|
||||
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", playerId);
|
||||
|
||||
@@ -119,4 +119,27 @@ class AdminUserRepositoryTest extends AbstractPersistenceTest {
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, alice)).isEqualTo("DENIED");
|
||||
assertThat(repository.findState(alice).orElseThrow().deleted()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findPageAndCount_ListAnUnconfirmedUserAndFilterByIt() {
|
||||
jdbc.update("UPDATE users SET status = 'UNCONFIRMED' WHERE id = ?", alice);
|
||||
|
||||
assertThat(repository.count("", "")).isEqualTo(3);
|
||||
assertThat(repository.findPage("", "UNCONFIRMED", 0, 10)).extracting(AdminUser::status)
|
||||
.containsExactly("UNCONFIRMED");
|
||||
assertThat(repository.count("", "PENDING")).isZero();
|
||||
assertThat(repository.findState(alice).orElseThrow().unconfirmed()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUnconfirmed_RemovesOnlyAnUnconfirmedUserWithItsSettings() {
|
||||
assertThat(repository.deleteUnconfirmed(alice)).isFalse();
|
||||
jdbc.update("UPDATE users SET status = 'UNCONFIRMED' WHERE id = ?", alice);
|
||||
|
||||
assertThat(repository.deleteUnconfirmed(alice)).isTrue();
|
||||
assertThat(repository.deleteUnconfirmed(alice)).isFalse();
|
||||
assertThat(repository.count("", "")).isEqualTo(2);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM user_settings WHERE user_id = ?", Long.class, alice))
|
||||
.isZero();
|
||||
}
|
||||
}
|
||||
@@ -56,11 +56,15 @@ class AdminUserServiceTest extends AbstractUnitTest {
|
||||
}
|
||||
|
||||
private void target(Role role, boolean deleted) {
|
||||
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(role, deleted, false)));
|
||||
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(role, deleted, false, false)));
|
||||
}
|
||||
|
||||
private void pendingTarget() {
|
||||
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(Role.USER, false, true)));
|
||||
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(Role.USER, false, true, false)));
|
||||
}
|
||||
|
||||
private void unconfirmedTarget() {
|
||||
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(Role.USER, false, false, true)));
|
||||
}
|
||||
|
||||
private String refusal(Runnable action) {
|
||||
@@ -194,4 +198,38 @@ class AdminUserServiceTest extends AbstractUnitTest {
|
||||
assertThat(refusal(() -> service.delete(ADMIN, TARGET))).isEqualTo("TARGET_IS_PENDING");
|
||||
verifyNoInteractions(accountDeleter);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUnconfirmed_DeletesAndAudits() {
|
||||
unconfirmedTarget();
|
||||
when(adminUserRepository.deleteUnconfirmed(TARGET)).thenReturn(true);
|
||||
|
||||
service.deleteUnconfirmed(ADMIN, TARGET);
|
||||
|
||||
verify(adminAuditLog).record(ADMIN, "DELETE_UNCONFIRMED", TARGET, null);
|
||||
verifyNoInteractions(accountMailer, accountDeleter);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUnconfirmed_RefusesAnyOtherStatusAndALostRace() {
|
||||
pendingTarget();
|
||||
assertThat(refusal(() -> service.deleteUnconfirmed(ADMIN, TARGET))).isEqualTo("NOT_UNCONFIRMED");
|
||||
target(Role.USER, false);
|
||||
assertThat(refusal(() -> service.deleteUnconfirmed(ADMIN, TARGET))).isEqualTo("NOT_UNCONFIRMED");
|
||||
unconfirmedTarget();
|
||||
when(adminUserRepository.deleteUnconfirmed(TARGET)).thenReturn(false);
|
||||
assertThat(refusal(() -> service.deleteUnconfirmed(ADMIN, TARGET))).isEqualTo("NOT_UNCONFIRMED");
|
||||
|
||||
verifyNoInteractions(adminAuditLog);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decideBlockAndDelete_RefuseAnUnconfirmedRequest() {
|
||||
unconfirmedTarget();
|
||||
|
||||
assertThat(refusal(() -> service.decide(ADMIN, TARGET, true))).isEqualTo("NOT_PENDING");
|
||||
assertThat(refusal(() -> service.setBlocked(ADMIN, TARGET, true))).isEqualTo("TARGET_IS_UNCONFIRMED");
|
||||
assertThat(refusal(() -> service.delete(ADMIN, TARGET))).isEqualTo("TARGET_IS_UNCONFIRMED");
|
||||
verifyNoInteractions(accountDeleter, accountMailer);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package auth_service.modules.login.model;
|
||||
|
||||
import auth_service.modules.registration.model.Registration;
|
||||
import auth_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -20,7 +19,5 @@ class UserTest extends AbstractUnitTest {
|
||||
void toString_ShowsNoPassword() {
|
||||
assertThat(User.builder().email("a@example.com").password("hash-secret").build().toString())
|
||||
.contains("a@example.com").doesNotContain("hash-secret");
|
||||
assertThat(Registration.builder().email("a@example.com").rawPassword("raw-secret").encryptedPassword("hash-secret")
|
||||
.build().toString()).doesNotContain("raw-secret").doesNotContain("hash-secret");
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,15 @@ class LoginServiceTest extends AbstractUnitTest {
|
||||
verify(loginAttemptService).recordSuccess("test@example.com", IP);
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_UnconfirmedUserWithoutAPassword_LooksLikeAnyWrongPassword() {
|
||||
User dbUser = User.builder().id(815L).email("test@example.com").enabled(true).build();
|
||||
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
|
||||
|
||||
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
|
||||
verify(tokenGenerator, never()).generateToken(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_PendingUserWithAWrongPassword_LooksLikeAnyWrongPassword() {
|
||||
User dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true)
|
||||
|
||||
@@ -62,4 +62,13 @@ class MailTemplateTest extends AbstractUnitTest {
|
||||
assertThat(MailTemplate.date("en", date)).isEqualTo("5 November 2026");
|
||||
assertThat(MailTemplate.date(null, date)).isEqualTo("5 November 2026");
|
||||
}
|
||||
|
||||
@Test
|
||||
void render_ConfirmEmail_CarriesTheLinkInBothLanguages() {
|
||||
for (String language : new String[]{"de", "en"}) {
|
||||
Mail mail = MailTemplate.CONFIRM_EMAIL.render(language, "Alice", "https://tbp.example/confirm-email#tok");
|
||||
|
||||
assertThat(mail.body()).contains("https://tbp.example/confirm-email#tok");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ 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.mockito.ArgumentCaptor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -14,9 +13,12 @@ import org.springframework.mail.MailSendException;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
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.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -26,6 +28,7 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
@@ -36,6 +39,9 @@ class MailFlowTest extends AbstractIntegrationTest {
|
||||
private static final String PASSWORD = "correct horse battery";
|
||||
private static final String NEW_USER = "mail-flow-new@example.com";
|
||||
private static final String ADMIN = "mail-flow-admin@example.com";
|
||||
// The per-address mail limit lives in memory for an hour, so the limit tests use an address no other test mails.
|
||||
private static final String OWNER = "mail-flow-owner@example.com";
|
||||
private static final String CAPPED = "mail-flow-capped@example.com";
|
||||
private static final String PLAYER = "mail-flow-player@example.com";
|
||||
|
||||
@Autowired
|
||||
@@ -64,17 +70,19 @@ class MailFlowTest extends AbstractIntegrationTest {
|
||||
}
|
||||
|
||||
private String accessToken(String email) throws Exception {
|
||||
return login(email).andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE).getValue();
|
||||
}
|
||||
|
||||
private ResultActions login(String email) throws Exception {
|
||||
return mockMvc.perform(post("/api/auth/login").header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"" + email + "\",\"password\":\"" + PASSWORD + "\"}"))
|
||||
.andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE).getValue();
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"" + email + "\",\"password\":\"" + PASSWORD + "\"}"));
|
||||
}
|
||||
|
||||
private void register() throws Exception {
|
||||
mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"" + NEW_USER + "\",\"password\":\"" + PASSWORD
|
||||
+ "\",\"displayName\":\"MailFlow\",\"language\":\"de\"}"))
|
||||
.content("{\"email\":\"" + NEW_USER + "\",\"displayName\":\"MailFlow\",\"language\":\"de\"}"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@@ -83,22 +91,48 @@ class MailFlowTest extends AbstractIntegrationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void registration_WritesBothMailsAndTheJobSendsThem() throws Exception {
|
||||
void registration_MailsTheLinkFirstAndTheAdminOnlyAfterTheClick() throws Exception {
|
||||
register();
|
||||
|
||||
assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address") + " " + row.get("status"))
|
||||
.containsExactly("REGISTRATION_REQUESTED admin@test.local PENDING",
|
||||
"REGISTRATION_RECEIVED " + NEW_USER + " PENDING");
|
||||
assertThat(rows().get(1).get("subject")).isEqualTo("Deine ToBePlayed-Registrierung ist angekommen");
|
||||
.containsExactly("CONFIRM_EMAIL " + NEW_USER + " PENDING");
|
||||
assertThat(rows().get(0).get("subject")).isEqualTo("Bestätige deine E-Mail-Adresse für ToBePlayed");
|
||||
String body = jdbc.queryForObject("SELECT body FROM mail_outbox", String.class);
|
||||
Matcher link = Pattern.compile("/confirm-email#(\\S+)").matcher(body);
|
||||
assertThat(link.find()).isTrue();
|
||||
String token = link.group(1);
|
||||
assertThat(body).doesNotContain(jdbc.queryForObject("SELECT email_confirmation_hash FROM users WHERE email = ?",
|
||||
String.class, NEW_USER));
|
||||
|
||||
confirm(token, PASSWORD).andExpect(status().isNoContent());
|
||||
|
||||
assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address"))
|
||||
.containsExactly("CONFIRM_EMAIL " + NEW_USER, "REGISTRATION_REQUESTED admin@test.local");
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM users WHERE email = ?", String.class, NEW_USER)).isEqualTo("PENDING");
|
||||
login(NEW_USER).andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value("ACCOUNT_PENDING"));
|
||||
confirm(token, PASSWORD).andExpect(status().isGone());
|
||||
|
||||
mailOutboxService.sendDue();
|
||||
|
||||
ArgumentCaptor<SimpleMailMessage> sent = ArgumentCaptor.forClass(SimpleMailMessage.class);
|
||||
verify(mailSender, times(2)).send(sent.capture());
|
||||
assertThat(sent.getAllValues()).extracting(SimpleMailMessage::getFrom).containsOnly("noreply@test.local");
|
||||
verify(mailSender, times(2)).send(any(SimpleMailMessage.class));
|
||||
assertThat(rows()).extracting(row -> row.get("status")).containsOnly("SENT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_BeforeTheClick_IsRefusedAndTheUserHasNoPassword() throws Exception {
|
||||
register();
|
||||
|
||||
login(NEW_USER).andExpect(status().isUnauthorized());
|
||||
assertThat(jdbc.queryForObject("SELECT password FROM users WHERE email = ?", String.class, NEW_USER)).isNull();
|
||||
}
|
||||
|
||||
private ResultActions confirm(String token, String password) throws Exception {
|
||||
return mockMvc.perform(post("/api/registration/confirm").header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.header("X-Forwarded-For", freshClientIp()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"token\":\"" + token + "\",\"password\":\"" + password + "\"}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptAndDeny_MailTheUser() throws Exception {
|
||||
insertUser(ADMIN, "PLATFORM_ADMIN");
|
||||
@@ -128,7 +162,7 @@ class MailFlowTest extends AbstractIntegrationTest {
|
||||
assertThat(rows()).extracting(row -> row.get("status") + " " + row.get("attempts") + " " + row.get("last_error"))
|
||||
.containsOnly("PENDING 1 connection refused");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mail_outbox WHERE next_attempt_at > NOW()", Integer.class))
|
||||
.isEqualTo(2);
|
||||
.isEqualTo(1);
|
||||
|
||||
jdbc.update("UPDATE mail_outbox SET attempts = ?, next_attempt_at = NOW()", MailOutboxService.MAX_ATTEMPTS - 1);
|
||||
mailOutboxService.sendDue();
|
||||
@@ -142,8 +176,7 @@ class MailFlowTest extends AbstractIntegrationTest {
|
||||
|
||||
mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"" + NEW_USER + "\",\"password\":\"" + PASSWORD
|
||||
+ "\"}"))
|
||||
.content("{\"email\":\"" + NEW_USER + "\"}"))
|
||||
.andExpect(status().isConflict());
|
||||
|
||||
assertThat(rows()).isEmpty();
|
||||
@@ -192,4 +225,83 @@ class MailFlowTest extends AbstractIntegrationTest {
|
||||
assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address"))
|
||||
.containsExactly("ACCOUNT_DELETED " + PLAYER);
|
||||
}
|
||||
|
||||
private ResultActions registerAs(String email) throws Exception {
|
||||
return mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.header("X-Forwarded-For", freshClientIp()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"" + email + "\",\"language\":\"de\"}"));
|
||||
}
|
||||
|
||||
private int confirmMailsTo(String email) {
|
||||
return jdbc.queryForObject("SELECT COUNT(*) FROM mail_outbox WHERE kind = 'CONFIRM_EMAIL' AND to_address = ?",
|
||||
Integer.class, email);
|
||||
}
|
||||
|
||||
private String newestToken() {
|
||||
String body = jdbc.queryForObject("SELECT body FROM mail_outbox WHERE kind = 'CONFIRM_EMAIL' ORDER BY id DESC LIMIT 1",
|
||||
String.class);
|
||||
Matcher link = Pattern.compile("/confirm-email#(\\S+)").matcher(body);
|
||||
assertThat(link.find()).isTrue();
|
||||
return link.group(1);
|
||||
}
|
||||
|
||||
// A stranger who registers an address first sets no password: the owner's click sets it, and only a link from
|
||||
// the owner's mail can do that. Both mails carry a token of their own; the newest one wins.
|
||||
@Test
|
||||
void strangerRegisteringFirst_cannotLogInToTheOwnersAccount() throws Exception {
|
||||
registerAs(OWNER).andExpect(status().isOk());
|
||||
String strangerToken = newestToken();
|
||||
registerAs(OWNER).andExpect(status().isOk()); // the address owner, later: same row, new link
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE email = ?", Integer.class, OWNER)).isEqualTo(1);
|
||||
|
||||
confirm(strangerToken, "stranger password 1").andExpect(status().isGone());
|
||||
confirm(newestToken(), PASSWORD).andExpect(status().isNoContent());
|
||||
insertAdminAndAccept();
|
||||
|
||||
login(OWNER).andExpect(status().isOk());
|
||||
int strangerLogin = mockMvc.perform(post("/api/auth/login").header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"" + OWNER + "\",\"password\":\"stranger password 1\"}"))
|
||||
.andReturn().getResponse().getStatus();
|
||||
assertThat(strangerLogin).isEqualTo(401);
|
||||
}
|
||||
|
||||
private void insertAdminAndAccept() throws Exception {
|
||||
insertUser(ADMIN, "PLATFORM_ADMIN");
|
||||
long userId = jdbc.queryForObject("SELECT id FROM users WHERE email = ?", Long.class, OWNER);
|
||||
mockMvc.perform(put("/api/admin/users/" + userId + "/decision").header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, accessToken(ADMIN)))
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{\"accepted\":true}"))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
// An address has at most 3 mails per hour over all IPs, and every answer stays the same.
|
||||
@Test
|
||||
void resendFromManyIps_isCappedPerAddress() throws Exception {
|
||||
registerAs(CAPPED).andExpect(status().isOk());
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
mockMvc.perform(post("/api/registration/resend").header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.header("X-Forwarded-For", freshClientIp()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"" + CAPPED + "\"}"))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
for (int i = 0; i < 5; i++) {
|
||||
registerAs(CAPPED).andExpect(status().isOk());
|
||||
}
|
||||
|
||||
assertThat(confirmMailsTo(CAPPED)).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unconfirmedRegistrations_countTowardsThePendingLimit() throws Exception {
|
||||
int maxPending = 20; // RegistrationService.MAX_PENDING
|
||||
|
||||
for (int i = 0; i < maxPending + 1; i++) {
|
||||
registerAs("mail-flow-flood-" + i + "@example.com");
|
||||
}
|
||||
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mail_outbox WHERE kind = 'CONFIRM_EMAIL'", Integer.class))
|
||||
.isEqualTo(maxPending);
|
||||
}
|
||||
}
|
||||
@@ -51,17 +51,26 @@ class MailServiceTest extends AbstractUnitTest {
|
||||
|
||||
assertThat(inserted(MailTemplate.REGISTRATION_REQUESTED, ADMIN).body())
|
||||
.startsWith("Eine neue Registrierung").contains("Alice", "alice@example.com", "https://tbp.example/admin/users");
|
||||
assertThat(inserted(MailTemplate.REGISTRATION_RECEIVED, "alice@example.com").body()).startsWith("Hello Alice,");
|
||||
verify(repository, never()).insert(any(), anyLong(), eq("alice@example.com"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registrationRequested_GreetsByEmailWithoutADisplayName() {
|
||||
void confirmEmail_MailsTheLinkWithTheTokenInTheFragmentInTheUserLanguage() {
|
||||
user("Alice", "de");
|
||||
|
||||
service(ADMIN).confirmEmail(USER, "raw-token");
|
||||
|
||||
Mail mail = inserted(MailTemplate.CONFIRM_EMAIL, "alice@example.com");
|
||||
assertThat(mail.subject()).contains("Bestätige");
|
||||
assertThat(mail.body()).startsWith("Hallo Alice,").contains("https://tbp.example/confirm-email#raw-token");
|
||||
}
|
||||
|
||||
@Test
|
||||
void registrationRequested_NamesTheUserWithoutADisplayNameAsDash() {
|
||||
user(null, "de");
|
||||
|
||||
service(ADMIN).registrationRequested(USER);
|
||||
|
||||
assertThat(inserted(MailTemplate.REGISTRATION_RECEIVED, "alice@example.com").body())
|
||||
.startsWith("Hallo alice@example.com,");
|
||||
assertThat(inserted(MailTemplate.REGISTRATION_REQUESTED, ADMIN).body()).contains("Name: -");
|
||||
}
|
||||
|
||||
@@ -71,8 +80,7 @@ class MailServiceTest extends AbstractUnitTest {
|
||||
|
||||
service("").registrationRequested(USER);
|
||||
|
||||
verify(repository, never()).insert(eq(MailTemplate.REGISTRATION_REQUESTED), anyLong(), anyString(), any());
|
||||
inserted(MailTemplate.REGISTRATION_RECEIVED, "alice@example.com");
|
||||
verify(repository, never()).insert(any(), anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+97
-36
@@ -9,6 +9,7 @@ import org.springframework.http.converter.HttpMessageNotWritableException;
|
||||
import org.hibernate.exception.ConstraintViolationException;
|
||||
import auth_service.configuration.security.JwtService;
|
||||
import auth_service.modules.registration.api.dto.request.RegistrationRequest;
|
||||
import auth_service.modules.registration.exception.ConfirmationInvalidException;
|
||||
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
|
||||
import auth_service.modules.registration.exception.PendingLimitReachedException;
|
||||
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
|
||||
@@ -18,6 +19,7 @@ import auth_service.shared.test.AbstractWebMvcTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters;
|
||||
@@ -37,6 +39,7 @@ import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -83,7 +86,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
// Arrange
|
||||
var request = RegistrationRequest.builder()
|
||||
.email("test@example.com")
|
||||
.password("SuperSecret123!")
|
||||
.build();
|
||||
|
||||
long expectedUserId = 42L;
|
||||
@@ -104,7 +106,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
// Arrange
|
||||
var request = RegistrationRequest.builder()
|
||||
.email("test@example.com")
|
||||
.password("SuperSecret123!")
|
||||
.build();
|
||||
|
||||
when(registrationService.register(any(Registration.class), any()))
|
||||
@@ -124,7 +125,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","steamId":"76561198000000001"}"""))
|
||||
{"email":"test@example.com","inviteCode":"code","steamId":"76561198000000001"}"""))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@@ -132,7 +133,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
void register_DuplicateEmailRace_Is409EmailAlreadyInUse() throws Exception {
|
||||
var request = RegistrationRequest.builder()
|
||||
.email("test@example.com")
|
||||
.password("SuperSecret123!")
|
||||
.build();
|
||||
when(registrationService.register(any(Registration.class), any()))
|
||||
.thenThrow(duplicate("users_email_lower"));
|
||||
@@ -152,7 +152,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"test@example.com","password":"SuperSecret123!","displayName":"Lobo"}"""))
|
||||
{"email":"test@example.com","displayName":"Lobo"}"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("DISPLAY_NAME_ALREADY_IN_USE"));
|
||||
}
|
||||
@@ -164,7 +164,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"test@example.com","password":"SuperSecret123!","language":"de"}"""))
|
||||
{"email":"test@example.com","language":"de"}"""))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
ArgumentCaptor<Registration> registration = ArgumentCaptor.forClass(Registration.class);
|
||||
@@ -177,7 +177,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"test@example.com","password":"SuperSecret123!","language":"fr"}"""))
|
||||
{"email":"test@example.com","language":"fr"}"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
|
||||
}
|
||||
@@ -196,7 +196,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"test@example.com","password":"SuperSecret123!"}"""))
|
||||
{"email":"test@example.com"}"""))
|
||||
.andExpect(status().isInternalServerError())
|
||||
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"))
|
||||
.andExpect(content().string(not(containsString("secret detail"))));
|
||||
@@ -207,7 +207,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"test@example.com","password":"SuperSecret123!","displayName":"x"}"""))
|
||||
{"email":"test@example.com","displayName":"x"}"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
|
||||
.andExpect(jsonPath("$.fields[0].field").value("displayName"));
|
||||
@@ -224,7 +224,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"test@example.com","password":"SuperSecret123!","displayName":"Lobo"}"""))
|
||||
{"email":"test@example.com","displayName":"Lobo"}"""))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
// Assert
|
||||
@@ -243,7 +243,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
.header("X-Forwarded-For", "10.0.0.1, 203.0.113.7")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"test@example.com","password":"SuperSecret123!"}"""))
|
||||
{"email":"test@example.com"}"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.userId").value(42L));
|
||||
}
|
||||
@@ -258,7 +258,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"test@example.com","password":"SuperSecret123!"}"""))
|
||||
{"email":"test@example.com"}"""))
|
||||
.andExpect(status().isTooManyRequests())
|
||||
.andExpect(header().string("Retry-After", "900"))
|
||||
.andExpect(jsonPath("$.code").value("TOO_MANY_ATTEMPTS"));
|
||||
@@ -271,33 +271,15 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"test@example.com","password":"SuperSecret123!"}"""))
|
||||
{"email":"test@example.com"}"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("PENDING_LIMIT_REACHED"));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(delimiter = '|', value = {
|
||||
" |SuperSecret123!",
|
||||
"no-at-sign|SuperSecret123!",
|
||||
"test@example.com| ",
|
||||
"test@example.com|short",
|
||||
})
|
||||
void register_InvalidInput_Is400WithoutRegistering(String email, String password) throws Exception {
|
||||
var request = RegistrationRequest.builder().email(email).password(password).build();
|
||||
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(registrationRequestJson.write(request).getJson()))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verifyNoInteractions(registrationService);
|
||||
}
|
||||
|
||||
// BCrypt uses only 72 bytes: a longer password would mostly be ignored, or refused with a 500.
|
||||
@Test
|
||||
void register_PasswordOver72Bytes_Is400() throws Exception {
|
||||
var request = RegistrationRequest.builder().email("test@example.com").password("é".repeat(37)).build();
|
||||
@ValueSource(strings = {" ", "no-at-sign"})
|
||||
void register_InvalidEmail_Is400WithoutRegistering(String email) throws Exception {
|
||||
var request = RegistrationRequest.builder().email(email).build();
|
||||
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -309,7 +291,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
|
||||
@Test
|
||||
void register_Email_IsTrimmedAndLowerCase() throws Exception {
|
||||
var request = RegistrationRequest.builder().email(" Bob@Example.COM ").password("SuperSecret123!").build();
|
||||
var request = RegistrationRequest.builder().email(" Bob@Example.COM ").build();
|
||||
when(registrationService.register(any(Registration.class), any())).thenReturn(1L);
|
||||
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
@@ -331,10 +313,89 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"test@example.com","password":"SuperSecret123!"}"""))
|
||||
{"email":"test@example.com"}"""))
|
||||
.andExpect(status().isInternalServerError())
|
||||
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"));
|
||||
|
||||
assertTrue(output.getAll().contains("body not writable"), "the 500 left no trace in the log");
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirm_Is204AndPassesTokenPasswordAndIp() throws Exception {
|
||||
mockMvc.perform(post("/api/registration/confirm").header("X-Forwarded-For", "203.0.113.7")
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{\"token\":\"abc\",\"password\":\"SuperSecret123!\"}"))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(registrationService).confirm("abc", "SuperSecret123!", "203.0.113.7");
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirm_AnInvalidLink_Is410ConfirmationInvalid() throws Exception {
|
||||
doThrow(new ConfirmationInvalidException()).when(registrationService).confirm(any(), any(), any());
|
||||
|
||||
mockMvc.perform(post("/api/registration/confirm").contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"token\":\"abc\",\"password\":\"SuperSecret123!\"}"))
|
||||
.andExpect(status().isGone())
|
||||
.andExpect(jsonPath("$.code").value("CONFIRMATION_INVALID"));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(delimiter = '|', value = {
|
||||
"abc| ",
|
||||
"abc|short",
|
||||
"abc|ééééééééééééééééééééééééééééééééééééé",
|
||||
})
|
||||
void confirm_InvalidInput_Is400WithoutConfirming(String token, String password) throws Exception {
|
||||
mockMvc.perform(post("/api/registration/confirm").contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"token\":\"" + token + "\",\"password\":\"" + password + "\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verifyNoInteractions(registrationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirm_BlankToken_Is400() throws Exception {
|
||||
mockMvc.perform(post("/api/registration/confirm").contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"token\":\" \",\"password\":\"SuperSecret123!\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verifyNoInteractions(registrationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirm_NoPassword_Is400() throws Exception {
|
||||
mockMvc.perform(post("/api/registration/confirm").contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"token\":\"abc\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verifyNoInteractions(registrationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resend_Is204AndNormalizesTheEmail() throws Exception {
|
||||
mockMvc.perform(post("/api/registration/resend").contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\" Bob@Example.COM \"}"))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(registrationService).resend(eq("bob@example.com"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resend_BadEmail_Is400() throws Exception {
|
||||
mockMvc.perform(post("/api/registration/resend").contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"no-at-sign\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verifyNoInteractions(registrationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resend_TooManyRequests_Is429WithRetryAfter() throws Exception {
|
||||
doThrow(new TooManyRegistrationAttemptsException(900)).when(registrationService).resend(any(), any());
|
||||
|
||||
mockMvc.perform(post("/api/registration/resend").contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"bob@example.com\"}"))
|
||||
.andExpect(status().isTooManyRequests())
|
||||
.andExpect(header().string("Retry-After", "900"));
|
||||
}
|
||||
}
|
||||
+48
-6
@@ -7,10 +7,13 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@@ -23,7 +26,7 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
private static Registration registration(String email, String displayName) {
|
||||
return Registration.builder().email(email).encryptedPassword("hashed").displayName(displayName).build();
|
||||
return Registration.builder().email(email).displayName(displayName).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -48,7 +51,7 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
Map<String, Object> saved = jdbc.queryForMap("SELECT * FROM users WHERE id = ?", id);
|
||||
assertEquals("save@example.com", saved.get("email"));
|
||||
assertEquals("hashed", saved.get("password"));
|
||||
assertNull(saved.get("password"));
|
||||
assertEquals(true, saved.get("enabled"));
|
||||
assertEquals("Saver", saved.get("display_name"));
|
||||
assertEquals("USER", saved.get("role"));
|
||||
@@ -65,13 +68,13 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void save_StoresAPendingRequestAndCountsIt() {
|
||||
long before = registeredUserRepository.countPending();
|
||||
void save_StoresAnUnconfirmedRequestThatCountsAsWaiting() {
|
||||
long before = registeredUserRepository.countWaiting();
|
||||
|
||||
long id = registeredUserRepository.save(registration("pending@example.com", null));
|
||||
|
||||
assertEquals("PENDING", jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, id));
|
||||
assertEquals(before + 1, registeredUserRepository.countPending());
|
||||
assertEquals("UNCONFIRMED", jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, id));
|
||||
assertEquals(before + 1, registeredUserRepository.countWaiting());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -95,4 +98,43 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
|
||||
// GlobalExceptionController finds the name in the message, in every server language.
|
||||
assertTrue(refused.getMostSpecificCause().getMessage().contains("users_email_lower"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirm_ReturnsTheIdOnceAndMakesTheUserPending() {
|
||||
long id = registeredUserRepository.save(registration("confirm-once@example.com", null));
|
||||
registeredUserRepository.storeConfirmation(id, "hash-once", Instant.now().plusSeconds(60));
|
||||
|
||||
assertEquals(Optional.of(id), registeredUserRepository.confirm("hash-once", "pw-hash", Instant.now()));
|
||||
assertEquals(Optional.empty(), registeredUserRepository.confirm("hash-once", "pw-hash", Instant.now()));
|
||||
assertEquals("PENDING", jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, id));
|
||||
assertEquals("pw-hash", jdbc.queryForObject("SELECT password FROM users WHERE id = ?", String.class, id));
|
||||
assertEquals(Optional.empty(), registeredUserRepository.findUnconfirmedIdByEmail("confirm-once@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirm_AnExpiredHashIsEmptyAndKeepsTheUserUnconfirmed() {
|
||||
long id = registeredUserRepository.save(registration("confirm-late@example.com", null));
|
||||
registeredUserRepository.storeConfirmation(id, "hash-late", Instant.now().minusSeconds(1));
|
||||
|
||||
assertEquals(Optional.empty(), registeredUserRepository.confirm("hash-late", "pw-hash", Instant.now()));
|
||||
assertEquals(Optional.of(id), registeredUserRepository.findUnconfirmedIdByEmail("Confirm-Late@example.com"));
|
||||
assertNull(jdbc.queryForObject("SELECT password FROM users WHERE id = ?", String.class, id));
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeConfirmation_ANewHashInvalidatesTheOldOne() {
|
||||
long id = registeredUserRepository.save(registration("confirm-resend@example.com", null));
|
||||
registeredUserRepository.storeConfirmation(id, "hash-old", Instant.now().plusSeconds(60));
|
||||
registeredUserRepository.storeConfirmation(id, "hash-new", Instant.now().plusSeconds(60));
|
||||
|
||||
assertEquals(Optional.empty(), registeredUserRepository.confirm("hash-old", "pw-hash", Instant.now()));
|
||||
assertEquals(Optional.of(id), registeredUserRepository.confirm("hash-new", "pw-hash", Instant.now()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findUnconfirmedIdByEmail_IgnoresAPendingUser() {
|
||||
jdbc.update("INSERT INTO users (email, password, enabled, status) VALUES ('pend@example.com', 'x', true, 'PENDING')");
|
||||
|
||||
assertEquals(Optional.empty(), registeredUserRepository.findUnconfirmedIdByEmail("pend@example.com"));
|
||||
}
|
||||
}
|
||||
+11
@@ -16,6 +16,7 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@@ -106,4 +107,14 @@ class RegistrationAttemptServiceTest extends AbstractUnitTest {
|
||||
assertTrue(passed.get() <= RegistrationAttemptService.MAX_FAILURES_PER_IP,
|
||||
"attempts that passed the limit: " + passed.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowsThreeConfirmationMailsPerAddressWhateverTheIp() {
|
||||
for (int i = 0; i < RegistrationAttemptService.MAX_CONFIRMATION_MAILS_PER_ADDRESS; i++) {
|
||||
assertTrue(attempts.tryConfirmationMail("a@example.com"));
|
||||
}
|
||||
|
||||
assertFalse(attempts.tryConfirmationMail("a@example.com"));
|
||||
assertTrue(attempts.tryConfirmationMail("b@example.com"));
|
||||
}
|
||||
}
|
||||
+115
-7
@@ -1,5 +1,6 @@
|
||||
package auth_service.modules.registration.service;
|
||||
|
||||
import auth_service.modules.registration.exception.ConfirmationInvalidException;
|
||||
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
|
||||
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
|
||||
import auth_service.modules.registration.exception.PendingLimitReachedException;
|
||||
@@ -7,16 +8,24 @@ import auth_service.modules.registration.exception.TooManyRegistrationAttemptsEx
|
||||
import auth_service.modules.registration.model.Registration;
|
||||
import auth_service.modules.registration.persistence.RegisteredUserRepository;
|
||||
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.InjectMocks;
|
||||
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.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
@@ -47,7 +56,6 @@ class RegistrationServiceTest extends AbstractUnitTest {
|
||||
void setUp() {
|
||||
validRegistration = Registration.builder()
|
||||
.email("test@example.com")
|
||||
.rawPassword("SuperSecret123!")
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -55,8 +63,8 @@ class RegistrationServiceTest extends AbstractUnitTest {
|
||||
void register_Success() {
|
||||
// Arrange
|
||||
long expectedUserId = 42L;
|
||||
when(registrationAttemptService.tryConfirmationMail(any())).thenReturn(true);
|
||||
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
|
||||
when(passwordEncoder.encode(validRegistration.getRawPassword())).thenReturn("hashedPasswordXYZ");
|
||||
when(registeredUserRepository.save(any(Registration.class))).thenReturn(expectedUserId);
|
||||
|
||||
// Act
|
||||
@@ -64,18 +72,85 @@ class RegistrationServiceTest extends AbstractUnitTest {
|
||||
|
||||
// Assert
|
||||
assertEquals(expectedUserId, result);
|
||||
verify(passwordEncoder).encode("SuperSecret123!");
|
||||
verifyNoInteractions(passwordEncoder);
|
||||
verify(registeredUserRepository).save(validRegistration);
|
||||
assertEquals("hashedPasswordXYZ", validRegistration.getEncryptedPassword());
|
||||
verify(registrationAttemptService).acquire(IP);
|
||||
verify(registrationAttemptService).release(IP);
|
||||
verify(registrationAttemptService).countRequest(IP);
|
||||
verify(accountMailer).registrationRequested(expectedUserId);
|
||||
verify(accountMailer, never()).registrationRequested(anyLong());
|
||||
ArgumentCaptor<String> token = ArgumentCaptor.forClass(String.class);
|
||||
verify(accountMailer).confirmEmail(eq(expectedUserId), token.capture());
|
||||
ArgumentCaptor<Instant> expires = ArgumentCaptor.forClass(Instant.class);
|
||||
verify(registeredUserRepository).storeConfirmation(eq(expectedUserId), eq(OpaqueToken.hash(token.getValue())),
|
||||
expires.capture());
|
||||
assertThat(expires.getValue()).isBetween(Instant.now().plus(Duration.ofHours(23)),
|
||||
Instant.now().plus(Duration.ofHours(25)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_AnUnconfirmedEmail_IsNoConflictNoSecondRowButANewLink() {
|
||||
when(registrationAttemptService.tryConfirmationMail(any())).thenReturn(true);
|
||||
when(registeredUserRepository.findUnconfirmedIdByEmail(validRegistration.getEmail())).thenReturn(Optional.of(5L));
|
||||
|
||||
assertEquals(5L, registrationService.register(validRegistration, IP));
|
||||
|
||||
verify(registeredUserRepository, never()).save(any());
|
||||
verify(registeredUserRepository, never()).existsByEmail(anyString());
|
||||
verify(passwordEncoder, never()).encode(any());
|
||||
verify(registrationAttemptService).release(IP);
|
||||
verify(registrationAttemptService).countRequest(IP);
|
||||
verify(accountMailer).confirmEmail(eq(5L), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirm_AValidToken_ReleasesTheAttemptAndTellsTheAdmin() {
|
||||
when(passwordEncoder.encode("SuperSecret123!")).thenReturn("hashedPasswordXYZ");
|
||||
when(registeredUserRepository.confirm(eq(OpaqueToken.hash("raw")), eq("hashedPasswordXYZ"), any()))
|
||||
.thenReturn(Optional.of(9L));
|
||||
|
||||
registrationService.confirm("raw", "SuperSecret123!", IP);
|
||||
|
||||
verify(registrationAttemptService).acquire(IP);
|
||||
verify(registrationAttemptService).release(IP);
|
||||
verify(accountMailer).registrationRequested(9L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirm_AnInvalidToken_CountsAsAFailedAttemptAndMailsNothing() {
|
||||
when(registeredUserRepository.confirm(anyString(), any(), any())).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(ConfirmationInvalidException.class, () -> registrationService.confirm("raw", "SuperSecret123!", IP));
|
||||
|
||||
verify(registrationAttemptService, never()).release(anyString());
|
||||
verifyNoInteractions(accountMailer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resend_AnUnknownAddress_MailsNothingAndDoesNotThrow() {
|
||||
when(registeredUserRepository.findUnconfirmedIdByEmail("nobody@example.com")).thenReturn(Optional.empty());
|
||||
|
||||
registrationService.resend("nobody@example.com", IP);
|
||||
|
||||
verify(registrationAttemptService).countRequest(IP);
|
||||
verifyNoInteractions(accountMailer);
|
||||
verify(registeredUserRepository, never()).storeConfirmation(anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resend_AnUnconfirmedAddress_StoresANewHashAndMailsTheLink() {
|
||||
when(registeredUserRepository.findUnconfirmedIdByEmail("a@example.com")).thenReturn(Optional.of(5L));
|
||||
when(registrationAttemptService.tryConfirmationMail("a@example.com")).thenReturn(true);
|
||||
|
||||
registrationService.resend("a@example.com", IP);
|
||||
|
||||
verify(registrationAttemptService).countRequest(IP);
|
||||
verify(registeredUserRepository).storeConfirmation(eq(5L), anyString(), any());
|
||||
verify(accountMailer).confirmEmail(eq(5L), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_AFullQueue_RefusesAndCountsAsAFailedAttempt() {
|
||||
when(registeredUserRepository.countPending()).thenReturn((long) RegistrationService.MAX_PENDING);
|
||||
when(registeredUserRepository.countWaiting()).thenReturn((long) RegistrationService.MAX_PENDING);
|
||||
|
||||
assertThrows(PendingLimitReachedException.class, () -> registrationService.register(validRegistration, IP));
|
||||
|
||||
@@ -85,10 +160,43 @@ class RegistrationServiceTest extends AbstractUnitTest {
|
||||
verifyNoInteractions(accountMailer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_AFullQueue_StillAnswersAnUnconfirmedAddressWithANewLink() {
|
||||
when(registeredUserRepository.findUnconfirmedIdByEmail(validRegistration.getEmail())).thenReturn(Optional.of(5L));
|
||||
when(registrationAttemptService.tryConfirmationMail(any())).thenReturn(true);
|
||||
|
||||
assertEquals(5L, registrationService.register(validRegistration, IP));
|
||||
|
||||
verify(registeredUserRepository, never()).countWaiting();
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_AnUnconfirmedAddressOverTheMailLimit_AnswersTheSameAndMailsNothing() {
|
||||
when(registeredUserRepository.findUnconfirmedIdByEmail(validRegistration.getEmail())).thenReturn(Optional.of(5L));
|
||||
when(registrationAttemptService.tryConfirmationMail(validRegistration.getEmail())).thenReturn(false);
|
||||
|
||||
assertEquals(5L, registrationService.register(validRegistration, IP));
|
||||
|
||||
verify(registeredUserRepository, never()).storeConfirmation(anyLong(), anyString(), any());
|
||||
verifyNoInteractions(accountMailer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resend_OverTheMailLimit_MailsNothingAndKeepsTheOldLink() {
|
||||
when(registeredUserRepository.findUnconfirmedIdByEmail("a@example.com")).thenReturn(Optional.of(5L));
|
||||
when(registrationAttemptService.tryConfirmationMail("a@example.com")).thenReturn(false);
|
||||
|
||||
registrationService.resend("a@example.com", IP);
|
||||
|
||||
verify(registeredUserRepository, never()).storeConfirmation(anyLong(), anyString(), any());
|
||||
verifyNoInteractions(accountMailer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_OneBelowTheLimit_IsTaken() {
|
||||
when(registeredUserRepository.countPending()).thenReturn((long) RegistrationService.MAX_PENDING - 1);
|
||||
when(registeredUserRepository.countWaiting()).thenReturn((long) RegistrationService.MAX_PENDING - 1);
|
||||
when(registeredUserRepository.save(any(Registration.class))).thenReturn(43L);
|
||||
when(registrationAttemptService.tryConfirmationMail(any())).thenReturn(true);
|
||||
|
||||
assertEquals(43L, registrationService.register(validRegistration, IP));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package auth_service.shared.security;
|
||||
|
||||
import auth_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OpaqueTokenTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void generate_Is43UrlSafeCharsAndDiffersPerCall() {
|
||||
String first = OpaqueToken.generate();
|
||||
|
||||
assertThat(first).matches("[A-Za-z0-9_-]{43}");
|
||||
assertThat(OpaqueToken.generate()).isNotEqualTo(first);
|
||||
}
|
||||
|
||||
@Test
|
||||
void hash_Is64HexCharsAndDeterministic() {
|
||||
assertThat(OpaqueToken.hash("abc")).matches("[0-9a-f]{64}").isEqualTo(OpaqueToken.hash("abc"))
|
||||
.isNotEqualTo(OpaqueToken.hash("abd"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user