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
- 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).
- Brute-force protection in memory: 5 failed logins per email or 20 per IP, and 10 failed registrations per IP,
block for 15 minutes with `429 TOO_MANY_ATTEMPTS` and `Retry-After`. Unknown emails cost the same BCrypt time as a
- Brute-force protection in memory: 5 failed logins per email or 20 per IP, and 10 failed or 3 taken registrations
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.
- 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
@@ -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.
"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`.
- **Mails** from the platform mailbox: "new user registered" to the admin (`APP_MAIL_ADMIN_TO`), and to the user
"your account is active" at the registration, "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
- **Mails** from the platform mailbox: "a new registration waits" to the admin (`APP_MAIL_ADMIN_TO`, with the link
to the admin page), and to the user "we got your registration", then "your account is active" or "your
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".
- **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
@@ -260,7 +260,7 @@ sequenceDiagram
participant B as Browser (Frontend)
participant A as Auth
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
loop every minute (MailOutboxService)
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
```
### 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
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
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
Flyway callback in `db/local` adds the dev user (`PLATFORM_ADMIN`, linked Steam ID). The invite code for more
users is `local`. What Core mocks and how to change the recorded answers: Core readme, "Run locally".
Flyway callback in `db/local` adds the dev user (`PLATFORM_ADMIN`, linked Steam ID). A new local user waits until
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`)
@@ -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_KID` | Key id written into the token header |
| `AUTH_INVITATION_CODE` | Code required for registration |
| `DB_PASSWORD` | Local PostgreSQL password |
| `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_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 |
| `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:
@@ -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");
}
/**