feat(admin): accept or deny registrations

This commit is contained in:
lobo committed 2026-10-01 09:18:12 +02:00
1 parent 1cac0c6b81
commit 1008034e82
29 files changed
+404 -43

No files matched your search

+10 -3
View File
@@ -65,6 +65,11 @@ 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
@@ -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 |
@@ -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;
@@ -51,26 +53,48 @@ 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();
} }
@@ -41,6 +41,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,
@@ -42,6 +42,18 @@ public class MailService implements AccountMailer {
}); });
} }
@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 ->
@@ -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'";
} }
} }
@@ -13,6 +13,10 @@ public interface AccountMailer {
*/ */
void userRegistered(long userId); void userRegistered(long userId);
void accountAccepted(long userId);
void registrationDenied(long userId);
void accountBlocked(long userId); void accountBlocked(long userId);
/** /**
@@ -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;
@@ -174,6 +178,19 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
assertThat(totalUsers()).isEqualTo(usersBefore - 1); assertThat(totalUsers()).isEqualTo(usersBefore - 1);
} }
@Test
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 @Test
void registration_CountsInThePublicStatsAtOnce() throws Exception { void registration_CountsInThePublicStatsAtOnce() throws Exception {
String newEmail = "delete-flow-registered@example.com"; String newEmail = "delete-flow-registered@example.com";
@@ -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();
@@ -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());