Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39a4d5ae49 | ||
|
|
1008034e82 |
No files matched your search
@@ -13,10 +13,10 @@ To Be Played is split into three repositories:
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Registration with an invitation code, login with email and password (BCrypt). Emails are stored trimmed and in
|
- Registration by admin approval, login with email and password (BCrypt). Emails are stored trimmed and in
|
||||||
lower case (V19, unique on `lower(email)`); a password has 8 characters and at most 72 bytes (BCrypt's limit).
|
lower case (V19, unique on `lower(email)`); a password has 8 characters and at most 72 bytes (BCrypt's limit).
|
||||||
- Brute-force protection in memory: 5 failed logins per email or 20 per IP, and 10 failed registrations per IP,
|
- Brute-force protection in memory: 5 failed logins per email or 20 per IP, and 10 failed or 3 taken registrations
|
||||||
block for 15 minutes with `429 TOO_MANY_ATTEMPTS` and `Retry-After`. Unknown emails cost the same BCrypt time as a
|
per IP, block for 15 minutes with `429 TOO_MANY_ATTEMPTS` and `Retry-After`. Unknown emails cost the same BCrypt time as a
|
||||||
wrong password, and every failed login gets the same error.
|
wrong password, and every failed login gets the same error.
|
||||||
- JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify
|
- JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify
|
||||||
tokens but never create them. Tokens carry a `kid` header, and `GET /.well-known/jwks.json` publishes the
|
tokens but never create them. Tokens carry a `kid` header, and `GET /.well-known/jwks.json` publishes the
|
||||||
@@ -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
|
(`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
|
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`.
|
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
|
- **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.
|
and end time, type `DISMISS` or `ACCEPT`). The dashboard shows the running hints one at a time, newest first.
|
||||||
"Do not show again" on a `DISMISS` hint or "OK" on an `ACCEPT` hint is a row in `hint_done`, so the hint never
|
"Do not show again" on a `DISMISS` hint or "OK" on an `ACCEPT` hint is a row in `hint_done`, so the hint never
|
||||||
comes back for this user on any device. Creating and ending a hint are rows in `admin_audit`.
|
comes back for this user on any device. Creating and ending a hint are rows in `admin_audit`.
|
||||||
- **Mails** from the platform mailbox: "new user registered" to the admin (`APP_MAIL_ADMIN_TO`), and to the user
|
- **Mails** from the platform mailbox: "a new registration waits" to the admin (`APP_MAIL_ADMIN_TO`, with the link
|
||||||
"your account is active" at the registration, "your account is blocked" and "your account will be deleted from
|
to the admin page), and to the user "we got your registration", then "your account is active" or "your
|
||||||
<date>". The registration sends the browser language (`de` for a German browser, else `en`), which also becomes
|
registration was denied", later "your account is blocked" and "your account will be deleted from <date>". The registration sends the browser language (`de` for a German browser, else `en`), which also becomes
|
||||||
the user's app language. Plain text only. See "Mail flow".
|
the user's app language. Plain text only. See "Mail flow".
|
||||||
- **Delete my account** with the password (5 wrong tries per 15 minutes, then `429`). It only sets
|
- **Delete my account** with the password (5 wrong tries per 15 minutes, then `429`). It only sets
|
||||||
`users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no access token, no public
|
`users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no access token, no public
|
||||||
@@ -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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
||||||
| `PUT` | `/api/admin/users/{id}/blocked` (`{blocked}`; `409 SELF_ACTION`, `TARGET_IS_ADMIN`, `ALREADY_DELETED`) | PLATFORM_ADMIN |
|
| `GET` | `/api/admin/users/pending-count` (`{count}`: registrations that wait) | PLATFORM_ADMIN |
|
||||||
| `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`) | 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 |
|
| `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 |
|
| `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 |
|
| `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 B as Browser (Frontend)
|
||||||
participant A as Auth
|
participant A as Auth
|
||||||
participant M as SMTP (Hostinger)
|
participant M as SMTP (Hostinger)
|
||||||
B->>A: register / admin blocks / delete account
|
B->>A: register / admin decides / admin blocks / delete account
|
||||||
A->>A: event + mail_outbox row (PENDING), one transaction
|
A->>A: event + mail_outbox row (PENDING), one transaction
|
||||||
loop every minute (MailOutboxService)
|
loop every minute (MailOutboxService)
|
||||||
A->>M: up to 20 due rows
|
A->>M: up to 20 due rows
|
||||||
@@ -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
|
Note over A: after 10 tries FAILED and a warning in the log; SENT rows are removed after 30 days
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Registration flow
|
||||||
|
|
||||||
|
A registration is a request: the account is PENDING until a PLATFORM_ADMIN decides. While 20 wait, the
|
||||||
|
registration answers `409 PENDING_LIMIT_REACHED` and stores nothing. Two requests at the same moment can make it 21.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as New user
|
||||||
|
participant A as Auth
|
||||||
|
participant D as Admin
|
||||||
|
U->>A: POST /api/registration/new
|
||||||
|
A->>A: users.status = PENDING, two mail rows
|
||||||
|
A-->>D: mail "a new registration waits"
|
||||||
|
A-->>U: mail "we got your registration"
|
||||||
|
U->>A: login: 403 ACCOUNT_PENDING
|
||||||
|
D->>A: PUT /api/admin/users/{id}/decision {accepted}
|
||||||
|
alt accepted
|
||||||
|
A->>A: status = ACTIVE
|
||||||
|
A-->>U: mail "your account is active"
|
||||||
|
else denied
|
||||||
|
A->>A: status = DENIED, deleted_at = now (nightly cleanup after 30 days)
|
||||||
|
A-->>U: mail "your registration was denied"
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
### Steam ID and block sync to Core
|
### Steam ID and block sync to Core
|
||||||
|
|
||||||
Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that
|
Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that
|
||||||
@@ -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
|
In Auth, `local` makes a new signing key at each start (`LocalKeysEnvironmentPostProcessor`, only in this
|
||||||
profile, and never together with `prod`), so no key is in the repo; after an Auth restart (also a devtools
|
profile, and never together with `prod`), so no key is in the repo; after an Auth restart (also a devtools
|
||||||
restart), log in again. Never use `local` on a server: the dev user's password is public. The
|
restart), log in again. Never use `local` on a server: the dev user's password is public. The
|
||||||
Flyway callback in `db/local` adds the dev user (`PLATFORM_ADMIN`, linked Steam ID). The invite code for more
|
Flyway callback in `db/local` adds the dev user (`PLATFORM_ADMIN`, linked Steam ID). A new local user waits until
|
||||||
users is `local`. What Core mocks and how to change the recorded answers: Core readme, "Run locally".
|
the dev user accepts it on the admin page. What Core mocks and how to change the recorded answers: Core readme, "Run locally".
|
||||||
|
|
||||||
### With your own keys (profile `dev`)
|
### With your own keys (profile `dev`)
|
||||||
|
|
||||||
@@ -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_PRIVATE_KEY`, `JWT_PUBLIC_KEY` | Ed25519 keypair, Base64 of the DER bytes (PKCS#8 / X.509) |
|
||||||
| `JWT_KID` | Key id written into the token header |
|
| `JWT_KID` | Key id written into the token header |
|
||||||
| `AUTH_INVITATION_CODE` | Code required for registration |
|
|
||||||
| `DB_PASSWORD` | Local PostgreSQL password |
|
| `DB_PASSWORD` | Local PostgreSQL password |
|
||||||
| `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) |
|
| `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) |
|
||||||
| `APP_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) |
|
| `APP_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) |
|
||||||
| `APP_CORE_INTERNAL_URL` | Core for the nightly account cleanup, required (local `http://localhost:8090`, Docker `http://tbp-core-service:8090`) |
|
| `APP_CORE_INTERNAL_URL` | Core for the nightly account cleanup, required (local `http://localhost:8090`, Docker `http://tbp-core-service:8090`) |
|
||||||
| `SPRING_MAIL_HOST`, `SPRING_MAIL_USERNAME`, `SPRING_MAIL_PASSWORD` | SMTP server and its login (port 465, SSL). No host = mail off |
|
| `SPRING_MAIL_HOST`, `SPRING_MAIL_USERNAME`, `SPRING_MAIL_PASSWORD` | SMTP server and its login (port 465, SSL). No host = mail off |
|
||||||
| `APP_MAIL_FROM` | Sender address, the mailbox of the SMTP login. Needed with a host |
|
| `APP_MAIL_FROM` | Sender address, the mailbox of the SMTP login. Needed with a host |
|
||||||
| `APP_MAIL_ADMIN_TO` | Gets "new user registered". Needed with a host |
|
| `APP_MAIL_ADMIN_TO` | Gets "a new registration waits". Needed with a host |
|
||||||
|
|
||||||
Generate a keypair:
|
Generate a keypair:
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package auth_service.modules.admin.api;
|
package auth_service.modules.admin.api;
|
||||||
|
|
||||||
import auth_service.modules.admin.api.dto.request.BlockRequest;
|
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.AdminUserPageResponse;
|
||||||
import auth_service.modules.admin.api.dto.response.AdminUserResponse;
|
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.AdminUser;
|
||||||
import auth_service.modules.admin.model.AdminUserPage;
|
import auth_service.modules.admin.model.AdminUserPage;
|
||||||
import auth_service.modules.admin.service.AdminUserService;
|
import auth_service.modules.admin.service.AdminUserService;
|
||||||
@@ -29,13 +31,26 @@ public class AdminUserController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<AdminUserPageResponse> findUsers(@RequestParam(defaultValue = "") String query,
|
public ResponseEntity<AdminUserPageResponse> findUsers(@RequestParam(defaultValue = "") String query,
|
||||||
|
@RequestParam(defaultValue = "") String status,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size) {
|
@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(),
|
return ResponseEntity.ok(new AdminUserPageResponse(found.content().stream().map(AdminUserController::toApi).toList(),
|
||||||
found.page(), found.size(), found.totalElements(), found.totalPages()));
|
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")
|
@PutMapping(value = "/{userId}/blocked")
|
||||||
public ResponseEntity<Void> setBlocked(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId,
|
public ResponseEntity<Void> setBlocked(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId,
|
||||||
@Valid @RequestBody BlockRequest request) {
|
@Valid @RequestBody BlockRequest request) {
|
||||||
@@ -51,6 +66,7 @@ public class AdminUserController {
|
|||||||
|
|
||||||
private static AdminUserResponse toApi(AdminUser user) {
|
private static AdminUserResponse toApi(AdminUser user) {
|
||||||
return new AdminUserResponse(user.id(), user.email(), user.displayName(), user.role().name(),
|
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;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
public record AdminUserResponse(long id, String email, String displayName, String role, LocalDateTime registrationDate,
|
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
|
@Getter
|
||||||
public class AdminActionRefusedException extends RuntimeException {
|
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;
|
private final String code;
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import java.time.Instant;
|
|||||||
import java.time.LocalDateTime;
|
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
|
* @param deletedAt set while the account waits for the nightly cleanup
|
||||||
*/
|
*/
|
||||||
public record AdminUser(long id, String email, String displayName, Role role, LocalDateTime registrationDate,
|
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;
|
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.
|
// 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 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(
|
private static final RowMapper<AdminUser> ROW = (rs, row) -> new AdminUser(
|
||||||
rs.getLong("id"),
|
rs.getLong("id"),
|
||||||
@@ -32,7 +33,8 @@ public class AdminUserRepository {
|
|||||||
localDateTime(rs.getTimestamp("last_login_date")),
|
localDateTime(rs.getTimestamp("last_login_date")),
|
||||||
rs.getObject("steam_id") != null,
|
rs.getObject("steam_id") != null,
|
||||||
!rs.getBoolean("enabled"),
|
!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;
|
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);
|
Object[] match = matchArgs(query);
|
||||||
return jdbc.query("""
|
return jdbc.query("""
|
||||||
SELECT id, email, display_name, role, registration_date, last_login_date, steam_id, enabled, deleted_at
|
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,
|
status
|
||||||
match[0], match[1], match[2], size, (long) page * size);
|
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) {
|
public long count(String query, String status) {
|
||||||
Long count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE " + MATCHES, Long.class, matchArgs(query));
|
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;
|
return count != null ? count : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<TargetState> findState(long userId) {
|
public Optional<TargetState> findState(long userId) {
|
||||||
return jdbc.query("SELECT role, deleted_at FROM users WHERE id = ?",
|
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),
|
(rs, row) -> new TargetState(Role.parse(rs.getString("role")), rs.getTimestamp("deleted_at") != null,
|
||||||
|
"PENDING".equals(rs.getString("status"))),
|
||||||
userId).stream().findFirst();
|
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.
|
* Blocking also ends every login of the user.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -34,20 +34,44 @@ public class AdminUserService {
|
|||||||
private final AccountStatePublisher accountStatePublisher;
|
private final AccountStatePublisher accountStatePublisher;
|
||||||
private final AccountMailer accountMailer;
|
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 search = query == null ? "" : query.trim();
|
||||||
|
String withStatus = status == null ? "" : status;
|
||||||
int pageSize = Math.clamp(size, 1, MAX_PAGE_SIZE);
|
int pageSize = Math.clamp(size, 1, MAX_PAGE_SIZE);
|
||||||
int pageNumber = Math.max(page, 0);
|
int pageNumber = Math.max(page, 0);
|
||||||
long total = adminUserRepository.count(search);
|
long total = adminUserRepository.count(search, withStatus);
|
||||||
return new AdminUserPage(adminUserRepository.findPage(search, pageNumber, pageSize), pageNumber, pageSize, total,
|
return new AdminUserPage(adminUserRepository.findPage(search, withStatus, pageNumber, pageSize), pageNumber,
|
||||||
(int) Math.ceil((double) total / pageSize));
|
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
|
@Transactional
|
||||||
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#targetUserId"),
|
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#targetUserId"),
|
||||||
@CacheEvict(value = "publicStats", allEntries = true)})
|
@CacheEvict(value = "publicStats", allEntries = true)})
|
||||||
public void setBlocked(long actorUserId, long targetUserId, boolean blocked) {
|
public void setBlocked(long actorUserId, long targetUserId, boolean blocked) {
|
||||||
TargetState target = checkedTarget(actorUserId, targetUserId);
|
TargetState target = notPending(checkedTarget(actorUserId, targetUserId));
|
||||||
if (target.deleted()) {
|
if (target.deleted()) {
|
||||||
throw new AdminActionRefusedException("ALREADY_DELETED", "The account is already 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.
|
* 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) {
|
public void delete(long actorUserId, long targetUserId) {
|
||||||
checkedTarget(actorUserId, targetUserId);
|
notPending(checkedTarget(actorUserId, targetUserId));
|
||||||
if (accountDeleter.deleteByAdmin(targetUserId)) {
|
if (accountDeleter.deleteByAdmin(targetUserId)) {
|
||||||
adminAuditLog.record(actorUserId, "DELETE", targetUserId, null);
|
adminAuditLog.record(actorUserId, "DELETE", targetUserId, null);
|
||||||
log.info("Admin {} deleted user {}", actorUserId, targetUserId);
|
log.info("Admin {} deleted user {}", actorUserId, targetUserId);
|
||||||
@@ -84,4 +108,11 @@ public class AdminUserService {
|
|||||||
}
|
}
|
||||||
return target;
|
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.shared.api.dto.response.ErrorResponse;
|
||||||
import auth_service.modules.login.exception.AccountBlockedException;
|
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.InvalidCredentialsException;
|
||||||
import auth_service.modules.login.exception.RoleNotAllowedException;
|
import auth_service.modules.login.exception.RoleNotAllowedException;
|
||||||
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
||||||
@@ -45,6 +46,11 @@ public class LoginExceptionController {
|
|||||||
return response(new ErrorResponse("ACCOUNT_BLOCKED", ex.getMessage()), HttpStatus.FORBIDDEN);
|
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)
|
@ExceptionHandler(RoleNotAllowedException.class)
|
||||||
public ResponseEntity<ErrorResponse> clientError(RoleNotAllowedException ex) {
|
public ResponseEntity<ErrorResponse> clientError(RoleNotAllowedException ex) {
|
||||||
return response(new ErrorResponse("ROLE_NOT_ALLOWED", ex.getMessage()), HttpStatus.FORBIDDEN);
|
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 Long steamId;
|
||||||
private boolean enabled;
|
private boolean enabled;
|
||||||
private boolean deleted;
|
private boolean deleted;
|
||||||
|
private boolean pending;
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Role role = Role.USER;
|
private Role role = Role.USER;
|
||||||
|
|
||||||
public boolean canLogin() {
|
public boolean canLogin() {
|
||||||
return enabled && !deleted;
|
return enabled && !deleted && !pending;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,8 @@ import java.util.Optional;
|
|||||||
public class MinimalUserRepositoryImpl implements MinimalUserRepository {
|
public class MinimalUserRepositoryImpl implements MinimalUserRepository {
|
||||||
|
|
||||||
private static final String SELECT_USER =
|
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()
|
private static final RowMapper<User> USER_ROW = (rs, row) -> User.builder()
|
||||||
.id(rs.getLong("id"))
|
.id(rs.getLong("id"))
|
||||||
@@ -26,6 +27,7 @@ public class MinimalUserRepositoryImpl implements MinimalUserRepository {
|
|||||||
.steamId(rs.getObject("steam_id", Long.class))
|
.steamId(rs.getObject("steam_id", Long.class))
|
||||||
.enabled(rs.getBoolean("enabled"))
|
.enabled(rs.getBoolean("enabled"))
|
||||||
.deleted(rs.getBoolean("deleted"))
|
.deleted(rs.getBoolean("deleted"))
|
||||||
|
.pending(rs.getBoolean("pending"))
|
||||||
.role(Role.parse(rs.getString("role")))
|
.role(Role.parse(rs.getString("role")))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package auth_service.modules.login.service;
|
|||||||
import auth_service.shared.security.Public;
|
import auth_service.shared.security.Public;
|
||||||
import auth_service.shared.security.TokenGenerator;
|
import auth_service.shared.security.TokenGenerator;
|
||||||
import auth_service.modules.login.exception.AccountBlockedException;
|
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.InvalidCredentialsException;
|
||||||
import auth_service.modules.login.model.Session;
|
import auth_service.modules.login.model.Session;
|
||||||
import auth_service.modules.login.model.User;
|
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
|
* 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,
|
* @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.
|
* 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()) {
|
if (foundUser.isEmpty() || !passwordMatches || foundUser.get().isDeleted()) {
|
||||||
throw new InvalidCredentialsException("Invalid email or password");
|
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()) {
|
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);
|
loginAttemptService.recordSuccess(email, clientIp);
|
||||||
throw new AccountBlockedException();
|
throw new AccountBlockedException();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,18 +9,42 @@ import java.util.Locale;
|
|||||||
*/
|
*/
|
||||||
public enum MailTemplate {
|
public enum MailTemplate {
|
||||||
|
|
||||||
NEW_USER_REGISTERED(
|
REGISTRATION_REQUESTED(
|
||||||
"New registration on ToBePlayed", """
|
"New registration on ToBePlayed waits", """
|
||||||
A new user has registered.
|
A new registration waits for your decision.
|
||||||
|
|
||||||
Name: %s
|
Name: %s
|
||||||
Email: %s
|
Email: %s
|
||||||
|
|
||||||
|
Accept or deny it here:
|
||||||
|
%s
|
||||||
""",
|
""",
|
||||||
"Neue Registrierung bei ToBePlayed", """
|
"Neue Registrierung bei ToBePlayed wartet", """
|
||||||
Ein neuer Nutzer hat sich registriert.
|
Eine neue Registrierung wartet auf deine Entscheidung.
|
||||||
|
|
||||||
Name: %s
|
Name: %s
|
||||||
E-Mail: %s
|
E-Mail: %s
|
||||||
|
|
||||||
|
Hier nimmst du sie an oder lehnst sie ab:
|
||||||
|
%s
|
||||||
|
"""),
|
||||||
|
|
||||||
|
REGISTRATION_RECEIVED(
|
||||||
|
"Your ToBePlayed registration has arrived", """
|
||||||
|
Hello %s,
|
||||||
|
|
||||||
|
thank you for your registration. An administrator checks each new account.
|
||||||
|
You get a mail as soon as it is decided.
|
||||||
|
|
||||||
|
ToBePlayed
|
||||||
|
""",
|
||||||
|
"Deine ToBePlayed-Registrierung ist angekommen", """
|
||||||
|
Hallo %s,
|
||||||
|
|
||||||
|
danke für deine Registrierung. Ein Administrator prüft jedes neue Konto.
|
||||||
|
Du bekommst eine Mail, sobald entschieden ist.
|
||||||
|
|
||||||
|
ToBePlayed
|
||||||
"""),
|
"""),
|
||||||
|
|
||||||
ACCOUNT_ACTIVE(
|
ACCOUNT_ACTIVE(
|
||||||
@@ -41,6 +65,24 @@ public enum MailTemplate {
|
|||||||
ToBePlayed
|
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(
|
ACCOUNT_BLOCKED(
|
||||||
"Your ToBePlayed account is blocked", """
|
"Your ToBePlayed account is blocked", """
|
||||||
Hello %s,
|
Hello %s,
|
||||||
|
|||||||
@@ -31,17 +31,30 @@ public class MailService implements AccountMailer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void userRegistered(long userId) {
|
public void registrationRequested(long userId) {
|
||||||
repository.findRecipient(userId).ifPresent(user -> {
|
repository.findRecipient(userId).ifPresent(user -> {
|
||||||
// Blank only while mail is off: with a host, MailOutboxService stops the start without it.
|
// Blank only while mail is off: with a host, MailOutboxService stops the start without it.
|
||||||
if (!adminTo.isBlank()) {
|
if (!adminTo.isBlank()) {
|
||||||
repository.insert(MailTemplate.NEW_USER_REGISTERED, userId, adminTo, MailTemplate.NEW_USER_REGISTERED
|
repository.insert(MailTemplate.REGISTRATION_REQUESTED, userId, adminTo, MailTemplate.REGISTRATION_REQUESTED
|
||||||
.render(ADMIN_LANGUAGE, user.displayName() == null ? "-" : user.displayName(), user.email()));
|
.render(ADMIN_LANGUAGE, user.displayName() == null ? "-" : user.displayName(), user.email(),
|
||||||
|
frontendUrl + "/admin/users"));
|
||||||
}
|
}
|
||||||
insertForUser(MailTemplate.ACCOUNT_ACTIVE, userId, user, user.greeting(), frontendUrl);
|
insertForUser(MailTemplate.REGISTRATION_RECEIVED, userId, user, user.greeting());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
@Override
|
||||||
public void accountBlocked(long userId) {
|
public void accountBlocked(long userId) {
|
||||||
repository.findRecipient(userId).ifPresent(user ->
|
repository.findRecipient(userId).ifPresent(user ->
|
||||||
|
|||||||
+4
-5
@@ -2,7 +2,7 @@ package auth_service.modules.registration.api;
|
|||||||
|
|
||||||
import auth_service.shared.api.dto.response.ErrorResponse;
|
import auth_service.shared.api.dto.response.ErrorResponse;
|
||||||
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
|
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
|
||||||
import auth_service.modules.registration.exception.InvalidInvitationCodeException;
|
import auth_service.modules.registration.exception.PendingLimitReachedException;
|
||||||
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
|
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
|
||||||
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
|
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
|
||||||
import lombok.NonNull;
|
import lombok.NonNull;
|
||||||
@@ -40,10 +40,9 @@ public class RegistrationExceptionController {
|
|||||||
return response(body, HttpStatus.CONFLICT);
|
return response(body, HttpStatus.CONFLICT);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(InvalidInvitationCodeException.class)
|
@ExceptionHandler(PendingLimitReachedException.class)
|
||||||
public ResponseEntity<ErrorResponse> clientError(InvalidInvitationCodeException ex) {
|
public ResponseEntity<ErrorResponse> clientError(PendingLimitReachedException ex) {
|
||||||
var body = new ErrorResponse("INVALID_INVITATION_CODE", ex.getMessage());
|
return response(new ErrorResponse("PENDING_LIMIT_REACHED", ex.getMessage()), HttpStatus.CONFLICT);
|
||||||
return response(body, HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(DisplayNameAlreadyRegisteredException.class)
|
@ExceptionHandler(DisplayNameAlreadyRegisteredException.class)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ public abstract class ApiMapper {
|
|||||||
return Registration.builder().
|
return Registration.builder().
|
||||||
email(registrationRequest.email()).
|
email(registrationRequest.email()).
|
||||||
rawPassword(registrationRequest.password()).
|
rawPassword(registrationRequest.password()).
|
||||||
inviteCode(registrationRequest.inviteCode()).
|
|
||||||
displayName(registrationRequest.displayName()).
|
displayName(registrationRequest.displayName()).
|
||||||
language(registrationRequest.language()).
|
language(registrationRequest.language()).
|
||||||
build();
|
build();
|
||||||
|
|||||||
-1
@@ -18,7 +18,6 @@ import java.nio.charset.StandardCharsets;
|
|||||||
@Builder
|
@Builder
|
||||||
public record RegistrationRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_LENGTH) String email,
|
public record RegistrationRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_LENGTH) String email,
|
||||||
@NotBlank @Size(min = 8) String password,
|
@NotBlank @Size(min = 8) String password,
|
||||||
@NotBlank String inviteCode,
|
|
||||||
@Pattern(regexp = DisplayNameFormat.PATTERN) String displayName,
|
@Pattern(regexp = DisplayNameFormat.PATTERN) String displayName,
|
||||||
// For the mails; null = never chosen.
|
// For the mails; null = never chosen.
|
||||||
@Pattern(regexp = "de|en") String language) {
|
@Pattern(regexp = "de|en") String language) {
|
||||||
|
|||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
package auth_service.modules.registration.exception;
|
|
||||||
|
|
||||||
public class InvalidInvitationCodeException extends RuntimeException {
|
|
||||||
public InvalidInvitationCodeException(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package auth_service.modules.registration.exception;
|
||||||
|
|
||||||
|
public class PendingLimitReachedException extends RuntimeException {
|
||||||
|
public PendingLimitReachedException() {
|
||||||
|
super("Too many registrations wait for the admin. Please try again later.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ public class Registration {
|
|||||||
private String rawPassword;
|
private String rawPassword;
|
||||||
@ToString.Exclude
|
@ToString.Exclude
|
||||||
private String encryptedPassword;
|
private String encryptedPassword;
|
||||||
private String inviteCode;
|
|
||||||
private String displayName;
|
private String displayName;
|
||||||
private String language;
|
private String language;
|
||||||
}
|
}
|
||||||
+5
@@ -7,5 +7,10 @@ public interface RegisteredUserRepository {
|
|||||||
|
|
||||||
boolean existsByDisplayName(String displayName);
|
boolean existsByDisplayName(String displayName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the user as PENDING: an admin accepts or denies the registration.
|
||||||
|
*/
|
||||||
long save(Registration registration);
|
long save(Registration registration);
|
||||||
|
|
||||||
|
long countPending();
|
||||||
}
|
}
|
||||||
+8
-2
@@ -32,8 +32,8 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public long save(Registration registration) {
|
public long save(Registration registration) {
|
||||||
Long id = jdbc.queryForObject("""
|
Long id = jdbc.queryForObject("""
|
||||||
INSERT INTO users (email, password, enabled, display_name, registration_date)
|
INSERT INTO users (email, password, enabled, display_name, registration_date, status)
|
||||||
VALUES (?, ?, TRUE, ?, ?) RETURNING id""", Long.class,
|
VALUES (?, ?, TRUE, ?, ?, 'PENDING') RETURNING id""", Long.class,
|
||||||
registration.getEmail(), registration.getEncryptedPassword(), registration.getDisplayName(),
|
registration.getEmail(), registration.getEncryptedPassword(), registration.getDisplayName(),
|
||||||
LocalDateTime.now(Clock.systemUTC()));
|
LocalDateTime.now(Clock.systemUTC()));
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
@@ -44,4 +44,10 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository {
|
|||||||
registration.getLanguage());
|
registration.getLanguage());
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long countPending() {
|
||||||
|
Long count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE status = 'PENDING'", Long.class);
|
||||||
|
return count != null ? count : 0L;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package auth_service.modules.registration.service;
|
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class CodeValidationService {
|
|
||||||
|
|
||||||
@Value("${auth.registration.invitation-code}")
|
|
||||||
private String invitationCode;
|
|
||||||
|
|
||||||
public boolean isCodeValid(String code) {
|
|
||||||
if (code == null || code.isEmpty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return code.equals(invitationCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+13
-2
@@ -7,16 +7,18 @@ import org.springframework.stereotype.Service;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blocks a client IP after too many failed registrations. Every rejection counts, so guessing the
|
* Limits each client IP: 10 failed registrations (probing which emails have an account) and 3 taken requests (so one
|
||||||
* invitation code and probing which emails have an account are both limited.
|
* IP cannot fill the queue for the admin) within 15 minutes.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class RegistrationAttemptService {
|
public class RegistrationAttemptService {
|
||||||
|
|
||||||
static final int MAX_FAILURES_PER_IP = 10;
|
static final int MAX_FAILURES_PER_IP = 10;
|
||||||
|
static final int MAX_REQUESTS_PER_IP = 3;
|
||||||
static final Duration LOCK_DURATION = Duration.ofMinutes(15);
|
static final Duration LOCK_DURATION = Duration.ofMinutes(15);
|
||||||
|
|
||||||
private final AttemptLimiter limiter = new AttemptLimiter(LOCK_DURATION);
|
private final AttemptLimiter limiter = new AttemptLimiter(LOCK_DURATION);
|
||||||
|
private final AttemptLimiter requests = new AttemptLimiter(LOCK_DURATION);
|
||||||
|
|
||||||
public void acquire(String clientIp) {
|
public void acquire(String clientIp) {
|
||||||
if (!limiter.tryAcquire(clientIp, MAX_FAILURES_PER_IP)) {
|
if (!limiter.tryAcquire(clientIp, MAX_FAILURES_PER_IP)) {
|
||||||
@@ -27,4 +29,13 @@ public class RegistrationAttemptService {
|
|||||||
public void release(String clientIp) {
|
public void release(String clientIp) {
|
||||||
limiter.release(clientIp);
|
limiter.release(clientIp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A request that passed every check; it is never given back.
|
||||||
|
*/
|
||||||
|
public void countRequest(String clientIp) {
|
||||||
|
if (!requests.tryAcquire(clientIp, MAX_REQUESTS_PER_IP)) {
|
||||||
|
throw new TooManyRegistrationAttemptsException(LOCK_DURATION.toSeconds());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
package auth_service.modules.registration.service;
|
package auth_service.modules.registration.service;
|
||||||
|
|
||||||
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
|
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
|
||||||
import auth_service.modules.registration.exception.InvalidInvitationCodeException;
|
|
||||||
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
|
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
|
||||||
|
import auth_service.modules.registration.exception.PendingLimitReachedException;
|
||||||
import auth_service.modules.registration.model.Registration;
|
import auth_service.modules.registration.model.Registration;
|
||||||
import auth_service.modules.registration.persistence.RegisteredUserRepository;
|
import auth_service.modules.registration.persistence.RegisteredUserRepository;
|
||||||
import auth_service.shared.mail.AccountMailer;
|
import auth_service.shared.mail.AccountMailer;
|
||||||
import auth_service.shared.security.Public;
|
import auth_service.shared.security.Public;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.cache.annotation.CacheEvict;
|
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -18,33 +17,39 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class RegistrationService {
|
public class RegistrationService {
|
||||||
|
|
||||||
private final CodeValidationService codeValidationService;
|
|
||||||
private final RegisteredUserRepository registeredUserRepository;
|
private final RegisteredUserRepository registeredUserRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final RegistrationAttemptService registrationAttemptService;
|
private final RegistrationAttemptService registrationAttemptService;
|
||||||
private final AccountMailer accountMailer;
|
private final AccountMailer accountMailer;
|
||||||
|
|
||||||
|
static final int MAX_PENDING = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a request that waits for the admin. When MAX_PENDING wait already, it is refused and counts as a failed
|
||||||
|
* attempt of the IP.
|
||||||
|
*/
|
||||||
// The user and the mail rows commit together.
|
// The user and the mail rows commit together.
|
||||||
@Transactional
|
@Transactional
|
||||||
@CacheEvict(value = "publicStats", allEntries = true)
|
|
||||||
public long register(Registration registration, String clientIp) {
|
public long register(Registration registration, String clientIp) {
|
||||||
registrationAttemptService.acquire(clientIp);
|
registrationAttemptService.acquire(clientIp);
|
||||||
|
// ponytail: two requests at the same time can pass at 19 and make 21; a pg_advisory_xact_lock would make it
|
||||||
|
// exact, but the limit is a brake, not a quota.
|
||||||
|
if (registeredUserRepository.countPending() >= MAX_PENDING) {
|
||||||
|
throw new PendingLimitReachedException();
|
||||||
|
}
|
||||||
validate(registration);
|
validate(registration);
|
||||||
registrationAttemptService.release(clientIp);
|
registrationAttemptService.release(clientIp);
|
||||||
|
registrationAttemptService.countRequest(clientIp);
|
||||||
|
|
||||||
var rawPassword = registration.getRawPassword();
|
var rawPassword = registration.getRawPassword();
|
||||||
registration.setEncryptedPassword(passwordEncoder.encode(rawPassword));
|
registration.setEncryptedPassword(passwordEncoder.encode(rawPassword));
|
||||||
|
|
||||||
long userId = registeredUserRepository.save(registration);
|
long userId = registeredUserRepository.save(registration);
|
||||||
accountMailer.userRegistered(userId);
|
accountMailer.registrationRequested(userId);
|
||||||
return userId;
|
return userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validate(Registration registration) {
|
private void validate(Registration registration) {
|
||||||
if (!codeValidationService.isCodeValid(registration.getInviteCode())) {
|
|
||||||
throw new InvalidInvitationCodeException("The provided invite code is invalid.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (registeredUserRepository.existsByEmail(registration.getEmail())) {
|
if (registeredUserRepository.existsByEmail(registration.getEmail())) {
|
||||||
throw new EmailAlreadyInUseException("The provided email address is already in use.");
|
throw new EmailAlreadyInUseException("The provided email address is already in use.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package auth_service.shared.account;
|
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 {
|
public final class ActiveUser {
|
||||||
|
|
||||||
@@ -9,6 +9,6 @@ public final class ActiveUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static String of(String alias) {
|
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 {
|
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);
|
void accountBlocked(long userId);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,3 @@ app:
|
|||||||
cookie-secure: false
|
cookie-secure: false
|
||||||
core:
|
core:
|
||||||
internal-url: "http://localhost:8090"
|
internal-url: "http://localhost:8090"
|
||||||
|
|
||||||
auth:
|
|
||||||
registration:
|
|
||||||
invitation-code: local
|
|
||||||
@@ -80,11 +80,6 @@ app:
|
|||||||
mail:
|
mail:
|
||||||
# The mailbox the SMTP login belongs to; Hostinger refuses any other sender.
|
# The mailbox the SMTP login belongs to; Hostinger refuses any other sender.
|
||||||
from: ${APP_MAIL_FROM:}
|
from: ${APP_MAIL_FROM:}
|
||||||
# Gets "new user registered". Only in the env file, never in git.
|
# Gets "a new registration waits". Only in the env file, never in git.
|
||||||
admin-to: ${APP_MAIL_ADMIN_TO:}
|
admin-to: ${APP_MAIL_ADMIN_TO:}
|
||||||
cron: "0 * * * * *"
|
cron: "0 * * * * *"
|
||||||
|
|
||||||
# Registration settings
|
|
||||||
auth:
|
|
||||||
registration:
|
|
||||||
invitation-code: ${AUTH_INVITATION_CODE}
|
|
||||||
@@ -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.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.mock.web.MockHttpServletResponse;
|
import org.springframework.mock.web.MockHttpServletResponse;
|
||||||
@@ -47,6 +48,9 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private CacheManager cacheManager;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void steamIdOfAFlaggedUser_CanBeLinkedByANewAccount() throws Exception {
|
void steamIdOfAFlaggedUser_CanBeLinkedByANewAccount() throws Exception {
|
||||||
long steamId = 76561198099990001L;
|
long steamId = 76561198099990001L;
|
||||||
@@ -175,20 +179,35 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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";
|
String newEmail = "delete-flow-registered@example.com";
|
||||||
jdbc.update("DELETE FROM users WHERE email = ?", newEmail);
|
jdbc.update("DELETE FROM users WHERE email = ?", newEmail);
|
||||||
long before = totalUsers();
|
long before = totalUsers();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
mockMvc.perform(post("/api/registration/new")
|
mockMvc.perform(post("/api/registration/new")
|
||||||
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
.header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"" + newEmail + "\",\"password\":\"" + PASSWORD
|
.content("{\"email\":\"" + newEmail + "\",\"password\":\"" + PASSWORD
|
||||||
+ "\",\"inviteCode\":\"test-invite-code\"}"))
|
+ "\"}"))
|
||||||
.andExpect(status().is2xxSuccessful());
|
.andExpect(status().is2xxSuccessful());
|
||||||
|
|
||||||
assertThat(totalUsers()).isEqualTo(before + 1);
|
assertThat(jdbc.queryForObject("SELECT status FROM users WHERE email = ?", String.class, newEmail))
|
||||||
|
.isEqualTo("PENDING");
|
||||||
|
assertThat(totalUsers()).isEqualTo(before);
|
||||||
} finally {
|
} finally {
|
||||||
jdbc.update("DELETE FROM users WHERE email = ?", newEmail);
|
jdbc.update("DELETE FROM users WHERE email = ?", newEmail);
|
||||||
}
|
}
|
||||||
@@ -199,10 +218,10 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
|
|||||||
jdbc.update("UPDATE users SET deleted_at = NOW() WHERE email = ?", EMAIL);
|
jdbc.update("UPDATE users SET deleted_at = NOW() WHERE email = ?", EMAIL);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/registration/new")
|
mockMvc.perform(post("/api/registration/new")
|
||||||
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
.header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD
|
.content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD
|
||||||
+ "\",\"inviteCode\":\"test-invite-code\"}"))
|
+ "\"}"))
|
||||||
.andExpect(jsonPath("$.code").value("EMAIL_ALREADY_IN_USE"));
|
.andExpect(jsonPath("$.code").value("EMAIL_ALREADY_IN_USE"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +106,47 @@ class AdminFlowTest extends AbstractIntegrationTest {
|
|||||||
Long.class, action, target);
|
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
|
@Test
|
||||||
void admin_ListsUsersWithSearchAndPaging() throws Exception {
|
void admin_ListsUsersWithSearchAndPaging() throws Exception {
|
||||||
Session admin = Session.of(login(ADMIN));
|
Session admin = Session.of(login(ADMIN));
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.tuple;
|
||||||
|
|
||||||
class AdminUserRepositoryTest extends AbstractPersistenceTest {
|
class AdminUserRepositoryTest extends AbstractPersistenceTest {
|
||||||
|
|
||||||
@@ -37,29 +38,29 @@ class AdminUserRepositoryTest extends AbstractPersistenceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findPage_SearchesEmailAndNameInAnyCase() {
|
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");
|
.containsExactly("alice@example.com", "bob@example.com");
|
||||||
assertThat(repository.findPage("bob", 0, 10)).extracting(AdminUser::email).containsExactly("bob@example.com");
|
assertThat(repository.findPage("bob", "", 0, 10)).extracting(AdminUser::email).containsExactly("bob@example.com");
|
||||||
assertThat(repository.count("")).isEqualTo(3);
|
assertThat(repository.count("", "")).isEqualTo(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findPage_TreatsLikeCharactersAsText() {
|
void findPage_TreatsLikeCharactersAsText() {
|
||||||
assertThat(repository.findPage("%", 0, 10)).extracting(AdminUser::email).containsExactly("carol@sample.org");
|
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.findPage("e_1", "", 0, 10)).extracting(AdminUser::email).containsExactly("alice@example.com");
|
||||||
assertThat(repository.count("_")).isEqualTo(1);
|
assertThat(repository.count("_", "")).isEqualTo(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findPage_Pages() {
|
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
|
@Test
|
||||||
void findPage_ShowsRoleBlockAndDelete() {
|
void findPage_ShowsRoleBlockAndDelete() {
|
||||||
jdbc.update("UPDATE users SET role = 'AUDITOR', enabled = false, deleted_at = NOW(), steam_id = 1 WHERE id = ?", alice);
|
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.role()).isEqualTo(Role.AUDITOR);
|
||||||
assertThat(found.blocked()).isTrue();
|
assertThat(found.blocked()).isTrue();
|
||||||
@@ -88,4 +89,34 @@ class AdminUserRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertThat(repository.setBlocked(alice, true, Instant.now())).isFalse();
|
assertThat(repository.setBlocked(alice, true, Instant.now())).isFalse();
|
||||||
assertThat(repository.setBlocked(alice, false, Instant.now())).isTrue();
|
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) {
|
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) {
|
private String refusal(Runnable action) {
|
||||||
@@ -146,9 +150,48 @@ class AdminUserServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findUsers_ClampsTheSizeAndCountsThePages() {
|
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);
|
assertThat(service.findUsers(" bob ", "PENDING", -1, 500).totalPages()).isEqualTo(3);
|
||||||
verify(adminUserRepository).findPage("bob", 0, 100);
|
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.configuration.security.JwtAuthenticationFilter;
|
||||||
import auth_service.modules.login.api.dto.request.LoginRequest;
|
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.InvalidCredentialsException;
|
||||||
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
||||||
import auth_service.modules.login.model.Session;
|
import auth_service.modules.login.model.Session;
|
||||||
@@ -119,6 +120,17 @@ class LoginControllerTest extends AbstractWebMvcTest {
|
|||||||
.andExpect(status().isOk());
|
.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
|
@Test
|
||||||
void login_TooManyAttempts() throws Exception {
|
void login_TooManyAttempts() throws Exception {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
|
|
||||||
class UserTest extends AbstractUnitTest {
|
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
|
@Test
|
||||||
void toString_ShowsNoPassword() {
|
void toString_ShowsNoPassword() {
|
||||||
assertThat(User.builder().email("a@example.com").password("hash-secret").build().toString())
|
assertThat(User.builder().email("a@example.com").password("hash-secret").build().toString())
|
||||||
|
|||||||
@@ -40,9 +40,26 @@ class MinimalUserRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertEquals(76561198000000001L, user.getSteamId());
|
assertEquals(76561198000000001L, user.getSteamId());
|
||||||
assertTrue(user.isEnabled());
|
assertTrue(user.isEnabled());
|
||||||
assertTrue(user.isDeleted());
|
assertTrue(user.isDeleted());
|
||||||
|
assertFalse(user.isPending());
|
||||||
assertEquals(Role.USER, user.getRole());
|
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
|
@Test
|
||||||
void findById_UnknownUser_IsEmpty() {
|
void findById_UnknownUser_IsEmpty() {
|
||||||
assertFalse(minimalUserRepository.findById(-1).isPresent());
|
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.Role;
|
||||||
import auth_service.shared.security.TokenGenerator;
|
import auth_service.shared.security.TokenGenerator;
|
||||||
import auth_service.modules.login.exception.AccountBlockedException;
|
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.InvalidCredentialsException;
|
||||||
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
||||||
import auth_service.modules.login.model.Session;
|
import auth_service.modules.login.model.Session;
|
||||||
@@ -179,6 +180,28 @@ class LoginServiceTest extends AbstractUnitTest {
|
|||||||
verify(loginAttemptService).recordSuccess("test@example.com", IP);
|
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
|
@Test
|
||||||
void login_BlockedUserWithAWrongPassword_LooksLikeAnyWrongPassword() {
|
void login_BlockedUserWithAWrongPassword_LooksLikeAnyWrongPassword() {
|
||||||
var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(false).build();
|
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)
|
@EnumSource(MailTemplate.class)
|
||||||
void render_FillsEveryPlaceholderInBothLanguages(MailTemplate template) {
|
void render_FillsEveryPlaceholderInBothLanguages(MailTemplate template) {
|
||||||
for (String language : new String[]{"de", "en"}) {
|
for (String language : new String[]{"de", "en"}) {
|
||||||
Mail mail = template.render(language, "Alice", "alice@example.com");
|
Mail mail = template.render(language, "Alice", "alice@example.com", "https://tbp.example");
|
||||||
|
|
||||||
assertThat(mail.subject()).isNotBlank().doesNotContain("%");
|
assertThat(mail.subject()).isNotBlank().doesNotContain("%");
|
||||||
assertThat(mail.body()).contains("Alice").doesNotContain("%s");
|
assertThat(mail.body()).contains("Alice").doesNotContain("%s");
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ class MailFlowTest extends AbstractIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void register() throws Exception {
|
private void register() throws Exception {
|
||||||
mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"" + NEW_USER + "\",\"password\":\"" + PASSWORD
|
.content("{\"email\":\"" + NEW_USER + "\",\"password\":\"" + PASSWORD
|
||||||
+ "\",\"inviteCode\":\"test-invite-code\",\"displayName\":\"MailFlow\",\"language\":\"de\"}"))
|
+ "\",\"displayName\":\"MailFlow\",\"language\":\"de\"}"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,9 +87,9 @@ class MailFlowTest extends AbstractIntegrationTest {
|
|||||||
register();
|
register();
|
||||||
|
|
||||||
assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address") + " " + row.get("status"))
|
assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address") + " " + row.get("status"))
|
||||||
.containsExactly("NEW_USER_REGISTERED admin@test.local PENDING",
|
.containsExactly("REGISTRATION_REQUESTED admin@test.local PENDING",
|
||||||
"ACCOUNT_ACTIVE " + NEW_USER + " PENDING");
|
"REGISTRATION_RECEIVED " + NEW_USER + " PENDING");
|
||||||
assertThat(rows().get(1).get("subject")).isEqualTo("Dein ToBePlayed-Konto ist aktiv");
|
assertThat(rows().get(1).get("subject")).isEqualTo("Deine ToBePlayed-Registrierung ist angekommen");
|
||||||
|
|
||||||
mailOutboxService.sendDue();
|
mailOutboxService.sendDue();
|
||||||
|
|
||||||
@@ -99,6 +99,25 @@ class MailFlowTest extends AbstractIntegrationTest {
|
|||||||
assertThat(rows()).extracting(row -> row.get("status")).containsOnly("SENT");
|
assertThat(rows()).extracting(row -> row.get("status")).containsOnly("SENT");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptAndDeny_MailTheUser() throws Exception {
|
||||||
|
insertUser(ADMIN, "PLATFORM_ADMIN");
|
||||||
|
long accepted = insertUser(PLAYER, "USER");
|
||||||
|
long denied = insertUser(NEW_USER, "USER");
|
||||||
|
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id IN (?, ?)", accepted, denied);
|
||||||
|
String admin = accessToken(ADMIN);
|
||||||
|
|
||||||
|
for (long userId : new long[]{accepted, denied}) {
|
||||||
|
mockMvc.perform(put("/api/admin/users/" + userId + "/decision").header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||||
|
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"accepted\":" + (userId == accepted) + "}"))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address"))
|
||||||
|
.containsExactly("ACCOUNT_ACTIVE " + PLAYER, "REGISTRATION_DENIED " + NEW_USER);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void aFailedSend_IsTriedAgainAndThenGivenUp() throws Exception {
|
void aFailedSend_IsTriedAgainAndThenGivenUp() throws Exception {
|
||||||
register();
|
register();
|
||||||
@@ -121,10 +140,10 @@ class MailFlowTest extends AbstractIntegrationTest {
|
|||||||
void aFailedRegistration_WritesNoMail() throws Exception {
|
void aFailedRegistration_WritesNoMail() throws Exception {
|
||||||
insertUser(NEW_USER, "USER");
|
insertUser(NEW_USER, "USER");
|
||||||
|
|
||||||
mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"" + NEW_USER + "\",\"password\":\"" + PASSWORD
|
.content("{\"email\":\"" + NEW_USER + "\",\"password\":\"" + PASSWORD
|
||||||
+ "\",\"inviteCode\":\"test-invite-code\"}"))
|
+ "\"}"))
|
||||||
.andExpect(status().isConflict());
|
.andExpect(status().isConflict());
|
||||||
|
|
||||||
assertThat(rows()).isEmpty();
|
assertThat(rows()).isEmpty();
|
||||||
|
|||||||
@@ -44,35 +44,35 @@ class MailServiceTest extends AbstractUnitTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void userRegistered_TellsTheAdminInGermanAndTheUserInTheirLanguage() {
|
void registrationRequested_TellsTheAdminInGermanWithTheLinkAndTheUserInTheirLanguage() {
|
||||||
user("Alice", "en");
|
user("Alice", "en");
|
||||||
|
|
||||||
service(ADMIN).userRegistered(USER);
|
service(ADMIN).registrationRequested(USER);
|
||||||
|
|
||||||
assertThat(inserted(MailTemplate.NEW_USER_REGISTERED, ADMIN).body())
|
assertThat(inserted(MailTemplate.REGISTRATION_REQUESTED, ADMIN).body())
|
||||||
.startsWith("Ein neuer Nutzer").contains("Alice", "alice@example.com");
|
.startsWith("Eine neue Registrierung").contains("Alice", "alice@example.com", "https://tbp.example/admin/users");
|
||||||
assertThat(inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com").body())
|
assertThat(inserted(MailTemplate.REGISTRATION_RECEIVED, "alice@example.com").body()).startsWith("Hello Alice,");
|
||||||
.startsWith("Hello Alice,").contains("https://tbp.example");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void userRegistered_GreetsByEmailWithoutADisplayName() {
|
void registrationRequested_GreetsByEmailWithoutADisplayName() {
|
||||||
user(null, "de");
|
user(null, "de");
|
||||||
|
|
||||||
service(ADMIN).userRegistered(USER);
|
service(ADMIN).registrationRequested(USER);
|
||||||
|
|
||||||
assertThat(inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com").body()).startsWith("Hallo alice@example.com,");
|
assertThat(inserted(MailTemplate.REGISTRATION_RECEIVED, "alice@example.com").body())
|
||||||
assertThat(inserted(MailTemplate.NEW_USER_REGISTERED, ADMIN).body()).contains("Name: -");
|
.startsWith("Hallo alice@example.com,");
|
||||||
|
assertThat(inserted(MailTemplate.REGISTRATION_REQUESTED, ADMIN).body()).contains("Name: -");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void userRegistered_WritesNoAdminRowWithoutAnAdminAddress() {
|
void registrationRequested_WritesNoAdminRowWithoutAnAdminAddress() {
|
||||||
user("Alice", "en");
|
user("Alice", "en");
|
||||||
|
|
||||||
service("").userRegistered(USER);
|
service("").registrationRequested(USER);
|
||||||
|
|
||||||
verify(repository, never()).insert(eq(MailTemplate.NEW_USER_REGISTERED), anyLong(), anyString(), any());
|
verify(repository, never()).insert(eq(MailTemplate.REGISTRATION_REQUESTED), anyLong(), anyString(), any());
|
||||||
inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com");
|
inserted(MailTemplate.REGISTRATION_RECEIVED, "alice@example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -93,6 +93,25 @@ class MailServiceTest extends AbstractUnitTest {
|
|||||||
assertThat(inserted(MailTemplate.ACCOUNT_DELETED, "alice@example.com").body()).contains("ab dem 05.11.2026");
|
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
|
@Test
|
||||||
void anUnknownUserGetsNoMail() {
|
void anUnknownUserGetsNoMail() {
|
||||||
when(repository.findRecipient(USER)).thenReturn(Optional.empty());
|
when(repository.findRecipient(USER)).thenReturn(Optional.empty());
|
||||||
|
|||||||
+31
-24
@@ -10,6 +10,7 @@ import org.hibernate.exception.ConstraintViolationException;
|
|||||||
import auth_service.configuration.security.JwtService;
|
import auth_service.configuration.security.JwtService;
|
||||||
import auth_service.modules.registration.api.dto.request.RegistrationRequest;
|
import auth_service.modules.registration.api.dto.request.RegistrationRequest;
|
||||||
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
|
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
|
||||||
|
import auth_service.modules.registration.exception.PendingLimitReachedException;
|
||||||
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
|
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
|
||||||
import auth_service.modules.registration.model.Registration;
|
import auth_service.modules.registration.model.Registration;
|
||||||
import auth_service.modules.registration.service.RegistrationService;
|
import auth_service.modules.registration.service.RegistrationService;
|
||||||
@@ -83,7 +84,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
|||||||
var request = RegistrationRequest.builder()
|
var request = RegistrationRequest.builder()
|
||||||
.email("test@example.com")
|
.email("test@example.com")
|
||||||
.password("SuperSecret123!")
|
.password("SuperSecret123!")
|
||||||
.inviteCode("test-invite-code")
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
long expectedUserId = 42L;
|
long expectedUserId = 42L;
|
||||||
@@ -105,7 +105,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
|||||||
var request = RegistrationRequest.builder()
|
var request = RegistrationRequest.builder()
|
||||||
.email("test@example.com")
|
.email("test@example.com")
|
||||||
.password("SuperSecret123!")
|
.password("SuperSecret123!")
|
||||||
.inviteCode("test-invite-code")
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
when(registrationService.register(any(Registration.class), any()))
|
when(registrationService.register(any(Registration.class), any()))
|
||||||
@@ -119,7 +118,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void register_OldClientSendingASteamId_IsAcceptedAndTheIdIgnored() throws Exception {
|
void register_OldClientSendingASteamIdAndAnInviteCode_IsAcceptedAndBothIgnored() throws Exception {
|
||||||
when(registrationService.register(any(Registration.class), any())).thenReturn(42L);
|
when(registrationService.register(any(Registration.class), any())).thenReturn(42L);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/registration/new")
|
mockMvc.perform(post("/api/registration/new")
|
||||||
@@ -134,7 +133,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
|||||||
var request = RegistrationRequest.builder()
|
var request = RegistrationRequest.builder()
|
||||||
.email("test@example.com")
|
.email("test@example.com")
|
||||||
.password("SuperSecret123!")
|
.password("SuperSecret123!")
|
||||||
.inviteCode("code")
|
|
||||||
.build();
|
.build();
|
||||||
when(registrationService.register(any(Registration.class), any()))
|
when(registrationService.register(any(Registration.class), any()))
|
||||||
.thenThrow(duplicate("users_email_lower"));
|
.thenThrow(duplicate("users_email_lower"));
|
||||||
@@ -154,7 +152,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
|||||||
mockMvc.perform(post("/api/registration/new")
|
mockMvc.perform(post("/api/registration/new")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("""
|
.content("""
|
||||||
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","displayName":"Lobo"}"""))
|
{"email":"test@example.com","password":"SuperSecret123!","displayName":"Lobo"}"""))
|
||||||
.andExpect(status().isConflict())
|
.andExpect(status().isConflict())
|
||||||
.andExpect(jsonPath("$.code").value("DISPLAY_NAME_ALREADY_IN_USE"));
|
.andExpect(jsonPath("$.code").value("DISPLAY_NAME_ALREADY_IN_USE"));
|
||||||
}
|
}
|
||||||
@@ -166,7 +164,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
|||||||
mockMvc.perform(post("/api/registration/new")
|
mockMvc.perform(post("/api/registration/new")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("""
|
.content("""
|
||||||
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","language":"de"}"""))
|
{"email":"test@example.com","password":"SuperSecret123!","language":"de"}"""))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
ArgumentCaptor<Registration> registration = ArgumentCaptor.forClass(Registration.class);
|
ArgumentCaptor<Registration> registration = ArgumentCaptor.forClass(Registration.class);
|
||||||
@@ -179,7 +177,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
|||||||
mockMvc.perform(post("/api/registration/new")
|
mockMvc.perform(post("/api/registration/new")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("""
|
.content("""
|
||||||
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","language":"fr"}"""))
|
{"email":"test@example.com","password":"SuperSecret123!","language":"fr"}"""))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
|
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
|
||||||
}
|
}
|
||||||
@@ -198,7 +196,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
|||||||
mockMvc.perform(post("/api/registration/new")
|
mockMvc.perform(post("/api/registration/new")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("""
|
.content("""
|
||||||
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code"}"""))
|
{"email":"test@example.com","password":"SuperSecret123!"}"""))
|
||||||
.andExpect(status().isInternalServerError())
|
.andExpect(status().isInternalServerError())
|
||||||
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"))
|
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"))
|
||||||
.andExpect(content().string(not(containsString("secret detail"))));
|
.andExpect(content().string(not(containsString("secret detail"))));
|
||||||
@@ -209,7 +207,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
|||||||
mockMvc.perform(post("/api/registration/new")
|
mockMvc.perform(post("/api/registration/new")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("""
|
.content("""
|
||||||
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","displayName":"x"}"""))
|
{"email":"test@example.com","password":"SuperSecret123!","displayName":"x"}"""))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
|
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
|
||||||
.andExpect(jsonPath("$.fields[0].field").value("displayName"));
|
.andExpect(jsonPath("$.fields[0].field").value("displayName"));
|
||||||
@@ -226,7 +224,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
|||||||
mockMvc.perform(post("/api/registration/new")
|
mockMvc.perform(post("/api/registration/new")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("""
|
.content("""
|
||||||
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","displayName":"Lobo"}"""))
|
{"email":"test@example.com","password":"SuperSecret123!","displayName":"Lobo"}"""))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -245,7 +243,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
|||||||
.header("X-Forwarded-For", "10.0.0.1, 203.0.113.7")
|
.header("X-Forwarded-For", "10.0.0.1, 203.0.113.7")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("""
|
.content("""
|
||||||
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code"}"""))
|
{"email":"test@example.com","password":"SuperSecret123!"}"""))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.userId").value(42L));
|
.andExpect(jsonPath("$.userId").value(42L));
|
||||||
}
|
}
|
||||||
@@ -260,22 +258,33 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
|||||||
mockMvc.perform(post("/api/registration/new")
|
mockMvc.perform(post("/api/registration/new")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("""
|
.content("""
|
||||||
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code"}"""))
|
{"email":"test@example.com","password":"SuperSecret123!"}"""))
|
||||||
.andExpect(status().isTooManyRequests())
|
.andExpect(status().isTooManyRequests())
|
||||||
.andExpect(header().string("Retry-After", "900"))
|
.andExpect(header().string("Retry-After", "900"))
|
||||||
.andExpect(jsonPath("$.code").value("TOO_MANY_ATTEMPTS"));
|
.andExpect(jsonPath("$.code").value("TOO_MANY_ATTEMPTS"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void register_FullQueue_Is409PendingLimitReached() throws Exception {
|
||||||
|
when(registrationService.register(any(Registration.class), any())).thenThrow(new PendingLimitReachedException());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/registration/new")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{"email":"test@example.com","password":"SuperSecret123!"}"""))
|
||||||
|
.andExpect(status().isConflict())
|
||||||
|
.andExpect(jsonPath("$.code").value("PENDING_LIMIT_REACHED"));
|
||||||
|
}
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@CsvSource(delimiter = '|', value = {
|
@CsvSource(delimiter = '|', value = {
|
||||||
" |SuperSecret123!|code",
|
" |SuperSecret123!",
|
||||||
"no-at-sign|SuperSecret123!|code",
|
"no-at-sign|SuperSecret123!",
|
||||||
"test@example.com| |code",
|
"test@example.com| ",
|
||||||
"test@example.com|short|code",
|
"test@example.com|short",
|
||||||
"test@example.com|SuperSecret123!| ",
|
|
||||||
})
|
})
|
||||||
void register_InvalidInput_Is400WithoutRegistering(String email, String password, String inviteCode) throws Exception {
|
void register_InvalidInput_Is400WithoutRegistering(String email, String password) throws Exception {
|
||||||
var request = RegistrationRequest.builder().email(email).password(password).inviteCode(inviteCode).build();
|
var request = RegistrationRequest.builder().email(email).password(password).build();
|
||||||
|
|
||||||
mockMvc.perform(post("/api/registration/new")
|
mockMvc.perform(post("/api/registration/new")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@@ -288,8 +297,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
|||||||
// BCrypt uses only 72 bytes: a longer password would mostly be ignored, or refused with a 500.
|
// BCrypt uses only 72 bytes: a longer password would mostly be ignored, or refused with a 500.
|
||||||
@Test
|
@Test
|
||||||
void register_PasswordOver72Bytes_Is400() throws Exception {
|
void register_PasswordOver72Bytes_Is400() throws Exception {
|
||||||
var request = RegistrationRequest.builder().email("test@example.com").password("é".repeat(37))
|
var request = RegistrationRequest.builder().email("test@example.com").password("é".repeat(37)).build();
|
||||||
.inviteCode("code").build();
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/registration/new")
|
mockMvc.perform(post("/api/registration/new")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@@ -301,8 +309,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void register_Email_IsTrimmedAndLowerCase() throws Exception {
|
void register_Email_IsTrimmedAndLowerCase() throws Exception {
|
||||||
var request = RegistrationRequest.builder().email(" Bob@Example.COM ").password("SuperSecret123!")
|
var request = RegistrationRequest.builder().email(" Bob@Example.COM ").password("SuperSecret123!").build();
|
||||||
.inviteCode("code").build();
|
|
||||||
when(registrationService.register(any(Registration.class), any())).thenReturn(1L);
|
when(registrationService.register(any(Registration.class), any())).thenReturn(1L);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/registration/new")
|
mockMvc.perform(post("/api/registration/new")
|
||||||
@@ -324,7 +331,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
|||||||
mockMvc.perform(post("/api/registration/new")
|
mockMvc.perform(post("/api/registration/new")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("""
|
.content("""
|
||||||
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code"}"""))
|
{"email":"test@example.com","password":"SuperSecret123!"}"""))
|
||||||
.andExpect(status().isInternalServerError())
|
.andExpect(status().isInternalServerError())
|
||||||
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"));
|
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"));
|
||||||
|
|
||||||
|
|||||||
+10
@@ -64,6 +64,16 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertEquals(false, settings.get("public_profile"));
|
assertEquals(false, settings.get("public_profile"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void save_StoresAPendingRequestAndCountsIt() {
|
||||||
|
long before = registeredUserRepository.countPending();
|
||||||
|
|
||||||
|
long id = registeredUserRepository.save(registration("pending@example.com", null));
|
||||||
|
|
||||||
|
assertEquals("PENDING", jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, id));
|
||||||
|
assertEquals(before + 1, registeredUserRepository.countPending());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void save_StoresTheLanguageOfTheRegistrationPage() {
|
void save_StoresTheLanguageOfTheRegistrationPage() {
|
||||||
Registration registration = registration("lang@example.com", null);
|
Registration registration = registration("lang@example.com", null);
|
||||||
|
|||||||
+11
@@ -40,6 +40,17 @@ class RegistrationAttemptServiceTest extends AbstractUnitTest {
|
|||||||
assertDoesNotThrow(() -> attempts.acquire("5.6.7.8"));
|
assertDoesNotThrow(() -> attempts.acquire("5.6.7.8"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void takesAtMostThreeRequestsPerIp() {
|
||||||
|
for (int i = 0; i < RegistrationAttemptService.MAX_REQUESTS_PER_IP; i++) {
|
||||||
|
attempts.countRequest("1.2.3.4");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThrows(TooManyRegistrationAttemptsException.class, () -> attempts.countRequest("1.2.3.4"));
|
||||||
|
assertDoesNotThrow(() -> attempts.countRequest("5.6.7.8"));
|
||||||
|
assertDoesNotThrow(() -> attempts.acquire("1.2.3.4"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void successDoesNotUseUpAnAttempt() {
|
void successDoesNotUseUpAnAttempt() {
|
||||||
for (int i = 0; i < RegistrationAttemptService.MAX_FAILURES_PER_IP * 2; i++) {
|
for (int i = 0; i < RegistrationAttemptService.MAX_FAILURES_PER_IP * 2; i++) {
|
||||||
|
|||||||
+26
-15
@@ -1,8 +1,8 @@
|
|||||||
package auth_service.modules.registration.service;
|
package auth_service.modules.registration.service;
|
||||||
|
|
||||||
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
|
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
|
||||||
import auth_service.modules.registration.exception.InvalidInvitationCodeException;
|
|
||||||
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
|
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
|
||||||
|
import auth_service.modules.registration.exception.PendingLimitReachedException;
|
||||||
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
|
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
|
||||||
import auth_service.modules.registration.model.Registration;
|
import auth_service.modules.registration.model.Registration;
|
||||||
import auth_service.modules.registration.persistence.RegisteredUserRepository;
|
import auth_service.modules.registration.persistence.RegisteredUserRepository;
|
||||||
@@ -26,9 +26,6 @@ class RegistrationServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
private static final String IP = "203.0.113.7";
|
private static final String IP = "203.0.113.7";
|
||||||
|
|
||||||
@Mock
|
|
||||||
private CodeValidationService codeValidationService;
|
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private RegisteredUserRepository registeredUserRepository;
|
private RegisteredUserRepository registeredUserRepository;
|
||||||
|
|
||||||
@@ -51,7 +48,6 @@ class RegistrationServiceTest extends AbstractUnitTest {
|
|||||||
validRegistration = Registration.builder()
|
validRegistration = Registration.builder()
|
||||||
.email("test@example.com")
|
.email("test@example.com")
|
||||||
.rawPassword("SuperSecret123!")
|
.rawPassword("SuperSecret123!")
|
||||||
.inviteCode("test-invite-code")
|
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +55,6 @@ class RegistrationServiceTest extends AbstractUnitTest {
|
|||||||
void register_Success() {
|
void register_Success() {
|
||||||
// Arrange
|
// Arrange
|
||||||
long expectedUserId = 42L;
|
long expectedUserId = 42L;
|
||||||
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
|
|
||||||
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
|
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
|
||||||
when(passwordEncoder.encode(validRegistration.getRawPassword())).thenReturn("hashedPasswordXYZ");
|
when(passwordEncoder.encode(validRegistration.getRawPassword())).thenReturn("hashedPasswordXYZ");
|
||||||
when(registeredUserRepository.save(any(Registration.class))).thenReturn(expectedUserId);
|
when(registeredUserRepository.save(any(Registration.class))).thenReturn(expectedUserId);
|
||||||
@@ -74,16 +69,15 @@ class RegistrationServiceTest extends AbstractUnitTest {
|
|||||||
assertEquals("hashedPasswordXYZ", validRegistration.getEncryptedPassword());
|
assertEquals("hashedPasswordXYZ", validRegistration.getEncryptedPassword());
|
||||||
verify(registrationAttemptService).acquire(IP);
|
verify(registrationAttemptService).acquire(IP);
|
||||||
verify(registrationAttemptService).release(IP);
|
verify(registrationAttemptService).release(IP);
|
||||||
verify(accountMailer).userRegistered(expectedUserId);
|
verify(registrationAttemptService).countRequest(IP);
|
||||||
|
verify(accountMailer).registrationRequested(expectedUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void register_InvalidInviteCode() {
|
void register_AFullQueue_RefusesAndCountsAsAFailedAttempt() {
|
||||||
// Arrange
|
when(registeredUserRepository.countPending()).thenReturn((long) RegistrationService.MAX_PENDING);
|
||||||
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(false);
|
|
||||||
|
|
||||||
// Act & Assert
|
assertThrows(PendingLimitReachedException.class, () -> registrationService.register(validRegistration, IP));
|
||||||
assertThrows(InvalidInvitationCodeException.class, () -> registrationService.register(validRegistration, IP));
|
|
||||||
|
|
||||||
verify(registeredUserRepository, never()).existsByEmail(anyString());
|
verify(registeredUserRepository, never()).existsByEmail(anyString());
|
||||||
verify(registeredUserRepository, never()).save(any());
|
verify(registeredUserRepository, never()).save(any());
|
||||||
@@ -91,10 +85,27 @@ class RegistrationServiceTest extends AbstractUnitTest {
|
|||||||
verifyNoInteractions(accountMailer);
|
verifyNoInteractions(accountMailer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void register_OneBelowTheLimit_IsTaken() {
|
||||||
|
when(registeredUserRepository.countPending()).thenReturn((long) RegistrationService.MAX_PENDING - 1);
|
||||||
|
when(registeredUserRepository.save(any(Registration.class))).thenReturn(43L);
|
||||||
|
|
||||||
|
assertEquals(43L, registrationService.register(validRegistration, IP));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void register_TooManyRequestsFromOneIp_SavesNothing() {
|
||||||
|
doThrow(new TooManyRegistrationAttemptsException(900)).when(registrationAttemptService).countRequest(IP);
|
||||||
|
|
||||||
|
assertThrows(TooManyRegistrationAttemptsException.class, () -> registrationService.register(validRegistration, IP));
|
||||||
|
|
||||||
|
verify(registeredUserRepository, never()).save(any());
|
||||||
|
verifyNoInteractions(accountMailer);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void register_EmailAlreadyInUse() {
|
void register_EmailAlreadyInUse() {
|
||||||
// Arrange
|
// Arrange
|
||||||
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
|
|
||||||
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(true);
|
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(true);
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
@@ -102,13 +113,13 @@ class RegistrationServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
verify(registeredUserRepository, never()).save(any());
|
verify(registeredUserRepository, never()).save(any());
|
||||||
verify(registrationAttemptService, never()).release(anyString());
|
verify(registrationAttemptService, never()).release(anyString());
|
||||||
|
verify(registrationAttemptService, never()).countRequest(anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void register_DisplayNameTaken() {
|
void register_DisplayNameTaken() {
|
||||||
// Arrange
|
// Arrange
|
||||||
validRegistration.setDisplayName("Lobo");
|
validRegistration.setDisplayName("Lobo");
|
||||||
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
|
|
||||||
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
|
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
|
||||||
when(registeredUserRepository.existsByDisplayName("Lobo")).thenReturn(true);
|
when(registeredUserRepository.existsByDisplayName("Lobo")).thenReturn(true);
|
||||||
|
|
||||||
@@ -125,7 +136,7 @@ class RegistrationServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
assertThrows(TooManyRegistrationAttemptsException.class, () -> registrationService.register(validRegistration, IP));
|
assertThrows(TooManyRegistrationAttemptsException.class, () -> registrationService.register(validRegistration, IP));
|
||||||
verifyNoInteractions(codeValidationService, registeredUserRepository);
|
verifyNoInteractions(registeredUserRepository);
|
||||||
verify(registrationAttemptService, never()).release(anyString());
|
verify(registrationAttemptService, never()).release(anyString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,7 @@ import java.security.KeyPairGenerator;
|
|||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base class for integration tests.
|
* Base class for integration tests.
|
||||||
@@ -43,6 +44,16 @@ public abstract class AbstractIntegrationTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private JdbcTemplate baseJdbc;
|
private JdbcTemplate baseJdbc;
|
||||||
|
|
||||||
|
private static final AtomicInteger NEXT_CLIENT = new AtomicInteger();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For X-Forwarded-For: a new client address per registration, because Auth takes at most 3 per address.
|
||||||
|
*/
|
||||||
|
protected static String freshClientIp() {
|
||||||
|
int client = NEXT_CLIENT.incrementAndGet();
|
||||||
|
return "198.18." + client / 250 + "." + (client % 250 + 1);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For users a test inserts by SQL. Registration inserts the row itself.
|
* For users a test inserts by SQL. Registration inserts the row itself.
|
||||||
*/
|
*/
|
||||||
@@ -58,7 +69,6 @@ public abstract class AbstractIntegrationTest {
|
|||||||
static void secrets(DynamicPropertyRegistry registry) {
|
static void secrets(DynamicPropertyRegistry registry) {
|
||||||
registry.add("security.jwt.private-key", () -> Base64.getEncoder().encodeToString(KEYS.getPrivate().getEncoded()));
|
registry.add("security.jwt.private-key", () -> Base64.getEncoder().encodeToString(KEYS.getPrivate().getEncoded()));
|
||||||
registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(KEYS.getPublic().getEncoded()));
|
registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(KEYS.getPublic().getEncoded()));
|
||||||
registry.add("auth.registration.invitation-code", () -> "test-invite-code");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user