Compare commits

...
2 Commits
Author SHA1 Message Date
lobo 39a4d5ae49 feat(registration): registration waits for the admin, no invite code
Build, Test & Push Auth Service / docker (push) Successful in 1m20s
Build, Test & Push Auth Service / quality-check (push) Successful in 50s
2026-10-01 09:18:12 +02:00
lobo 1008034e82 feat(admin): accept or deny registrations 2026-10-01 09:18:12 +02:00
49 changed files with 666 additions and 191 deletions

No files matched your search

+45 -14
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
@@ -65,13 +65,18 @@ To Be Played is split into three repositories:
(`users.enabled`; blocking ends every login and, within 30 s, the access token at Auth; Core accepts the token
until it expires, max 15 min; the login then answers `403 ACCOUNT_BLOCKED`, but only after the right password), delete (the same flag and nightly cleanup as "Delete my account"). No action on the own account
or on another admin. Every action and every "view as" is a row in `admin_audit`.
Accept or deny an account that waits (`users.status` PENDING; the login answers `403 ACCOUNT_PENDING` after the
right password): accept makes it ACTIVE and mails "your account is active"; deny makes it DENIED, mails the user
and sets `deleted_at`, so the login answers like a deleted account and the nightly cleanup removes it after 30
days. A waiting account cannot be blocked or deleted. Pending and denied users are nowhere public
(`shared/account/ActiveUser`).
- **Dashboard hints**: a PLATFORM_ADMIN writes a hint for all users (plain text in English, German optional, start
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
@@ -93,9 +98,11 @@ To Be Played is split into three repositories:
| `POST` | `/api/users/steam-link` | access token, returns Steam's login URL and sets the binding cookie |
| `DELETE` | `/api/users/steam-link` | access token, removes the Steam ID and sets a new access cookie |
| `POST` | `/api/auth/view-as` (`{role}`, null = own role; new cookies; `403 ROLE_NOT_ALLOWED` above the own role) | refresh cookie |
| `GET` | `/api/admin/users?query=&page=&size=` (`{content, page, size, totalElements, totalPages}`) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/users/{id}/blocked` (`{blocked}`; `409 SELF_ACTION`, `TARGET_IS_ADMIN`, `ALREADY_DELETED`) | PLATFORM_ADMIN |
| `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
| `GET` | `/api/admin/users?query=&status=&page=&size=` (`{content, page, size, totalElements, totalPages}`; `status` PENDING, ACTIVE or DENIED, empty for all; each user has `status`) | PLATFORM_ADMIN |
| `GET` | `/api/admin/users/pending-count` (`{count}`: registrations that wait) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/users/{id}/decision` (`{accepted}`; `204`; `409 NOT_PENDING`) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/users/{id}/blocked` (`{blocked}`; `409 SELF_ACTION`, `TARGET_IS_ADMIN`, `ALREADY_DELETED`, `TARGET_IS_PENDING`) | PLATFORM_ADMIN |
| `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`, `TARGET_IS_PENDING`) | PLATFORM_ADMIN |
| `GET` | `/api/hints` (`{results}`: running hints not closed for good, newest first) | access token |
| `PUT` | `/api/hints/{id}/done` (`204`; `404 HINT_NOT_FOUND`) | access token |
| `GET` | `/api/admin/hints` (`{results}` with `status` PLANNED/ACTIVE/ENDED by the server clock and `doneCount`, newest first) | PLATFORM_ADMIN |
@@ -253,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
@@ -262,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
@@ -300,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`)
@@ -325,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:
@@ -1,8 +1,10 @@
package auth_service.modules.admin.api;
import auth_service.modules.admin.api.dto.request.BlockRequest;
import auth_service.modules.admin.api.dto.request.DecisionRequest;
import auth_service.modules.admin.api.dto.response.AdminUserPageResponse;
import auth_service.modules.admin.api.dto.response.AdminUserResponse;
import auth_service.modules.admin.api.dto.response.PendingCountResponse;
import auth_service.modules.admin.model.AdminUser;
import auth_service.modules.admin.model.AdminUserPage;
import auth_service.modules.admin.service.AdminUserService;
@@ -29,13 +31,26 @@ public class AdminUserController {
@GetMapping
public ResponseEntity<AdminUserPageResponse> findUsers(@RequestParam(defaultValue = "") String query,
@RequestParam(defaultValue = "") String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
AdminUserPage found = adminUserService.findUsers(query, page, size);
AdminUserPage found = adminUserService.findUsers(query, status, page, size);
return ResponseEntity.ok(new AdminUserPageResponse(found.content().stream().map(AdminUserController::toApi).toList(),
found.page(), found.size(), found.totalElements(), found.totalPages()));
}
@GetMapping("/pending-count")
public ResponseEntity<PendingCountResponse> countPending() {
return ResponseEntity.ok(new PendingCountResponse(adminUserService.countPending()));
}
@PutMapping(value = "/{userId}/decision")
public ResponseEntity<Void> decide(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId,
@Valid @RequestBody DecisionRequest request) {
adminUserService.decide(principal.getIdAsLong(), userId, request.accepted());
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/{userId}/blocked")
public ResponseEntity<Void> setBlocked(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId,
@Valid @RequestBody BlockRequest request) {
@@ -51,6 +66,7 @@ public class AdminUserController {
private static AdminUserResponse toApi(AdminUser user) {
return new AdminUserResponse(user.id(), user.email(), user.displayName(), user.role().name(),
user.registrationDate(), user.lastLoginDate(), user.steamLinked(), user.blocked(), user.deletedAt());
user.registrationDate(), user.lastLoginDate(), user.steamLinked(), user.blocked(), user.deletedAt(),
user.status());
}
}
@@ -0,0 +1,6 @@
package auth_service.modules.admin.api.dto.request;
import jakarta.validation.constraints.NotNull;
public record DecisionRequest(@NotNull Boolean accepted) {
}
@@ -4,5 +4,6 @@ import java.time.Instant;
import java.time.LocalDateTime;
public record AdminUserResponse(long id, String email, String displayName, String role, LocalDateTime registrationDate,
LocalDateTime lastLoginDate, boolean steamLinked, boolean blocked, Instant deletedAt) {
LocalDateTime lastLoginDate, boolean steamLinked, boolean blocked, Instant deletedAt,
String status) {
}
@@ -0,0 +1,4 @@
package auth_service.modules.admin.api.dto.response;
public record PendingCountResponse(long count) {
}
@@ -5,7 +5,7 @@ import lombok.Getter;
@Getter
public class AdminActionRefusedException extends RuntimeException {
/**
* SELF_ACTION, TARGET_IS_ADMIN or ALREADY_DELETED
* SELF_ACTION, TARGET_IS_ADMIN, ALREADY_DELETED, NOT_PENDING or TARGET_IS_PENDING
*/
private final String code;
@@ -6,8 +6,10 @@ import java.time.Instant;
import java.time.LocalDateTime;
/**
* @param status PENDING (registration waits for an admin), ACTIVE or DENIED
* @param deletedAt set while the account waits for the nightly cleanup
*/
public record AdminUser(long id, String email, String displayName, Role role, LocalDateTime registrationDate,
LocalDateTime lastLoginDate, boolean steamLinked, boolean blocked, Instant deletedAt) {
LocalDateTime lastLoginDate, boolean steamLinked, boolean blocked, Instant deletedAt,
String status) {
}
@@ -2,5 +2,5 @@ package auth_service.modules.admin.model;
import auth_service.shared.security.Role;
public record TargetState(Role role, boolean deleted) {
public record TargetState(Role role, boolean deleted, boolean pending) {
}
@@ -22,6 +22,7 @@ public class AdminUserRepository {
// An empty query matches every user; % and _ in the query are searched as text.
private static final String MATCHES = "(? = '' OR email ILIKE ? ESCAPE '\\' OR display_name ILIKE ? ESCAPE '\\')";
private static final String HAS_STATUS = "(? = '' OR status = ?)";
private static final RowMapper<AdminUser> ROW = (rs, row) -> new AdminUser(
rs.getLong("id"),
@@ -32,7 +33,8 @@ public class AdminUserRepository {
localDateTime(rs.getTimestamp("last_login_date")),
rs.getObject("steam_id") != null,
!rs.getBoolean("enabled"),
rs.getTimestamp("deleted_at") != null ? rs.getTimestamp("deleted_at").toInstant() : null);
rs.getTimestamp("deleted_at") != null ? rs.getTimestamp("deleted_at").toInstant() : null,
rs.getString("status"));
private final JdbcTemplate jdbc;
@@ -50,27 +52,49 @@ public class AdminUserRepository {
}
/**
* @param query part of the email or display name, any case; empty for all users
* @param query part of the email or display name, any case; empty for all users
* @param status PENDING, ACTIVE or DENIED; empty for all
*/
public List<AdminUser> findPage(String query, int page, int size) {
public List<AdminUser> findPage(String query, String status, int page, int size) {
Object[] match = matchArgs(query);
return jdbc.query("""
SELECT id, email, display_name, role, registration_date, last_login_date, steam_id, enabled, deleted_at
FROM users WHERE %s ORDER BY id LIMIT ? OFFSET ?""".formatted(MATCHES), ROW,
match[0], match[1], match[2], size, (long) page * size);
SELECT id, email, display_name, role, registration_date, last_login_date, steam_id, enabled, deleted_at,
status
FROM users WHERE %s AND %s ORDER BY id LIMIT ? OFFSET ?""".formatted(MATCHES, HAS_STATUS), ROW,
match[0], match[1], match[2], status, status, size, (long) page * size);
}
public long count(String query) {
Long count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE " + MATCHES, Long.class, matchArgs(query));
public long count(String query, String status) {
Object[] match = matchArgs(query);
Long count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE " + MATCHES + " AND " + HAS_STATUS, Long.class,
match[0], match[1], match[2], status, status);
return count != null ? count : 0L;
}
public Optional<TargetState> findState(long userId) {
return jdbc.query("SELECT role, deleted_at FROM users WHERE id = ?",
(rs, row) -> new TargetState(Role.parse(rs.getString("role")), rs.getTimestamp("deleted_at") != null),
return jdbc.query("SELECT role, deleted_at, status FROM users WHERE id = ?",
(rs, row) -> new TargetState(Role.parse(rs.getString("role")), rs.getTimestamp("deleted_at") != null,
"PENDING".equals(rs.getString("status"))),
userId).stream().findFirst();
}
/**
* @return false when the user is not pending (any more)
*/
public boolean accept(long userId) {
return jdbc.update("UPDATE users SET status = 'ACTIVE' WHERE id = ? AND status = 'PENDING'", userId) > 0;
}
/**
* deleted_at hands the request to the nightly cleanup, like a deleted account.
*
* @return false when the user is not pending (any more)
*/
public boolean deny(long userId, Instant now) {
return jdbc.update("UPDATE users SET status = 'DENIED', deleted_at = ? WHERE id = ? AND status = 'PENDING'",
Timestamp.from(now), userId) > 0;
}
/**
* Blocking also ends every login of the user.
*
@@ -34,20 +34,44 @@ public class AdminUserService {
private final AccountStatePublisher accountStatePublisher;
private final AccountMailer accountMailer;
public AdminUserPage findUsers(String query, int page, int size) {
public AdminUserPage findUsers(String query, String status, int page, int size) {
String search = query == null ? "" : query.trim();
String withStatus = status == null ? "" : status;
int pageSize = Math.clamp(size, 1, MAX_PAGE_SIZE);
int pageNumber = Math.max(page, 0);
long total = adminUserRepository.count(search);
return new AdminUserPage(adminUserRepository.findPage(search, pageNumber, pageSize), pageNumber, pageSize, total,
(int) Math.ceil((double) total / pageSize));
long total = adminUserRepository.count(search, withStatus);
return new AdminUserPage(adminUserRepository.findPage(search, withStatus, pageNumber, pageSize), pageNumber,
pageSize, total, (int) Math.ceil((double) total / pageSize));
}
public long countPending() {
return adminUserRepository.count("", "PENDING");
}
@Transactional
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#targetUserId"),
@CacheEvict(value = "publicStats", allEntries = true)})
public void decide(long actorUserId, long targetUserId, boolean accepted) {
checkedTarget(actorUserId, targetUserId);
boolean changed = accepted ? adminUserRepository.accept(targetUserId)
: adminUserRepository.deny(targetUserId, Instant.now());
if (!changed) {
throw new AdminActionRefusedException("NOT_PENDING", "The registration is already decided");
}
adminAuditLog.record(actorUserId, accepted ? "ACCEPT" : "DENY", targetUserId, null);
if (accepted) {
accountMailer.accountAccepted(targetUserId);
} else {
accountMailer.registrationDenied(targetUserId);
}
log.info("Admin {} {} the registration of user {}", actorUserId, accepted ? "accepted" : "denied", targetUserId);
}
@Transactional
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#targetUserId"),
@CacheEvict(value = "publicStats", allEntries = true)})
public void setBlocked(long actorUserId, long targetUserId, boolean blocked) {
TargetState target = checkedTarget(actorUserId, targetUserId);
TargetState target = notPending(checkedTarget(actorUserId, targetUserId));
if (target.deleted()) {
throw new AdminActionRefusedException("ALREADY_DELETED", "The account is already deleted");
}
@@ -66,7 +90,7 @@ public class AdminUserService {
* transaction: the flag commits before the port calls Core, so Core never hides a user Auth did not flag.
*/
public void delete(long actorUserId, long targetUserId) {
checkedTarget(actorUserId, targetUserId);
notPending(checkedTarget(actorUserId, targetUserId));
if (accountDeleter.deleteByAdmin(targetUserId)) {
adminAuditLog.record(actorUserId, "DELETE", targetUserId, null);
log.info("Admin {} deleted user {}", actorUserId, targetUserId);
@@ -84,4 +108,11 @@ public class AdminUserService {
}
return target;
}
private static TargetState notPending(TargetState target) {
if (target.pending()) {
throw new AdminActionRefusedException("TARGET_IS_PENDING", "Accept or deny the registration first");
}
return target;
}
}
@@ -2,6 +2,7 @@ package auth_service.modules.login.api;
import auth_service.shared.api.dto.response.ErrorResponse;
import auth_service.modules.login.exception.AccountBlockedException;
import auth_service.modules.login.exception.AccountPendingException;
import auth_service.modules.login.exception.InvalidCredentialsException;
import auth_service.modules.login.exception.RoleNotAllowedException;
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
@@ -45,6 +46,11 @@ public class LoginExceptionController {
return response(new ErrorResponse("ACCOUNT_BLOCKED", ex.getMessage()), HttpStatus.FORBIDDEN);
}
@ExceptionHandler(AccountPendingException.class)
public ResponseEntity<ErrorResponse> clientError(AccountPendingException ex) {
return response(new ErrorResponse("ACCOUNT_PENDING", ex.getMessage()), HttpStatus.FORBIDDEN);
}
@ExceptionHandler(RoleNotAllowedException.class)
public ResponseEntity<ErrorResponse> clientError(RoleNotAllowedException ex) {
return response(new ErrorResponse("ROLE_NOT_ALLOWED", ex.getMessage()), HttpStatus.FORBIDDEN);
@@ -0,0 +1,7 @@
package auth_service.modules.login.exception;
public class AccountPendingException extends RuntimeException {
public AccountPendingException() {
super("An admin has not accepted this account yet");
}
}
@@ -15,10 +15,11 @@ public class User {
private Long steamId;
private boolean enabled;
private boolean deleted;
private boolean pending;
@Builder.Default
private Role role = Role.USER;
public boolean canLogin() {
return enabled && !deleted;
return enabled && !deleted && !pending;
}
}
@@ -17,7 +17,8 @@ import java.util.Optional;
public class MinimalUserRepositoryImpl implements MinimalUserRepository {
private static final String SELECT_USER =
"SELECT id, email, password, steam_id, enabled, deleted_at IS NOT NULL AS deleted, role FROM users ";
"SELECT id, email, password, steam_id, enabled, deleted_at IS NOT NULL OR status = 'DENIED' AS deleted, status = 'PENDING' AS pending, "
+ "role FROM users ";
private static final RowMapper<User> USER_ROW = (rs, row) -> User.builder()
.id(rs.getLong("id"))
@@ -26,6 +27,7 @@ public class MinimalUserRepositoryImpl implements MinimalUserRepository {
.steamId(rs.getObject("steam_id", Long.class))
.enabled(rs.getBoolean("enabled"))
.deleted(rs.getBoolean("deleted"))
.pending(rs.getBoolean("pending"))
.role(Role.parse(rs.getString("role")))
.build();
@@ -3,6 +3,7 @@ package auth_service.modules.login.service;
import auth_service.shared.security.Public;
import auth_service.shared.security.TokenGenerator;
import auth_service.modules.login.exception.AccountBlockedException;
import auth_service.modules.login.exception.AccountPendingException;
import auth_service.modules.login.exception.InvalidCredentialsException;
import auth_service.modules.login.model.Session;
import auth_service.modules.login.model.User;
@@ -44,7 +45,7 @@ public class LoginService {
/**
* Authenticates a user and starts a session. Unknown email, wrong password and deleted account all fail with the
* same InvalidCredentialsException; a blocked account says so, but only after the right password.
* same InvalidCredentialsException; a pending or blocked account says so, but only after the right password.
*
* @param previousRefreshToken the refresh cookie this browser still sends, or null. The new cookie replaces it,
* so its login is revoked; else it would stay valid unseen for 30 days.
@@ -60,9 +61,13 @@ public class LoginService {
if (foundUser.isEmpty() || !passwordMatches || foundUser.get().isDeleted()) {
throw new InvalidCredentialsException("Invalid email or password");
}
// Only after the right password, so a wrong one never tells that the account exists and is blocked.
// Only after the right password, so a wrong one never tells that the account exists and is pending or blocked.
// The right password is no failed attempt; else the user gets 429 and stays locked after the accept or unblock.
if (foundUser.get().isPending()) {
loginAttemptService.recordSuccess(email, clientIp);
throw new AccountPendingException();
}
if (!foundUser.get().isEnabled()) {
// The right password is no failed attempt; else the user gets 429 and stays locked after an unblock.
loginAttemptService.recordSuccess(email, clientIp);
throw new AccountBlockedException();
}
@@ -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(
@@ -41,6 +65,24 @@ public enum MailTemplate {
ToBePlayed
"""),
REGISTRATION_DENIED(
"Your ToBePlayed registration was denied", """
Hello %s,
an administrator has denied your registration, so no account was made.
Your data will be deleted in 30 days.
ToBePlayed
""",
"Deine ToBePlayed-Registrierung wurde abgelehnt", """
Hallo %s,
ein Administrator hat deine Registrierung abgelehnt. Es gibt kein Konto für dich.
Deine Daten werden in 30 Tagen gelöscht.
ToBePlayed
"""),
ACCOUNT_BLOCKED(
"Your ToBePlayed account is blocked", """
Hello %s,
@@ -31,17 +31,30 @@ 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());
});
}
@Override
public void accountAccepted(long userId) {
repository.findRecipient(userId).ifPresent(user ->
insertForUser(MailTemplate.ACCOUNT_ACTIVE, userId, user, user.greeting(), frontendUrl));
}
@Override
public void registrationDenied(long userId) {
repository.findRecipient(userId).ifPresent(user ->
insertForUser(MailTemplate.REGISTRATION_DENIED, userId, user, user.greeting()));
}
@Override
public void accountBlocked(long userId) {
repository.findRecipient(userId).ifPresent(user ->
@@ -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.");
}
@@ -1,7 +1,7 @@
package auth_service.shared.account;
/**
* The SQL rule for a user who may log in and is shown to others: not blocked and not deleted.
* The SQL rule for a user who may log in and is shown to others: not blocked, not deleted, accepted by an admin.
*/
public final class ActiveUser {
@@ -9,6 +9,6 @@ public final class ActiveUser {
}
public static String of(String alias) {
return alias + ".enabled AND " + alias + ".deleted_at IS NULL";
return alias + ".enabled AND " + alias + ".deleted_at IS NULL AND " + alias + ".status = 'ACTIVE'";
}
}
@@ -9,9 +9,13 @@ 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);
void registrationDenied(long userId);
void accountBlocked(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}
@@ -0,0 +1,4 @@
-- PENDING until an admin accepts the registration. A denied one also gets deleted_at, so the nightly cleanup
-- removes it like a deleted account. Existing users are ACTIVE.
ALTER TABLE users ADD COLUMN status VARCHAR(8) NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('PENDING', 'ACTIVE', 'DENIED'));
@@ -10,6 +10,7 @@ import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.CacheManager;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockHttpServletResponse;
@@ -47,6 +48,9 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
@Autowired
private UserService userService;
@Autowired
private CacheManager cacheManager;
@Test
void steamIdOfAFlaggedUser_CanBeLinkedByANewAccount() throws Exception {
long steamId = 76561198099990001L;
@@ -175,20 +179,35 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
}
@Test
void registration_CountsInThePublicStatsAtOnce() throws Exception {
void pendingUser_IsNotCountedAndHasNoPublicProfile() throws Exception {
mockMvc.perform(get("/api/public/profiles/" + NAME)).andExpect(status().isOk());
cacheManager.getCache("publicStats").clear();
long before = totalUsers();
jdbc.update("UPDATE users SET status = 'PENDING' WHERE email = ?", EMAIL);
cacheManager.getCache("publicStats").clear();
assertThat(totalUsers()).isEqualTo(before - 1);
mockMvc.perform(get("/api/public/profiles/" + NAME)).andExpect(status().isNotFound());
}
@Test
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);
}
@@ -199,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"));
}
}
@@ -106,6 +106,47 @@ class AdminFlowTest extends AbstractIntegrationTest {
Long.class, action, target);
}
private ResultActions decide(Session session, long userId, boolean accepted) throws Exception {
return mockMvc.perform(put("/api/admin/users/" + userId + "/decision").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access()))
.contentType(MediaType.APPLICATION_JSON).content("{\"accepted\":" + accepted + "}"));
}
@Test
void pendingRequest_WaitsForTheAcceptAndThenLogsIn() throws Exception {
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", playerId);
Session admin = Session.of(login(ADMIN));
assertThat(login(PLAYER).getStatus()).isEqualTo(403);
assertThat(login(PLAYER).getContentAsString()).contains("ACCOUNT_PENDING");
mockMvc.perform(get("/api/admin/users").param("status", "PENDING")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin.access())))
.andExpect(jsonPath("$.content[*].email").value(org.hamcrest.Matchers.hasItem(PLAYER)))
.andExpect(jsonPath("$.content[?(@.email == '" + PLAYER + "')].status").value("PENDING"));
mockMvc.perform(get("/api/admin/users/pending-count").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin.access())))
.andExpect(status().isOk()).andExpect(jsonPath("$.count").isNumber());
block(admin, playerId, true).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("TARGET_IS_PENDING"));
decide(admin, playerId, true).andExpect(status().isNoContent());
assertThat(login(PLAYER).getStatus()).isEqualTo(200);
assertThat(audits("ACCEPT", playerId)).isEqualTo(1);
decide(admin, playerId, true).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("NOT_PENDING"));
}
@Test
void deniedRequest_CannotLogInAndWaitsForTheCleanup() throws Exception {
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", playerId);
decide(Session.of(login(ADMIN)), playerId, false).andExpect(status().isNoContent());
assertThat(login(PLAYER).getStatus()).isEqualTo(401);
assertThat(jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, playerId)).isEqualTo("DENIED");
assertThat(jdbc.queryForObject("SELECT deleted_at IS NOT NULL FROM users WHERE id = ?", Boolean.class, playerId))
.isTrue();
assertThat(audits("DENY", playerId)).isEqualTo(1);
}
@Test
void admin_ListsUsersWithSearchAndPaging() throws Exception {
Session admin = Session.of(login(ADMIN));
@@ -11,6 +11,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
class AdminUserRepositoryTest extends AbstractPersistenceTest {
@@ -37,29 +38,29 @@ class AdminUserRepositoryTest extends AbstractPersistenceTest {
@Test
void findPage_SearchesEmailAndNameInAnyCase() {
assertThat(repository.findPage("EXAMPLE", 0, 10)).extracting(AdminUser::email)
assertThat(repository.findPage("EXAMPLE", "", 0, 10)).extracting(AdminUser::email)
.containsExactly("alice@example.com", "bob@example.com");
assertThat(repository.findPage("bob", 0, 10)).extracting(AdminUser::email).containsExactly("bob@example.com");
assertThat(repository.count("")).isEqualTo(3);
assertThat(repository.findPage("bob", "", 0, 10)).extracting(AdminUser::email).containsExactly("bob@example.com");
assertThat(repository.count("", "")).isEqualTo(3);
}
@Test
void findPage_TreatsLikeCharactersAsText() {
assertThat(repository.findPage("%", 0, 10)).extracting(AdminUser::email).containsExactly("carol@sample.org");
assertThat(repository.findPage("e_1", 0, 10)).extracting(AdminUser::email).containsExactly("alice@example.com");
assertThat(repository.count("_")).isEqualTo(1);
assertThat(repository.findPage("%", "", 0, 10)).extracting(AdminUser::email).containsExactly("carol@sample.org");
assertThat(repository.findPage("e_1", "", 0, 10)).extracting(AdminUser::email).containsExactly("alice@example.com");
assertThat(repository.count("_", "")).isEqualTo(1);
}
@Test
void findPage_Pages() {
assertThat(repository.findPage("", 1, 2)).extracting(AdminUser::email).containsExactly("carol@sample.org");
assertThat(repository.findPage("", "", 1, 2)).extracting(AdminUser::email).containsExactly("carol@sample.org");
}
@Test
void findPage_ShowsRoleBlockAndDelete() {
jdbc.update("UPDATE users SET role = 'AUDITOR', enabled = false, deleted_at = NOW(), steam_id = 1 WHERE id = ?", alice);
AdminUser found = repository.findPage("alice", 0, 10).getFirst();
AdminUser found = repository.findPage("alice", "", 0, 10).getFirst();
assertThat(found.role()).isEqualTo(Role.AUDITOR);
assertThat(found.blocked()).isTrue();
@@ -88,4 +89,34 @@ class AdminUserRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.setBlocked(alice, true, Instant.now())).isFalse();
assertThat(repository.setBlocked(alice, false, Instant.now())).isTrue();
}
@Test
void findPage_FiltersByStatusAndShowsIt() {
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", alice);
assertThat(repository.findPage("", "PENDING", 0, 10)).extracting(AdminUser::email, AdminUser::status)
.containsExactly(tuple("alice@example.com", "PENDING"));
assertThat(repository.count("", "PENDING")).isEqualTo(1);
assertThat(repository.count("", "ACTIVE")).isEqualTo(2);
}
@Test
void accept_ChangesOnlyAPendingRequest() {
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", alice);
assertThat(repository.findState(alice).orElseThrow().pending()).isTrue();
assertThat(repository.accept(alice)).isTrue();
assertThat(repository.accept(alice)).isFalse();
assertThat(jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, alice)).isEqualTo("ACTIVE");
}
@Test
void deny_FlagsTheRequestForTheNightlyCleanup() {
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", alice);
assertThat(repository.deny(alice, Instant.now())).isTrue();
assertThat(repository.deny(alice, Instant.now())).isFalse();
assertThat(jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, alice)).isEqualTo("DENIED");
assertThat(repository.findState(alice).orElseThrow().deleted()).isTrue();
}
}
@@ -56,7 +56,11 @@ class AdminUserServiceTest extends AbstractUnitTest {
}
private void target(Role role, boolean deleted) {
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(role, deleted)));
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(role, deleted, false)));
}
private void pendingTarget() {
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(Role.USER, false, true)));
}
private String refusal(Runnable action) {
@@ -146,9 +150,48 @@ class AdminUserServiceTest extends AbstractUnitTest {
@Test
void findUsers_ClampsTheSizeAndCountsThePages() {
when(adminUserRepository.count("bob")).thenReturn(250L);
when(adminUserRepository.count("bob", "PENDING")).thenReturn(250L);
assertThat(service.findUsers(" bob ", -1, 500).totalPages()).isEqualTo(3);
verify(adminUserRepository).findPage("bob", 0, 100);
assertThat(service.findUsers(" bob ", "PENDING", -1, 500).totalPages()).isEqualTo(3);
verify(adminUserRepository).findPage("bob", "PENDING", 0, 100);
}
@Test
void decide_AcceptActivatesAuditsAndMails() {
pendingTarget();
when(adminUserRepository.accept(TARGET)).thenReturn(true);
service.decide(ADMIN, TARGET, true);
verify(adminAuditLog).record(ADMIN, "ACCEPT", TARGET, null);
verify(accountMailer).accountAccepted(TARGET);
}
@Test
void decide_DenyFlagsAuditsAndMails() {
pendingTarget();
when(adminUserRepository.deny(eq(TARGET), any())).thenReturn(true);
service.decide(ADMIN, TARGET, false);
verify(adminAuditLog).record(ADMIN, "DENY", TARGET, null);
verify(accountMailer).registrationDenied(TARGET);
}
@Test
void decide_OnlyForAPendingRequest() {
target(Role.USER, false);
assertThat(refusal(() -> service.decide(ADMIN, TARGET, true))).isEqualTo("NOT_PENDING");
verifyNoInteractions(adminAuditLog, accountMailer);
}
@Test
void blockAndDelete_RefuseAPendingRequest() {
pendingTarget();
assertThat(refusal(() -> service.setBlocked(ADMIN, TARGET, true))).isEqualTo("TARGET_IS_PENDING");
assertThat(refusal(() -> service.delete(ADMIN, TARGET))).isEqualTo("TARGET_IS_PENDING");
verifyNoInteractions(accountDeleter);
}
}
@@ -2,6 +2,7 @@ package auth_service.modules.login.api;
import auth_service.configuration.security.JwtAuthenticationFilter;
import auth_service.modules.login.api.dto.request.LoginRequest;
import auth_service.modules.login.exception.AccountPendingException;
import auth_service.modules.login.exception.InvalidCredentialsException;
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
import auth_service.modules.login.model.Session;
@@ -119,6 +120,17 @@ class LoginControllerTest extends AbstractWebMvcTest {
.andExpect(status().isOk());
}
@Test
void login_PendingRequest_Is403AccountPending() throws Exception {
when(loginService.login(any(User.class), any(), any())).thenThrow(new AccountPendingException());
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(loginRequestJson.write(new LoginRequest("test@example.com", "password")).getJson()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("ACCOUNT_PENDING"));
}
@Test
void login_TooManyAttempts() throws Exception {
// Arrange
@@ -8,6 +8,14 @@ import static org.assertj.core.api.Assertions.assertThat;
class UserTest extends AbstractUnitTest {
@Test
void canLogin_OnlyWhenEnabledNotDeletedAndNotPending() {
assertThat(User.builder().enabled(true).build().canLogin()).isTrue();
assertThat(User.builder().enabled(true).pending(true).build().canLogin()).isFalse();
assertThat(User.builder().enabled(true).deleted(true).build().canLogin()).isFalse();
assertThat(User.builder().enabled(false).build().canLogin()).isFalse();
}
@Test
void toString_ShowsNoPassword() {
assertThat(User.builder().email("a@example.com").password("hash-secret").build().toString())
@@ -40,9 +40,26 @@ class MinimalUserRepositoryTest extends AbstractPersistenceTest {
assertEquals(76561198000000001L, user.getSteamId());
assertTrue(user.isEnabled());
assertTrue(user.isDeleted());
assertFalse(user.isPending());
assertEquals(Role.USER, user.getRole());
}
@Test
void findById_MapsAPendingRequest() {
long id = insertUser("minimal-pending@example.com");
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", id);
assertTrue(minimalUserRepository.findById(id).orElseThrow().isPending());
}
@Test
void deniedUser_CannotLoginEvenWithoutDeletedAt() {
long id = insertUser("minimal-denied@example.com");
jdbc.update("UPDATE users SET status = 'DENIED', deleted_at = NULL WHERE id = ?", id);
assertFalse(minimalUserRepository.findById(id).orElseThrow().canLogin());
}
@Test
void findById_UnknownUser_IsEmpty() {
assertFalse(minimalUserRepository.findById(-1).isPresent());
@@ -3,6 +3,7 @@ package auth_service.modules.login.service;
import auth_service.shared.security.Role;
import auth_service.shared.security.TokenGenerator;
import auth_service.modules.login.exception.AccountBlockedException;
import auth_service.modules.login.exception.AccountPendingException;
import auth_service.modules.login.exception.InvalidCredentialsException;
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
import auth_service.modules.login.model.Session;
@@ -179,6 +180,28 @@ class LoginServiceTest extends AbstractUnitTest {
verify(loginAttemptService).recordSuccess("test@example.com", IP);
}
@Test
void login_PendingUserWithTheRightPassword_IsToldItWaits() {
User dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true)
.pending(true).build();
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
assertThrows(AccountPendingException.class, () -> loginService.login(user, IP, null));
verify(tokenGenerator, never()).generateToken(any(), any(), any());
verify(loginAttemptService).recordSuccess("test@example.com", IP);
}
@Test
void login_PendingUserWithAWrongPassword_LooksLikeAnyWrongPassword() {
User dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true)
.pending(true).build();
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(false);
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
}
@Test
void login_BlockedUserWithAWrongPassword_LooksLikeAnyWrongPassword() {
var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(false).build();
@@ -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
@@ -93,6 +93,25 @@ class MailServiceTest extends AbstractUnitTest {
assertThat(inserted(MailTemplate.ACCOUNT_DELETED, "alice@example.com").body()).contains("ab dem 05.11.2026");
}
@Test
void accountAccepted_TellsTheUserTheAccountIsActive() {
user("Alice", "en");
service(ADMIN).accountAccepted(USER);
assertThat(inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com").body())
.startsWith("Hello Alice,").contains("https://tbp.example");
}
@Test
void registrationDenied_TellsTheUserInTheirLanguage() {
user("Alice", "de");
service(ADMIN).registrationDenied(USER);
assertThat(inserted(MailTemplate.REGISTRATION_DENIED, "alice@example.com").subject()).contains("abgelehnt");
}
@Test
void anUnknownUserGetsNoMail() {
when(repository.findRecipient(USER)).thenReturn(Optional.empty());
@@ -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");
}
/**