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