feat(registration): registration waits for the admin, no invite code
Build, Test & Push Auth Service / quality-check (push) Successful in 50s
Build, Test & Push Auth Service / docker (push) Successful in 1m20s

This commit is contained in:
lobo committed 2026-10-01 09:18:12 +02:00
1 parent 1008034e82
commit 39a4d5ae49
26 files changed
+261 -147

No files matched your search

@@ -9,18 +9,42 @@ import java.util.Locale;
*/
public enum MailTemplate {
NEW_USER_REGISTERED(
"New registration on ToBePlayed", """
A new user has registered.
REGISTRATION_REQUESTED(
"New registration on ToBePlayed waits", """
A new registration waits for your decision.
Name: %s
Email: %s
Accept or deny it here:
%s
""",
"Neue Registrierung bei ToBePlayed", """
Ein neuer Nutzer hat sich registriert.
"Neue Registrierung bei ToBePlayed wartet", """
Eine neue Registrierung wartet auf deine Entscheidung.
Name: %s
E-Mail: %s
Hier nimmst du sie an oder lehnst sie ab:
%s
"""),
REGISTRATION_RECEIVED(
"Your ToBePlayed registration has arrived", """
Hello %s,
thank you for your registration. An administrator checks each new account.
You get a mail as soon as it is decided.
ToBePlayed
""",
"Deine ToBePlayed-Registrierung ist angekommen", """
Hallo %s,
danke für deine Registrierung. Ein Administrator prüft jedes neue Konto.
Du bekommst eine Mail, sobald entschieden ist.
ToBePlayed
"""),
ACCOUNT_ACTIVE(
@@ -31,14 +31,15 @@ public class MailService implements AccountMailer {
}
@Override
public void userRegistered(long userId) {
public void registrationRequested(long userId) {
repository.findRecipient(userId).ifPresent(user -> {
// Blank only while mail is off: with a host, MailOutboxService stops the start without it.
if (!adminTo.isBlank()) {
repository.insert(MailTemplate.NEW_USER_REGISTERED, userId, adminTo, MailTemplate.NEW_USER_REGISTERED
.render(ADMIN_LANGUAGE, user.displayName() == null ? "-" : user.displayName(), user.email()));
repository.insert(MailTemplate.REGISTRATION_REQUESTED, userId, adminTo, MailTemplate.REGISTRATION_REQUESTED
.render(ADMIN_LANGUAGE, user.displayName() == null ? "-" : user.displayName(), user.email(),
frontendUrl + "/admin/users"));
}
insertForUser(MailTemplate.ACCOUNT_ACTIVE, userId, user, user.greeting(), frontendUrl);
insertForUser(MailTemplate.REGISTRATION_RECEIVED, userId, user, user.greeting());
});
}
@@ -2,7 +2,7 @@ package auth_service.modules.registration.api;
import auth_service.shared.api.dto.response.ErrorResponse;
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
import auth_service.modules.registration.exception.InvalidInvitationCodeException;
import auth_service.modules.registration.exception.PendingLimitReachedException;
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
import lombok.NonNull;
@@ -40,10 +40,9 @@ public class RegistrationExceptionController {
return response(body, HttpStatus.CONFLICT);
}
@ExceptionHandler(InvalidInvitationCodeException.class)
public ResponseEntity<ErrorResponse> clientError(InvalidInvitationCodeException ex) {
var body = new ErrorResponse("INVALID_INVITATION_CODE", ex.getMessage());
return response(body, HttpStatus.BAD_REQUEST);
@ExceptionHandler(PendingLimitReachedException.class)
public ResponseEntity<ErrorResponse> clientError(PendingLimitReachedException ex) {
return response(new ErrorResponse("PENDING_LIMIT_REACHED", ex.getMessage()), HttpStatus.CONFLICT);
}
@ExceptionHandler(DisplayNameAlreadyRegisteredException.class)
@@ -11,7 +11,6 @@ public abstract class ApiMapper {
return Registration.builder().
email(registrationRequest.email()).
rawPassword(registrationRequest.password()).
inviteCode(registrationRequest.inviteCode()).
displayName(registrationRequest.displayName()).
language(registrationRequest.language()).
build();
@@ -18,7 +18,6 @@ import java.nio.charset.StandardCharsets;
@Builder
public record RegistrationRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_LENGTH) String email,
@NotBlank @Size(min = 8) String password,
@NotBlank String inviteCode,
@Pattern(regexp = DisplayNameFormat.PATTERN) String displayName,
// For the mails; null = never chosen.
@Pattern(regexp = "de|en") String language) {
@@ -1,7 +0,0 @@
package auth_service.modules.registration.exception;
public class InvalidInvitationCodeException extends RuntimeException {
public InvalidInvitationCodeException(String message) {
super(message);
}
}
@@ -0,0 +1,7 @@
package auth_service.modules.registration.exception;
public class PendingLimitReachedException extends RuntimeException {
public PendingLimitReachedException() {
super("Too many registrations wait for the admin. Please try again later.");
}
}
@@ -12,7 +12,6 @@ public class Registration {
private String rawPassword;
@ToString.Exclude
private String encryptedPassword;
private String inviteCode;
private String displayName;
private String language;
}
@@ -7,5 +7,10 @@ public interface RegisteredUserRepository {
boolean existsByDisplayName(String displayName);
/**
* Stores the user as PENDING: an admin accepts or denies the registration.
*/
long save(Registration registration);
long countPending();
}
@@ -32,8 +32,8 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository {
@Transactional
public long save(Registration registration) {
Long id = jdbc.queryForObject("""
INSERT INTO users (email, password, enabled, display_name, registration_date)
VALUES (?, ?, TRUE, ?, ?) RETURNING id""", Long.class,
INSERT INTO users (email, password, enabled, display_name, registration_date, status)
VALUES (?, ?, TRUE, ?, ?, 'PENDING') RETURNING id""", Long.class,
registration.getEmail(), registration.getEncryptedPassword(), registration.getDisplayName(),
LocalDateTime.now(Clock.systemUTC()));
if (id == null) {
@@ -44,4 +44,10 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository {
registration.getLanguage());
return id;
}
@Override
public long countPending() {
Long count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE status = 'PENDING'", Long.class);
return count != null ? count : 0L;
}
}
@@ -1,19 +0,0 @@
package auth_service.modules.registration.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class CodeValidationService {
@Value("${auth.registration.invitation-code}")
private String invitationCode;
public boolean isCodeValid(String code) {
if (code == null || code.isEmpty()) {
return false;
}
return code.equals(invitationCode);
}
}
@@ -7,16 +7,18 @@ import org.springframework.stereotype.Service;
import java.time.Duration;
/**
* Blocks a client IP after too many failed registrations. Every rejection counts, so guessing the
* invitation code and probing which emails have an account are both limited.
* Limits each client IP: 10 failed registrations (probing which emails have an account) and 3 taken requests (so one
* IP cannot fill the queue for the admin) within 15 minutes.
*/
@Service
public class RegistrationAttemptService {
static final int MAX_FAILURES_PER_IP = 10;
static final int MAX_REQUESTS_PER_IP = 3;
static final Duration LOCK_DURATION = Duration.ofMinutes(15);
private final AttemptLimiter limiter = new AttemptLimiter(LOCK_DURATION);
private final AttemptLimiter requests = new AttemptLimiter(LOCK_DURATION);
public void acquire(String clientIp) {
if (!limiter.tryAcquire(clientIp, MAX_FAILURES_PER_IP)) {
@@ -27,4 +29,13 @@ public class RegistrationAttemptService {
public void release(String clientIp) {
limiter.release(clientIp);
}
/**
* A request that passed every check; it is never given back.
*/
public void countRequest(String clientIp) {
if (!requests.tryAcquire(clientIp, MAX_REQUESTS_PER_IP)) {
throw new TooManyRegistrationAttemptsException(LOCK_DURATION.toSeconds());
}
}
}
@@ -1,14 +1,13 @@
package auth_service.modules.registration.service;
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
import auth_service.modules.registration.exception.InvalidInvitationCodeException;
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
import auth_service.modules.registration.exception.PendingLimitReachedException;
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.Public;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -18,33 +17,39 @@ import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
public class RegistrationService {
private final CodeValidationService codeValidationService;
private final RegisteredUserRepository registeredUserRepository;
private final PasswordEncoder passwordEncoder;
private final RegistrationAttemptService registrationAttemptService;
private final AccountMailer accountMailer;
static final int MAX_PENDING = 20;
/**
* Stores a request that waits for the admin. When MAX_PENDING wait already, it is refused and counts as a failed
* attempt of the IP.
*/
// The user and the mail rows commit together.
@Transactional
@CacheEvict(value = "publicStats", allEntries = true)
public long register(Registration registration, String clientIp) {
registrationAttemptService.acquire(clientIp);
// ponytail: two requests at the same time can pass at 19 and make 21; a pg_advisory_xact_lock would make it
// exact, but the limit is a brake, not a quota.
if (registeredUserRepository.countPending() >= MAX_PENDING) {
throw new PendingLimitReachedException();
}
validate(registration);
registrationAttemptService.release(clientIp);
registrationAttemptService.countRequest(clientIp);
var rawPassword = registration.getRawPassword();
registration.setEncryptedPassword(passwordEncoder.encode(rawPassword));
long userId = registeredUserRepository.save(registration);
accountMailer.userRegistered(userId);
accountMailer.registrationRequested(userId);
return userId;
}
private void validate(Registration registration) {
if (!codeValidationService.isCodeValid(registration.getInviteCode())) {
throw new InvalidInvitationCodeException("The provided invite code is invalid.");
}
if (registeredUserRepository.existsByEmail(registration.getEmail())) {
throw new EmailAlreadyInUseException("The provided email address is already in use.");
}
@@ -9,9 +9,9 @@ import java.time.LocalDate;
public interface AccountMailer {
/**
* "New user registered" to the admin and "your account is active" to the user.
* "A new registration waits" to the admin and "we got your registration" to the user.
*/
void userRegistered(long userId);
void registrationRequested(long userId);
void accountAccepted(long userId);
-4
View File
@@ -10,7 +10,3 @@ app:
cookie-secure: false
core:
internal-url: "http://localhost:8090"
auth:
registration:
invitation-code: local
+1 -6
View File
@@ -80,11 +80,6 @@ app:
mail:
# The mailbox the SMTP login belongs to; Hostinger refuses any other sender.
from: ${APP_MAIL_FROM:}
# Gets "new user registered". Only in the env file, never in git.
# Gets "a new registration waits". Only in the env file, never in git.
admin-to: ${APP_MAIL_ADMIN_TO:}
cron: "0 * * * * *"
# Registration settings
auth:
registration:
invitation-code: ${AUTH_INVITATION_CODE}
@@ -192,20 +192,22 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
}
@Test
void registration_CountsInThePublicStatsAtOnce() throws Exception {
void registration_WaitsAndDoesNotCountUntilTheAccept() throws Exception {
String newEmail = "delete-flow-registered@example.com";
jdbc.update("DELETE FROM users WHERE email = ?", newEmail);
long before = totalUsers();
try {
mockMvc.perform(post("/api/registration/new")
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
.header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + newEmail + "\",\"password\":\"" + PASSWORD
+ "\",\"inviteCode\":\"test-invite-code\"}"))
+ "\"}"))
.andExpect(status().is2xxSuccessful());
assertThat(totalUsers()).isEqualTo(before + 1);
assertThat(jdbc.queryForObject("SELECT status FROM users WHERE email = ?", String.class, newEmail))
.isEqualTo("PENDING");
assertThat(totalUsers()).isEqualTo(before);
} finally {
jdbc.update("DELETE FROM users WHERE email = ?", newEmail);
}
@@ -216,10 +218,10 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
jdbc.update("UPDATE users SET deleted_at = NOW() WHERE email = ?", EMAIL);
mockMvc.perform(post("/api/registration/new")
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
.header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD
+ "\",\"inviteCode\":\"test-invite-code\"}"))
+ "\"}"))
.andExpect(jsonPath("$.code").value("EMAIL_ALREADY_IN_USE"));
}
}
@@ -17,7 +17,7 @@ class MailTemplateTest extends AbstractUnitTest {
@EnumSource(MailTemplate.class)
void render_FillsEveryPlaceholderInBothLanguages(MailTemplate template) {
for (String language : new String[]{"de", "en"}) {
Mail mail = template.render(language, "Alice", "alice@example.com");
Mail mail = template.render(language, "Alice", "alice@example.com", "https://tbp.example");
assertThat(mail.subject()).isNotBlank().doesNotContain("%");
assertThat(mail.body()).contains("Alice").doesNotContain("%s");
@@ -71,10 +71,10 @@ class MailFlowTest extends AbstractIntegrationTest {
}
private void register() throws Exception {
mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web")
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
+ "\",\"inviteCode\":\"test-invite-code\",\"displayName\":\"MailFlow\",\"language\":\"de\"}"))
+ "\",\"displayName\":\"MailFlow\",\"language\":\"de\"}"))
.andExpect(status().isOk());
}
@@ -87,9 +87,9 @@ class MailFlowTest extends AbstractIntegrationTest {
register();
assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address") + " " + row.get("status"))
.containsExactly("NEW_USER_REGISTERED admin@test.local PENDING",
"ACCOUNT_ACTIVE " + NEW_USER + " PENDING");
assertThat(rows().get(1).get("subject")).isEqualTo("Dein ToBePlayed-Konto ist aktiv");
.containsExactly("REGISTRATION_REQUESTED admin@test.local PENDING",
"REGISTRATION_RECEIVED " + NEW_USER + " PENDING");
assertThat(rows().get(1).get("subject")).isEqualTo("Deine ToBePlayed-Registrierung ist angekommen");
mailOutboxService.sendDue();
@@ -99,6 +99,25 @@ class MailFlowTest extends AbstractIntegrationTest {
assertThat(rows()).extracting(row -> row.get("status")).containsOnly("SENT");
}
@Test
void acceptAndDeny_MailTheUser() throws Exception {
insertUser(ADMIN, "PLATFORM_ADMIN");
long accepted = insertUser(PLAYER, "USER");
long denied = insertUser(NEW_USER, "USER");
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id IN (?, ?)", accepted, denied);
String admin = accessToken(ADMIN);
for (long userId : new long[]{accepted, denied}) {
mockMvc.perform(put("/api/admin/users/" + userId + "/decision").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin))
.contentType(MediaType.APPLICATION_JSON).content("{\"accepted\":" + (userId == accepted) + "}"))
.andExpect(status().isNoContent());
}
assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address"))
.containsExactly("ACCOUNT_ACTIVE " + PLAYER, "REGISTRATION_DENIED " + NEW_USER);
}
@Test
void aFailedSend_IsTriedAgainAndThenGivenUp() throws Exception {
register();
@@ -121,10 +140,10 @@ class MailFlowTest extends AbstractIntegrationTest {
void aFailedRegistration_WritesNoMail() throws Exception {
insertUser(NEW_USER, "USER");
mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web")
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
+ "\",\"inviteCode\":\"test-invite-code\"}"))
+ "\"}"))
.andExpect(status().isConflict());
assertThat(rows()).isEmpty();
@@ -44,35 +44,35 @@ class MailServiceTest extends AbstractUnitTest {
}
@Test
void userRegistered_TellsTheAdminInGermanAndTheUserInTheirLanguage() {
void registrationRequested_TellsTheAdminInGermanWithTheLinkAndTheUserInTheirLanguage() {
user("Alice", "en");
service(ADMIN).userRegistered(USER);
service(ADMIN).registrationRequested(USER);
assertThat(inserted(MailTemplate.NEW_USER_REGISTERED, ADMIN).body())
.startsWith("Ein neuer Nutzer").contains("Alice", "alice@example.com");
assertThat(inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com").body())
.startsWith("Hello Alice,").contains("https://tbp.example");
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,");
}
@Test
void userRegistered_GreetsByEmailWithoutADisplayName() {
void registrationRequested_GreetsByEmailWithoutADisplayName() {
user(null, "de");
service(ADMIN).userRegistered(USER);
service(ADMIN).registrationRequested(USER);
assertThat(inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com").body()).startsWith("Hallo alice@example.com,");
assertThat(inserted(MailTemplate.NEW_USER_REGISTERED, ADMIN).body()).contains("Name: -");
assertThat(inserted(MailTemplate.REGISTRATION_RECEIVED, "alice@example.com").body())
.startsWith("Hallo alice@example.com,");
assertThat(inserted(MailTemplate.REGISTRATION_REQUESTED, ADMIN).body()).contains("Name: -");
}
@Test
void userRegistered_WritesNoAdminRowWithoutAnAdminAddress() {
void registrationRequested_WritesNoAdminRowWithoutAnAdminAddress() {
user("Alice", "en");
service("").userRegistered(USER);
service("").registrationRequested(USER);
verify(repository, never()).insert(eq(MailTemplate.NEW_USER_REGISTERED), anyLong(), anyString(), any());
inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com");
verify(repository, never()).insert(eq(MailTemplate.REGISTRATION_REQUESTED), anyLong(), anyString(), any());
inserted(MailTemplate.REGISTRATION_RECEIVED, "alice@example.com");
}
@Test
@@ -10,6 +10,7 @@ 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.EmailAlreadyInUseException;
import auth_service.modules.registration.exception.PendingLimitReachedException;
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.service.RegistrationService;
@@ -83,7 +84,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
var request = RegistrationRequest.builder()
.email("test@example.com")
.password("SuperSecret123!")
.inviteCode("test-invite-code")
.build();
long expectedUserId = 42L;
@@ -105,7 +105,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
var request = RegistrationRequest.builder()
.email("test@example.com")
.password("SuperSecret123!")
.inviteCode("test-invite-code")
.build();
when(registrationService.register(any(Registration.class), any()))
@@ -119,7 +118,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
}
@Test
void register_OldClientSendingASteamId_IsAcceptedAndTheIdIgnored() throws Exception {
void register_OldClientSendingASteamIdAndAnInviteCode_IsAcceptedAndBothIgnored() throws Exception {
when(registrationService.register(any(Registration.class), any())).thenReturn(42L);
mockMvc.perform(post("/api/registration/new")
@@ -134,7 +133,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
var request = RegistrationRequest.builder()
.email("test@example.com")
.password("SuperSecret123!")
.inviteCode("code")
.build();
when(registrationService.register(any(Registration.class), any()))
.thenThrow(duplicate("users_email_lower"));
@@ -154,7 +152,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","displayName":"Lobo"}"""))
{"email":"test@example.com","password":"SuperSecret123!","displayName":"Lobo"}"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("DISPLAY_NAME_ALREADY_IN_USE"));
}
@@ -166,7 +164,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","language":"de"}"""))
{"email":"test@example.com","password":"SuperSecret123!","language":"de"}"""))
.andExpect(status().isOk());
ArgumentCaptor<Registration> registration = ArgumentCaptor.forClass(Registration.class);
@@ -179,7 +177,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","language":"fr"}"""))
{"email":"test@example.com","password":"SuperSecret123!","language":"fr"}"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
}
@@ -198,7 +196,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code"}"""))
{"email":"test@example.com","password":"SuperSecret123!"}"""))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"))
.andExpect(content().string(not(containsString("secret detail"))));
@@ -209,7 +207,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","displayName":"x"}"""))
{"email":"test@example.com","password":"SuperSecret123!","displayName":"x"}"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.fields[0].field").value("displayName"));
@@ -226,7 +224,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","displayName":"Lobo"}"""))
{"email":"test@example.com","password":"SuperSecret123!","displayName":"Lobo"}"""))
.andExpect(status().isOk());
// Assert
@@ -245,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!","inviteCode":"code"}"""))
{"email":"test@example.com","password":"SuperSecret123!"}"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.userId").value(42L));
}
@@ -260,22 +258,33 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code"}"""))
{"email":"test@example.com","password":"SuperSecret123!"}"""))
.andExpect(status().isTooManyRequests())
.andExpect(header().string("Retry-After", "900"))
.andExpect(jsonPath("$.code").value("TOO_MANY_ATTEMPTS"));
}
@Test
void register_FullQueue_Is409PendingLimitReached() throws Exception {
when(registrationService.register(any(Registration.class), any())).thenThrow(new PendingLimitReachedException());
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!"}"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("PENDING_LIMIT_REACHED"));
}
@ParameterizedTest
@CsvSource(delimiter = '|', value = {
" |SuperSecret123!|code",
"no-at-sign|SuperSecret123!|code",
"test@example.com| |code",
"test@example.com|short|code",
"test@example.com|SuperSecret123!| ",
" |SuperSecret123!",
"no-at-sign|SuperSecret123!",
"test@example.com| ",
"test@example.com|short",
})
void register_InvalidInput_Is400WithoutRegistering(String email, String password, String inviteCode) throws Exception {
var request = RegistrationRequest.builder().email(email).password(password).inviteCode(inviteCode).build();
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)
@@ -288,8 +297,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
// 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))
.inviteCode("code").build();
var request = RegistrationRequest.builder().email("test@example.com").password("é".repeat(37)).build();
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
@@ -301,8 +309,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
@Test
void register_Email_IsTrimmedAndLowerCase() throws Exception {
var request = RegistrationRequest.builder().email(" Bob@Example.COM ").password("SuperSecret123!")
.inviteCode("code").build();
var request = RegistrationRequest.builder().email(" Bob@Example.COM ").password("SuperSecret123!").build();
when(registrationService.register(any(Registration.class), any())).thenReturn(1L);
mockMvc.perform(post("/api/registration/new")
@@ -324,7 +331,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code"}"""))
{"email":"test@example.com","password":"SuperSecret123!"}"""))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"));
@@ -64,6 +64,16 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
assertEquals(false, settings.get("public_profile"));
}
@Test
void save_StoresAPendingRequestAndCountsIt() {
long before = registeredUserRepository.countPending();
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());
}
@Test
void save_StoresTheLanguageOfTheRegistrationPage() {
Registration registration = registration("lang@example.com", null);
@@ -40,6 +40,17 @@ class RegistrationAttemptServiceTest extends AbstractUnitTest {
assertDoesNotThrow(() -> attempts.acquire("5.6.7.8"));
}
@Test
void takesAtMostThreeRequestsPerIp() {
for (int i = 0; i < RegistrationAttemptService.MAX_REQUESTS_PER_IP; i++) {
attempts.countRequest("1.2.3.4");
}
assertThrows(TooManyRegistrationAttemptsException.class, () -> attempts.countRequest("1.2.3.4"));
assertDoesNotThrow(() -> attempts.countRequest("5.6.7.8"));
assertDoesNotThrow(() -> attempts.acquire("1.2.3.4"));
}
@Test
void successDoesNotUseUpAnAttempt() {
for (int i = 0; i < RegistrationAttemptService.MAX_FAILURES_PER_IP * 2; i++) {
@@ -1,8 +1,8 @@
package auth_service.modules.registration.service;
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
import auth_service.modules.registration.exception.InvalidInvitationCodeException;
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
import auth_service.modules.registration.exception.PendingLimitReachedException;
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.persistence.RegisteredUserRepository;
@@ -26,9 +26,6 @@ class RegistrationServiceTest extends AbstractUnitTest {
private static final String IP = "203.0.113.7";
@Mock
private CodeValidationService codeValidationService;
@Mock
private RegisteredUserRepository registeredUserRepository;
@@ -51,7 +48,6 @@ class RegistrationServiceTest extends AbstractUnitTest {
validRegistration = Registration.builder()
.email("test@example.com")
.rawPassword("SuperSecret123!")
.inviteCode("test-invite-code")
.build();
}
@@ -59,7 +55,6 @@ class RegistrationServiceTest extends AbstractUnitTest {
void register_Success() {
// Arrange
long expectedUserId = 42L;
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
when(passwordEncoder.encode(validRegistration.getRawPassword())).thenReturn("hashedPasswordXYZ");
when(registeredUserRepository.save(any(Registration.class))).thenReturn(expectedUserId);
@@ -74,16 +69,15 @@ class RegistrationServiceTest extends AbstractUnitTest {
assertEquals("hashedPasswordXYZ", validRegistration.getEncryptedPassword());
verify(registrationAttemptService).acquire(IP);
verify(registrationAttemptService).release(IP);
verify(accountMailer).userRegistered(expectedUserId);
verify(registrationAttemptService).countRequest(IP);
verify(accountMailer).registrationRequested(expectedUserId);
}
@Test
void register_InvalidInviteCode() {
// Arrange
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(false);
void register_AFullQueue_RefusesAndCountsAsAFailedAttempt() {
when(registeredUserRepository.countPending()).thenReturn((long) RegistrationService.MAX_PENDING);
// Act & Assert
assertThrows(InvalidInvitationCodeException.class, () -> registrationService.register(validRegistration, IP));
assertThrows(PendingLimitReachedException.class, () -> registrationService.register(validRegistration, IP));
verify(registeredUserRepository, never()).existsByEmail(anyString());
verify(registeredUserRepository, never()).save(any());
@@ -91,10 +85,27 @@ class RegistrationServiceTest extends AbstractUnitTest {
verifyNoInteractions(accountMailer);
}
@Test
void register_OneBelowTheLimit_IsTaken() {
when(registeredUserRepository.countPending()).thenReturn((long) RegistrationService.MAX_PENDING - 1);
when(registeredUserRepository.save(any(Registration.class))).thenReturn(43L);
assertEquals(43L, registrationService.register(validRegistration, IP));
}
@Test
void register_TooManyRequestsFromOneIp_SavesNothing() {
doThrow(new TooManyRegistrationAttemptsException(900)).when(registrationAttemptService).countRequest(IP);
assertThrows(TooManyRegistrationAttemptsException.class, () -> registrationService.register(validRegistration, IP));
verify(registeredUserRepository, never()).save(any());
verifyNoInteractions(accountMailer);
}
@Test
void register_EmailAlreadyInUse() {
// Arrange
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(true);
// Act & Assert
@@ -102,13 +113,13 @@ class RegistrationServiceTest extends AbstractUnitTest {
verify(registeredUserRepository, never()).save(any());
verify(registrationAttemptService, never()).release(anyString());
verify(registrationAttemptService, never()).countRequest(anyString());
}
@Test
void register_DisplayNameTaken() {
// Arrange
validRegistration.setDisplayName("Lobo");
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
when(registeredUserRepository.existsByDisplayName("Lobo")).thenReturn(true);
@@ -125,7 +136,7 @@ class RegistrationServiceTest extends AbstractUnitTest {
// Act & Assert
assertThrows(TooManyRegistrationAttemptsException.class, () -> registrationService.register(validRegistration, IP));
verifyNoInteractions(codeValidationService, registeredUserRepository);
verifyNoInteractions(registeredUserRepository);
verify(registrationAttemptService, never()).release(anyString());
}
}
@@ -20,6 +20,7 @@ import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Date;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Base class for integration tests.
@@ -43,6 +44,16 @@ public abstract class AbstractIntegrationTest {
@Autowired
private JdbcTemplate baseJdbc;
private static final AtomicInteger NEXT_CLIENT = new AtomicInteger();
/**
* For X-Forwarded-For: a new client address per registration, because Auth takes at most 3 per address.
*/
protected static String freshClientIp() {
int client = NEXT_CLIENT.incrementAndGet();
return "198.18." + client / 250 + "." + (client % 250 + 1);
}
/**
* For users a test inserts by SQL. Registration inserts the row itself.
*/
@@ -58,7 +69,6 @@ public abstract class AbstractIntegrationTest {
static void secrets(DynamicPropertyRegistry registry) {
registry.add("security.jwt.private-key", () -> Base64.getEncoder().encodeToString(KEYS.getPrivate().getEncoded()));
registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(KEYS.getPublic().getEncoded()));
registry.add("auth.registration.invitation-code", () -> "test-invite-code");
}
/**