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

+35 -11
View File
@@ -13,10 +13,10 @@ To Be Played is split into three repositories:
## Features ## Features
- Registration with an invitation code, login with email and password (BCrypt). Emails are stored trimmed and in - Registration by admin approval, login with email and password (BCrypt). Emails are stored trimmed and in
lower case (V19, unique on `lower(email)`); a password has 8 characters and at most 72 bytes (BCrypt's limit). lower case (V19, unique on `lower(email)`); a password has 8 characters and at most 72 bytes (BCrypt's limit).
- Brute-force protection in memory: 5 failed logins per email or 20 per IP, and 10 failed registrations per IP, - Brute-force protection in memory: 5 failed logins per email or 20 per IP, and 10 failed or 3 taken registrations
block for 15 minutes with `429 TOO_MANY_ATTEMPTS` and `Retry-After`. Unknown emails cost the same BCrypt time as a per IP, block for 15 minutes with `429 TOO_MANY_ATTEMPTS` and `Retry-After`. Unknown emails cost the same BCrypt time as a
wrong password, and every failed login gets the same error. wrong password, and every failed login gets the same error.
- JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify - JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify
tokens but never create them. Tokens carry a `kid` header, and `GET /.well-known/jwks.json` publishes the tokens but never create them. Tokens carry a `kid` header, and `GET /.well-known/jwks.json` publishes the
@@ -74,9 +74,9 @@ To Be Played is split into three repositories:
and end time, type `DISMISS` or `ACCEPT`). The dashboard shows the running hints one at a time, newest first. and end time, type `DISMISS` or `ACCEPT`). The dashboard shows the running hints one at a time, newest first.
"Do not show again" on a `DISMISS` hint or "OK" on an `ACCEPT` hint is a row in `hint_done`, so the hint never "Do not show again" on a `DISMISS` hint or "OK" on an `ACCEPT` hint is a row in `hint_done`, so the hint never
comes back for this user on any device. Creating and ending a hint are rows in `admin_audit`. comes back for this user on any device. Creating and ending a hint are rows in `admin_audit`.
- **Mails** from the platform mailbox: "new user registered" to the admin (`APP_MAIL_ADMIN_TO`), and to the user - **Mails** from the platform mailbox: "a new registration waits" to the admin (`APP_MAIL_ADMIN_TO`, with the link
"your account is active" at the registration, "your account is blocked" and "your account will be deleted from to the admin page), and to the user "we got your registration", then "your account is active" or "your
<date>". The registration sends the browser language (`de` for a German browser, else `en`), which also becomes registration was denied", later "your account is blocked" and "your account will be deleted from <date>". The registration sends the browser language (`de` for a German browser, else `en`), which also becomes
the user's app language. Plain text only. See "Mail flow". the user's app language. Plain text only. See "Mail flow".
- **Delete my account** with the password (5 wrong tries per 15 minutes, then `429`). It only sets - **Delete my account** with the password (5 wrong tries per 15 minutes, then `429`). It only sets
`users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no access token, no public `users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no access token, no public
@@ -260,7 +260,7 @@ sequenceDiagram
participant B as Browser (Frontend) participant B as Browser (Frontend)
participant A as Auth participant A as Auth
participant M as SMTP (Hostinger) participant M as SMTP (Hostinger)
B->>A: register / admin blocks / delete account B->>A: register / admin decides / admin blocks / delete account
A->>A: event + mail_outbox row (PENDING), one transaction A->>A: event + mail_outbox row (PENDING), one transaction
loop every minute (MailOutboxService) loop every minute (MailOutboxService)
A->>M: up to 20 due rows A->>M: up to 20 due rows
@@ -269,6 +269,31 @@ sequenceDiagram
Note over A: after 10 tries FAILED and a warning in the log; SENT rows are removed after 30 days Note over A: after 10 tries FAILED and a warning in the log; SENT rows are removed after 30 days
``` ```
### Registration flow
A registration is a request: the account is PENDING until a PLATFORM_ADMIN decides. While 20 wait, the
registration answers `409 PENDING_LIMIT_REACHED` and stores nothing. Two requests at the same moment can make it 21.
```mermaid
sequenceDiagram
participant U as New user
participant A as Auth
participant D as Admin
U->>A: POST /api/registration/new
A->>A: users.status = PENDING, two mail rows
A-->>D: mail "a new registration waits"
A-->>U: mail "we got your registration"
U->>A: login: 403 ACCOUNT_PENDING
D->>A: PUT /api/admin/users/{id}/decision {accepted}
alt accepted
A->>A: status = ACTIVE
A-->>U: mail "your account is active"
else denied
A->>A: status = DENIED, deleted_at = now (nightly cleanup after 30 days)
A-->>U: mail "your registration was denied"
end
```
### Steam ID and block sync to Core ### Steam ID and block sync to Core
Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that
@@ -307,8 +332,8 @@ other, then start them in this order (Core also starts without Auth and finds it
In Auth, `local` makes a new signing key at each start (`LocalKeysEnvironmentPostProcessor`, only in this In Auth, `local` makes a new signing key at each start (`LocalKeysEnvironmentPostProcessor`, only in this
profile, and never together with `prod`), so no key is in the repo; after an Auth restart (also a devtools profile, and never together with `prod`), so no key is in the repo; after an Auth restart (also a devtools
restart), log in again. Never use `local` on a server: the dev user's password is public. The restart), log in again. Never use `local` on a server: the dev user's password is public. The
Flyway callback in `db/local` adds the dev user (`PLATFORM_ADMIN`, linked Steam ID). The invite code for more Flyway callback in `db/local` adds the dev user (`PLATFORM_ADMIN`, linked Steam ID). A new local user waits until
users is `local`. What Core mocks and how to change the recorded answers: Core readme, "Run locally". the dev user accepts it on the admin page. What Core mocks and how to change the recorded answers: Core readme, "Run locally".
### With your own keys (profile `dev`) ### With your own keys (profile `dev`)
@@ -332,14 +357,13 @@ Configuration comes from environment variables:
|---|---| |---|---|
| `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY` | Ed25519 keypair, Base64 of the DER bytes (PKCS#8 / X.509) | | `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY` | Ed25519 keypair, Base64 of the DER bytes (PKCS#8 / X.509) |
| `JWT_KID` | Key id written into the token header | | `JWT_KID` | Key id written into the token header |
| `AUTH_INVITATION_CODE` | Code required for registration |
| `DB_PASSWORD` | Local PostgreSQL password | | `DB_PASSWORD` | Local PostgreSQL password |
| `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) | | `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) |
| `APP_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) | | `APP_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) |
| `APP_CORE_INTERNAL_URL` | Core for the nightly account cleanup, required (local `http://localhost:8090`, Docker `http://tbp-core-service:8090`) | | `APP_CORE_INTERNAL_URL` | Core for the nightly account cleanup, required (local `http://localhost:8090`, Docker `http://tbp-core-service:8090`) |
| `SPRING_MAIL_HOST`, `SPRING_MAIL_USERNAME`, `SPRING_MAIL_PASSWORD` | SMTP server and its login (port 465, SSL). No host = mail off | | `SPRING_MAIL_HOST`, `SPRING_MAIL_USERNAME`, `SPRING_MAIL_PASSWORD` | SMTP server and its login (port 465, SSL). No host = mail off |
| `APP_MAIL_FROM` | Sender address, the mailbox of the SMTP login. Needed with a host | | `APP_MAIL_FROM` | Sender address, the mailbox of the SMTP login. Needed with a host |
| `APP_MAIL_ADMIN_TO` | Gets "new user registered". Needed with a host | | `APP_MAIL_ADMIN_TO` | Gets "a new registration waits". Needed with a host |
Generate a keypair: Generate a keypair:
@@ -9,18 +9,42 @@ import java.util.Locale;
*/ */
public enum MailTemplate { public enum MailTemplate {
NEW_USER_REGISTERED( REGISTRATION_REQUESTED(
"New registration on ToBePlayed", """ "New registration on ToBePlayed waits", """
A new user has registered. A new registration waits for your decision.
Name: %s Name: %s
Email: %s Email: %s
Accept or deny it here:
%s
""", """,
"Neue Registrierung bei ToBePlayed", """ "Neue Registrierung bei ToBePlayed wartet", """
Ein neuer Nutzer hat sich registriert. Eine neue Registrierung wartet auf deine Entscheidung.
Name: %s Name: %s
E-Mail: %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( ACCOUNT_ACTIVE(
@@ -31,14 +31,15 @@ public class MailService implements AccountMailer {
} }
@Override @Override
public void userRegistered(long userId) { public void registrationRequested(long userId) {
repository.findRecipient(userId).ifPresent(user -> { repository.findRecipient(userId).ifPresent(user -> {
// Blank only while mail is off: with a host, MailOutboxService stops the start without it. // Blank only while mail is off: with a host, MailOutboxService stops the start without it.
if (!adminTo.isBlank()) { if (!adminTo.isBlank()) {
repository.insert(MailTemplate.NEW_USER_REGISTERED, userId, adminTo, MailTemplate.NEW_USER_REGISTERED repository.insert(MailTemplate.REGISTRATION_REQUESTED, userId, adminTo, MailTemplate.REGISTRATION_REQUESTED
.render(ADMIN_LANGUAGE, user.displayName() == null ? "-" : user.displayName(), user.email())); .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.shared.api.dto.response.ErrorResponse;
import auth_service.modules.registration.exception.EmailAlreadyInUseException; 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.DisplayNameAlreadyRegisteredException;
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException; import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
import lombok.NonNull; import lombok.NonNull;
@@ -40,10 +40,9 @@ public class RegistrationExceptionController {
return response(body, HttpStatus.CONFLICT); return response(body, HttpStatus.CONFLICT);
} }
@ExceptionHandler(InvalidInvitationCodeException.class) @ExceptionHandler(PendingLimitReachedException.class)
public ResponseEntity<ErrorResponse> clientError(InvalidInvitationCodeException ex) { public ResponseEntity<ErrorResponse> clientError(PendingLimitReachedException ex) {
var body = new ErrorResponse("INVALID_INVITATION_CODE", ex.getMessage()); return response(new ErrorResponse("PENDING_LIMIT_REACHED", ex.getMessage()), HttpStatus.CONFLICT);
return response(body, HttpStatus.BAD_REQUEST);
} }
@ExceptionHandler(DisplayNameAlreadyRegisteredException.class) @ExceptionHandler(DisplayNameAlreadyRegisteredException.class)
@@ -11,7 +11,6 @@ public abstract class ApiMapper {
return Registration.builder(). return Registration.builder().
email(registrationRequest.email()). email(registrationRequest.email()).
rawPassword(registrationRequest.password()). rawPassword(registrationRequest.password()).
inviteCode(registrationRequest.inviteCode()).
displayName(registrationRequest.displayName()). displayName(registrationRequest.displayName()).
language(registrationRequest.language()). language(registrationRequest.language()).
build(); build();
@@ -18,7 +18,6 @@ import java.nio.charset.StandardCharsets;
@Builder @Builder
public record RegistrationRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_LENGTH) String email, public record RegistrationRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_LENGTH) String email,
@NotBlank @Size(min = 8) String password, @NotBlank @Size(min = 8) String password,
@NotBlank String inviteCode,
@Pattern(regexp = DisplayNameFormat.PATTERN) String displayName, @Pattern(regexp = DisplayNameFormat.PATTERN) String displayName,
// For the mails; null = never chosen. // For the mails; null = never chosen.
@Pattern(regexp = "de|en") String language) { @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; private String rawPassword;
@ToString.Exclude @ToString.Exclude
private String encryptedPassword; private String encryptedPassword;
private String inviteCode;
private String displayName; private String displayName;
private String language; private String language;
} }
@@ -7,5 +7,10 @@ public interface RegisteredUserRepository {
boolean existsByDisplayName(String displayName); boolean existsByDisplayName(String displayName);
/**
* Stores the user as PENDING: an admin accepts or denies the registration.
*/
long save(Registration registration); long save(Registration registration);
long countPending();
} }
@@ -32,8 +32,8 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository {
@Transactional @Transactional
public long save(Registration registration) { public long save(Registration registration) {
Long id = jdbc.queryForObject(""" Long id = jdbc.queryForObject("""
INSERT INTO users (email, password, enabled, display_name, registration_date) INSERT INTO users (email, password, enabled, display_name, registration_date, status)
VALUES (?, ?, TRUE, ?, ?) RETURNING id""", Long.class, VALUES (?, ?, TRUE, ?, ?, 'PENDING') RETURNING id""", Long.class,
registration.getEmail(), registration.getEncryptedPassword(), registration.getDisplayName(), registration.getEmail(), registration.getEncryptedPassword(), registration.getDisplayName(),
LocalDateTime.now(Clock.systemUTC())); LocalDateTime.now(Clock.systemUTC()));
if (id == null) { if (id == null) {
@@ -44,4 +44,10 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository {
registration.getLanguage()); registration.getLanguage());
return id; 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; import java.time.Duration;
/** /**
* Blocks a client IP after too many failed registrations. Every rejection counts, so guessing the * Limits each client IP: 10 failed registrations (probing which emails have an account) and 3 taken requests (so one
* invitation code and probing which emails have an account are both limited. * IP cannot fill the queue for the admin) within 15 minutes.
*/ */
@Service @Service
public class RegistrationAttemptService { public class RegistrationAttemptService {
static final int MAX_FAILURES_PER_IP = 10; static final int MAX_FAILURES_PER_IP = 10;
static final int MAX_REQUESTS_PER_IP = 3;
static final Duration LOCK_DURATION = Duration.ofMinutes(15); static final Duration LOCK_DURATION = Duration.ofMinutes(15);
private final AttemptLimiter limiter = new AttemptLimiter(LOCK_DURATION); private final AttemptLimiter limiter = new AttemptLimiter(LOCK_DURATION);
private final AttemptLimiter requests = new AttemptLimiter(LOCK_DURATION);
public void acquire(String clientIp) { public void acquire(String clientIp) {
if (!limiter.tryAcquire(clientIp, MAX_FAILURES_PER_IP)) { if (!limiter.tryAcquire(clientIp, MAX_FAILURES_PER_IP)) {
@@ -27,4 +29,13 @@ public class RegistrationAttemptService {
public void release(String clientIp) { public void release(String clientIp) {
limiter.release(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; package auth_service.modules.registration.service;
import auth_service.modules.registration.exception.EmailAlreadyInUseException; 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.DisplayNameAlreadyRegisteredException;
import auth_service.modules.registration.exception.PendingLimitReachedException;
import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.persistence.RegisteredUserRepository; import auth_service.modules.registration.persistence.RegisteredUserRepository;
import auth_service.shared.mail.AccountMailer; import auth_service.shared.mail.AccountMailer;
import auth_service.shared.security.Public; import auth_service.shared.security.Public;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -18,33 +17,39 @@ import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor @RequiredArgsConstructor
public class RegistrationService { public class RegistrationService {
private final CodeValidationService codeValidationService;
private final RegisteredUserRepository registeredUserRepository; private final RegisteredUserRepository registeredUserRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final RegistrationAttemptService registrationAttemptService; private final RegistrationAttemptService registrationAttemptService;
private final AccountMailer accountMailer; 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. // The user and the mail rows commit together.
@Transactional @Transactional
@CacheEvict(value = "publicStats", allEntries = true)
public long register(Registration registration, String clientIp) { public long register(Registration registration, String clientIp) {
registrationAttemptService.acquire(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); validate(registration);
registrationAttemptService.release(clientIp); registrationAttemptService.release(clientIp);
registrationAttemptService.countRequest(clientIp);
var rawPassword = registration.getRawPassword(); var rawPassword = registration.getRawPassword();
registration.setEncryptedPassword(passwordEncoder.encode(rawPassword)); registration.setEncryptedPassword(passwordEncoder.encode(rawPassword));
long userId = registeredUserRepository.save(registration); long userId = registeredUserRepository.save(registration);
accountMailer.userRegistered(userId); accountMailer.registrationRequested(userId);
return userId; return userId;
} }
private void validate(Registration registration) { private void validate(Registration registration) {
if (!codeValidationService.isCodeValid(registration.getInviteCode())) {
throw new InvalidInvitationCodeException("The provided invite code is invalid.");
}
if (registeredUserRepository.existsByEmail(registration.getEmail())) { if (registeredUserRepository.existsByEmail(registration.getEmail())) {
throw new EmailAlreadyInUseException("The provided email address is already in use."); throw new EmailAlreadyInUseException("The provided email address is already in use.");
} }
@@ -9,9 +9,9 @@ import java.time.LocalDate;
public interface AccountMailer { 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); void accountAccepted(long userId);
-4
View File
@@ -10,7 +10,3 @@ app:
cookie-secure: false cookie-secure: false
core: core:
internal-url: "http://localhost:8090" internal-url: "http://localhost:8090"
auth:
registration:
invitation-code: local
+1 -6
View File
@@ -80,11 +80,6 @@ app:
mail: mail:
# The mailbox the SMTP login belongs to; Hostinger refuses any other sender. # The mailbox the SMTP login belongs to; Hostinger refuses any other sender.
from: ${APP_MAIL_FROM:} 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:} admin-to: ${APP_MAIL_ADMIN_TO:}
cron: "0 * * * * *" cron: "0 * * * * *"
# Registration settings
auth:
registration:
invitation-code: ${AUTH_INVITATION_CODE}
@@ -192,20 +192,22 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
} }
@Test @Test
void registration_CountsInThePublicStatsAtOnce() throws Exception { void registration_WaitsAndDoesNotCountUntilTheAccept() throws Exception {
String newEmail = "delete-flow-registered@example.com"; String newEmail = "delete-flow-registered@example.com";
jdbc.update("DELETE FROM users WHERE email = ?", newEmail); jdbc.update("DELETE FROM users WHERE email = ?", newEmail);
long before = totalUsers(); long before = totalUsers();
try { try {
mockMvc.perform(post("/api/registration/new") 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + newEmail + "\",\"password\":\"" + PASSWORD .content("{\"email\":\"" + newEmail + "\",\"password\":\"" + PASSWORD
+ "\",\"inviteCode\":\"test-invite-code\"}")) + "\"}"))
.andExpect(status().is2xxSuccessful()); .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 { } finally {
jdbc.update("DELETE FROM users WHERE email = ?", newEmail); 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); jdbc.update("UPDATE users SET deleted_at = NOW() WHERE email = ?", EMAIL);
mockMvc.perform(post("/api/registration/new") 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD .content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD
+ "\",\"inviteCode\":\"test-invite-code\"}")) + "\"}"))
.andExpect(jsonPath("$.code").value("EMAIL_ALREADY_IN_USE")); .andExpect(jsonPath("$.code").value("EMAIL_ALREADY_IN_USE"));
} }
} }
@@ -17,7 +17,7 @@ class MailTemplateTest extends AbstractUnitTest {
@EnumSource(MailTemplate.class) @EnumSource(MailTemplate.class)
void render_FillsEveryPlaceholderInBothLanguages(MailTemplate template) { void render_FillsEveryPlaceholderInBothLanguages(MailTemplate template) {
for (String language : new String[]{"de", "en"}) { 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.subject()).isNotBlank().doesNotContain("%");
assertThat(mail.body()).contains("Alice").doesNotContain("%s"); assertThat(mail.body()).contains("Alice").doesNotContain("%s");
@@ -71,10 +71,10 @@ class MailFlowTest extends AbstractIntegrationTest {
} }
private void register() throws Exception { 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + NEW_USER + "\",\"password\":\"" + PASSWORD .content("{\"email\":\"" + NEW_USER + "\",\"password\":\"" + PASSWORD
+ "\",\"inviteCode\":\"test-invite-code\",\"displayName\":\"MailFlow\",\"language\":\"de\"}")) + "\",\"displayName\":\"MailFlow\",\"language\":\"de\"}"))
.andExpect(status().isOk()); .andExpect(status().isOk());
} }
@@ -87,9 +87,9 @@ class MailFlowTest extends AbstractIntegrationTest {
register(); register();
assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address") + " " + row.get("status")) assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address") + " " + row.get("status"))
.containsExactly("NEW_USER_REGISTERED admin@test.local PENDING", .containsExactly("REGISTRATION_REQUESTED admin@test.local PENDING",
"ACCOUNT_ACTIVE " + NEW_USER + " PENDING"); "REGISTRATION_RECEIVED " + NEW_USER + " PENDING");
assertThat(rows().get(1).get("subject")).isEqualTo("Dein ToBePlayed-Konto ist aktiv"); assertThat(rows().get(1).get("subject")).isEqualTo("Deine ToBePlayed-Registrierung ist angekommen");
mailOutboxService.sendDue(); mailOutboxService.sendDue();
@@ -99,6 +99,25 @@ class MailFlowTest extends AbstractIntegrationTest {
assertThat(rows()).extracting(row -> row.get("status")).containsOnly("SENT"); 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 @Test
void aFailedSend_IsTriedAgainAndThenGivenUp() throws Exception { void aFailedSend_IsTriedAgainAndThenGivenUp() throws Exception {
register(); register();
@@ -121,10 +140,10 @@ class MailFlowTest extends AbstractIntegrationTest {
void aFailedRegistration_WritesNoMail() throws Exception { void aFailedRegistration_WritesNoMail() throws Exception {
insertUser(NEW_USER, "USER"); 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) .contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + NEW_USER + "\",\"password\":\"" + PASSWORD .content("{\"email\":\"" + NEW_USER + "\",\"password\":\"" + PASSWORD
+ "\",\"inviteCode\":\"test-invite-code\"}")) + "\"}"))
.andExpect(status().isConflict()); .andExpect(status().isConflict());
assertThat(rows()).isEmpty(); assertThat(rows()).isEmpty();
@@ -44,35 +44,35 @@ class MailServiceTest extends AbstractUnitTest {
} }
@Test @Test
void userRegistered_TellsTheAdminInGermanAndTheUserInTheirLanguage() { void registrationRequested_TellsTheAdminInGermanWithTheLinkAndTheUserInTheirLanguage() {
user("Alice", "en"); user("Alice", "en");
service(ADMIN).userRegistered(USER); service(ADMIN).registrationRequested(USER);
assertThat(inserted(MailTemplate.NEW_USER_REGISTERED, ADMIN).body()) assertThat(inserted(MailTemplate.REGISTRATION_REQUESTED, ADMIN).body())
.startsWith("Ein neuer Nutzer").contains("Alice", "alice@example.com"); .startsWith("Eine neue Registrierung").contains("Alice", "alice@example.com", "https://tbp.example/admin/users");
assertThat(inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com").body()) assertThat(inserted(MailTemplate.REGISTRATION_RECEIVED, "alice@example.com").body()).startsWith("Hello Alice,");
.startsWith("Hello Alice,").contains("https://tbp.example");
} }
@Test @Test
void userRegistered_GreetsByEmailWithoutADisplayName() { void registrationRequested_GreetsByEmailWithoutADisplayName() {
user(null, "de"); 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.REGISTRATION_RECEIVED, "alice@example.com").body())
assertThat(inserted(MailTemplate.NEW_USER_REGISTERED, ADMIN).body()).contains("Name: -"); .startsWith("Hallo alice@example.com,");
assertThat(inserted(MailTemplate.REGISTRATION_REQUESTED, ADMIN).body()).contains("Name: -");
} }
@Test @Test
void userRegistered_WritesNoAdminRowWithoutAnAdminAddress() { void registrationRequested_WritesNoAdminRowWithoutAnAdminAddress() {
user("Alice", "en"); user("Alice", "en");
service("").userRegistered(USER); service("").registrationRequested(USER);
verify(repository, never()).insert(eq(MailTemplate.NEW_USER_REGISTERED), anyLong(), anyString(), any()); verify(repository, never()).insert(eq(MailTemplate.REGISTRATION_REQUESTED), anyLong(), anyString(), any());
inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com"); inserted(MailTemplate.REGISTRATION_RECEIVED, "alice@example.com");
} }
@Test @Test
@@ -10,6 +10,7 @@ import org.hibernate.exception.ConstraintViolationException;
import auth_service.configuration.security.JwtService; import auth_service.configuration.security.JwtService;
import auth_service.modules.registration.api.dto.request.RegistrationRequest; import auth_service.modules.registration.api.dto.request.RegistrationRequest;
import auth_service.modules.registration.exception.EmailAlreadyInUseException; 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.exception.TooManyRegistrationAttemptsException;
import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.service.RegistrationService; import auth_service.modules.registration.service.RegistrationService;
@@ -83,7 +84,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
var request = RegistrationRequest.builder() var request = RegistrationRequest.builder()
.email("test@example.com") .email("test@example.com")
.password("SuperSecret123!") .password("SuperSecret123!")
.inviteCode("test-invite-code")
.build(); .build();
long expectedUserId = 42L; long expectedUserId = 42L;
@@ -105,7 +105,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
var request = RegistrationRequest.builder() var request = RegistrationRequest.builder()
.email("test@example.com") .email("test@example.com")
.password("SuperSecret123!") .password("SuperSecret123!")
.inviteCode("test-invite-code")
.build(); .build();
when(registrationService.register(any(Registration.class), any())) when(registrationService.register(any(Registration.class), any()))
@@ -119,7 +118,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
} }
@Test @Test
void register_OldClientSendingASteamId_IsAcceptedAndTheIdIgnored() throws Exception { void register_OldClientSendingASteamIdAndAnInviteCode_IsAcceptedAndBothIgnored() throws Exception {
when(registrationService.register(any(Registration.class), any())).thenReturn(42L); when(registrationService.register(any(Registration.class), any())).thenReturn(42L);
mockMvc.perform(post("/api/registration/new") mockMvc.perform(post("/api/registration/new")
@@ -134,7 +133,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
var request = RegistrationRequest.builder() var request = RegistrationRequest.builder()
.email("test@example.com") .email("test@example.com")
.password("SuperSecret123!") .password("SuperSecret123!")
.inviteCode("code")
.build(); .build();
when(registrationService.register(any(Registration.class), any())) when(registrationService.register(any(Registration.class), any()))
.thenThrow(duplicate("users_email_lower")); .thenThrow(duplicate("users_email_lower"));
@@ -154,7 +152,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new") mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(""" .content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","displayName":"Lobo"}""")) {"email":"test@example.com","password":"SuperSecret123!","displayName":"Lobo"}"""))
.andExpect(status().isConflict()) .andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("DISPLAY_NAME_ALREADY_IN_USE")); .andExpect(jsonPath("$.code").value("DISPLAY_NAME_ALREADY_IN_USE"));
} }
@@ -166,7 +164,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new") mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(""" .content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","language":"de"}""")) {"email":"test@example.com","password":"SuperSecret123!","language":"de"}"""))
.andExpect(status().isOk()); .andExpect(status().isOk());
ArgumentCaptor<Registration> registration = ArgumentCaptor.forClass(Registration.class); ArgumentCaptor<Registration> registration = ArgumentCaptor.forClass(Registration.class);
@@ -179,7 +177,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new") mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(""" .content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","language":"fr"}""")) {"email":"test@example.com","password":"SuperSecret123!","language":"fr"}"""))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); .andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
} }
@@ -198,7 +196,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new") mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(""" .content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code"}""")) {"email":"test@example.com","password":"SuperSecret123!"}"""))
.andExpect(status().isInternalServerError()) .andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR")) .andExpect(jsonPath("$.code").value("INTERNAL_ERROR"))
.andExpect(content().string(not(containsString("secret detail")))); .andExpect(content().string(not(containsString("secret detail"))));
@@ -209,7 +207,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new") mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(""" .content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","displayName":"x"}""")) {"email":"test@example.com","password":"SuperSecret123!","displayName":"x"}"""))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED")) .andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.fields[0].field").value("displayName")); .andExpect(jsonPath("$.fields[0].field").value("displayName"));
@@ -226,7 +224,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new") mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(""" .content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","displayName":"Lobo"}""")) {"email":"test@example.com","password":"SuperSecret123!","displayName":"Lobo"}"""))
.andExpect(status().isOk()); .andExpect(status().isOk());
// Assert // Assert
@@ -245,7 +243,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
.header("X-Forwarded-For", "10.0.0.1, 203.0.113.7") .header("X-Forwarded-For", "10.0.0.1, 203.0.113.7")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(""" .content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code"}""")) {"email":"test@example.com","password":"SuperSecret123!"}"""))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.userId").value(42L)); .andExpect(jsonPath("$.userId").value(42L));
} }
@@ -260,22 +258,33 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new") mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(""" .content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code"}""")) {"email":"test@example.com","password":"SuperSecret123!"}"""))
.andExpect(status().isTooManyRequests()) .andExpect(status().isTooManyRequests())
.andExpect(header().string("Retry-After", "900")) .andExpect(header().string("Retry-After", "900"))
.andExpect(jsonPath("$.code").value("TOO_MANY_ATTEMPTS")); .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 @ParameterizedTest
@CsvSource(delimiter = '|', value = { @CsvSource(delimiter = '|', value = {
" |SuperSecret123!|code", " |SuperSecret123!",
"no-at-sign|SuperSecret123!|code", "no-at-sign|SuperSecret123!",
"test@example.com| |code", "test@example.com| ",
"test@example.com|short|code", "test@example.com|short",
"test@example.com|SuperSecret123!| ",
}) })
void register_InvalidInput_Is400WithoutRegistering(String email, String password, String inviteCode) throws Exception { void register_InvalidInput_Is400WithoutRegistering(String email, String password) throws Exception {
var request = RegistrationRequest.builder().email(email).password(password).inviteCode(inviteCode).build(); var request = RegistrationRequest.builder().email(email).password(password).build();
mockMvc.perform(post("/api/registration/new") mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON) .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. // BCrypt uses only 72 bytes: a longer password would mostly be ignored, or refused with a 500.
@Test @Test
void register_PasswordOver72Bytes_Is400() throws Exception { void register_PasswordOver72Bytes_Is400() throws Exception {
var request = RegistrationRequest.builder().email("test@example.com").password("é".repeat(37)) var request = RegistrationRequest.builder().email("test@example.com").password("é".repeat(37)).build();
.inviteCode("code").build();
mockMvc.perform(post("/api/registration/new") mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -301,8 +309,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
@Test @Test
void register_Email_IsTrimmedAndLowerCase() throws Exception { void register_Email_IsTrimmedAndLowerCase() throws Exception {
var request = RegistrationRequest.builder().email(" Bob@Example.COM ").password("SuperSecret123!") var request = RegistrationRequest.builder().email(" Bob@Example.COM ").password("SuperSecret123!").build();
.inviteCode("code").build();
when(registrationService.register(any(Registration.class), any())).thenReturn(1L); when(registrationService.register(any(Registration.class), any())).thenReturn(1L);
mockMvc.perform(post("/api/registration/new") mockMvc.perform(post("/api/registration/new")
@@ -324,7 +331,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new") mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(""" .content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code"}""")) {"email":"test@example.com","password":"SuperSecret123!"}"""))
.andExpect(status().isInternalServerError()) .andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR")); .andExpect(jsonPath("$.code").value("INTERNAL_ERROR"));
@@ -64,6 +64,16 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
assertEquals(false, settings.get("public_profile")); 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 @Test
void save_StoresTheLanguageOfTheRegistrationPage() { void save_StoresTheLanguageOfTheRegistrationPage() {
Registration registration = registration("lang@example.com", null); Registration registration = registration("lang@example.com", null);
@@ -40,6 +40,17 @@ class RegistrationAttemptServiceTest extends AbstractUnitTest {
assertDoesNotThrow(() -> attempts.acquire("5.6.7.8")); 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 @Test
void successDoesNotUseUpAnAttempt() { void successDoesNotUseUpAnAttempt() {
for (int i = 0; i < RegistrationAttemptService.MAX_FAILURES_PER_IP * 2; i++) { for (int i = 0; i < RegistrationAttemptService.MAX_FAILURES_PER_IP * 2; i++) {
@@ -1,8 +1,8 @@
package auth_service.modules.registration.service; package auth_service.modules.registration.service;
import auth_service.modules.registration.exception.EmailAlreadyInUseException; 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.DisplayNameAlreadyRegisteredException;
import auth_service.modules.registration.exception.PendingLimitReachedException;
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException; import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.persistence.RegisteredUserRepository; import auth_service.modules.registration.persistence.RegisteredUserRepository;
@@ -26,9 +26,6 @@ class RegistrationServiceTest extends AbstractUnitTest {
private static final String IP = "203.0.113.7"; private static final String IP = "203.0.113.7";
@Mock
private CodeValidationService codeValidationService;
@Mock @Mock
private RegisteredUserRepository registeredUserRepository; private RegisteredUserRepository registeredUserRepository;
@@ -51,7 +48,6 @@ class RegistrationServiceTest extends AbstractUnitTest {
validRegistration = Registration.builder() validRegistration = Registration.builder()
.email("test@example.com") .email("test@example.com")
.rawPassword("SuperSecret123!") .rawPassword("SuperSecret123!")
.inviteCode("test-invite-code")
.build(); .build();
} }
@@ -59,7 +55,6 @@ class RegistrationServiceTest extends AbstractUnitTest {
void register_Success() { void register_Success() {
// Arrange // Arrange
long expectedUserId = 42L; long expectedUserId = 42L;
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false); when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
when(passwordEncoder.encode(validRegistration.getRawPassword())).thenReturn("hashedPasswordXYZ"); when(passwordEncoder.encode(validRegistration.getRawPassword())).thenReturn("hashedPasswordXYZ");
when(registeredUserRepository.save(any(Registration.class))).thenReturn(expectedUserId); when(registeredUserRepository.save(any(Registration.class))).thenReturn(expectedUserId);
@@ -74,16 +69,15 @@ class RegistrationServiceTest extends AbstractUnitTest {
assertEquals("hashedPasswordXYZ", validRegistration.getEncryptedPassword()); assertEquals("hashedPasswordXYZ", validRegistration.getEncryptedPassword());
verify(registrationAttemptService).acquire(IP); verify(registrationAttemptService).acquire(IP);
verify(registrationAttemptService).release(IP); verify(registrationAttemptService).release(IP);
verify(accountMailer).userRegistered(expectedUserId); verify(registrationAttemptService).countRequest(IP);
verify(accountMailer).registrationRequested(expectedUserId);
} }
@Test @Test
void register_InvalidInviteCode() { void register_AFullQueue_RefusesAndCountsAsAFailedAttempt() {
// Arrange when(registeredUserRepository.countPending()).thenReturn((long) RegistrationService.MAX_PENDING);
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(false);
// Act & Assert assertThrows(PendingLimitReachedException.class, () -> registrationService.register(validRegistration, IP));
assertThrows(InvalidInvitationCodeException.class, () -> registrationService.register(validRegistration, IP));
verify(registeredUserRepository, never()).existsByEmail(anyString()); verify(registeredUserRepository, never()).existsByEmail(anyString());
verify(registeredUserRepository, never()).save(any()); verify(registeredUserRepository, never()).save(any());
@@ -91,10 +85,27 @@ class RegistrationServiceTest extends AbstractUnitTest {
verifyNoInteractions(accountMailer); 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 @Test
void register_EmailAlreadyInUse() { void register_EmailAlreadyInUse() {
// Arrange // Arrange
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(true); when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(true);
// Act & Assert // Act & Assert
@@ -102,13 +113,13 @@ class RegistrationServiceTest extends AbstractUnitTest {
verify(registeredUserRepository, never()).save(any()); verify(registeredUserRepository, never()).save(any());
verify(registrationAttemptService, never()).release(anyString()); verify(registrationAttemptService, never()).release(anyString());
verify(registrationAttemptService, never()).countRequest(anyString());
} }
@Test @Test
void register_DisplayNameTaken() { void register_DisplayNameTaken() {
// Arrange // Arrange
validRegistration.setDisplayName("Lobo"); validRegistration.setDisplayName("Lobo");
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false); when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
when(registeredUserRepository.existsByDisplayName("Lobo")).thenReturn(true); when(registeredUserRepository.existsByDisplayName("Lobo")).thenReturn(true);
@@ -125,7 +136,7 @@ class RegistrationServiceTest extends AbstractUnitTest {
// Act & Assert // Act & Assert
assertThrows(TooManyRegistrationAttemptsException.class, () -> registrationService.register(validRegistration, IP)); assertThrows(TooManyRegistrationAttemptsException.class, () -> registrationService.register(validRegistration, IP));
verifyNoInteractions(codeValidationService, registeredUserRepository); verifyNoInteractions(registeredUserRepository);
verify(registrationAttemptService, never()).release(anyString()); verify(registrationAttemptService, never()).release(anyString());
} }
} }
@@ -20,6 +20,7 @@ import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.concurrent.atomic.AtomicInteger;
/** /**
* Base class for integration tests. * Base class for integration tests.
@@ -43,6 +44,16 @@ public abstract class AbstractIntegrationTest {
@Autowired @Autowired
private JdbcTemplate baseJdbc; 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. * 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) { static void secrets(DynamicPropertyRegistry registry) {
registry.add("security.jwt.private-key", () -> Base64.getEncoder().encodeToString(KEYS.getPrivate().getEncoded())); 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("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(KEYS.getPublic().getEncoded()));
registry.add("auth.registration.invitation-code", () -> "test-invite-code");
} }
/** /**