diff --git a/readme.md b/readme.md index 18e1d28..a5964c1 100644 --- a/readme.md +++ b/readme.md @@ -38,6 +38,15 @@ To Be Played is split into three repositories: 10 minutes, `typ=public-profile`) that Core accepts for the public library data only. Its `profileStats` claim names the stat groups the user chose to show there (none by default). The ticket is not a login: both services reject every token with a `typ` claim on normal endpoints. +- **Roles**: one per user, a ladder `USER < AUDITOR < PLATFORM_ADMIN` (`users.role`, the first admin is set by + SQL). The access token carries the active role in the `role` claim. **View as**: an admin switches the own login + down to USER or AUDITOR and back (`POST /api/auth/view-as`); the choice is stored per login in + `refresh_tokens.active_role` and never goes above the stored role, so a demoted admin loses the rights at the next + refresh. Core does not check roles yet. +- **Admin users page** (`/api/admin/**`, active role PLATFORM_ADMIN): search and page all users, block and unblock + (`users.enabled`; blocking ends every login, 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`. - **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 public profile, no Steam link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an undo needs a @@ -57,6 +66,10 @@ To Be Played is split into three repositories: | `PUT` | `/api/users/settings` | access token | | `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 | | `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `429`) | access token | | `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings | | `PUT` | `/api/users/settings/appearance` | access token | @@ -91,6 +104,9 @@ layers: `version` has only an `api` package: `GET /api/version` answers the commit and build time of the running image. `account` (delete and nightly cleanup) sets the flag with `JdbcTemplate` in a `repo` package too, and calls Core through `CoreAccountService`. +`admin` (users page, audit) works the same way. It deletes through the port `shared/account/AccountDeleter`, and +the login module writes "view as" to the audit table through `shared/audit/AdminAuditLog`, because modules never +use each other. ArchUnit enforces these rules in `src/test/java/auth_service/architecture/ArchitectureTest.java`, so a layer violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base @@ -113,6 +129,9 @@ sequenceDiagram B->>A: POST /api/auth/refresh with tbp_refresh A-->>B: New tbp_access and tbp_refresh, old refresh token rotated B->>C: Same API call again + Note over B,A: View as (admin only) + B->>A: POST /api/auth/view-as {role} with tbp_refresh + A-->>B: New tbp_access (role claim) and tbp_refresh, active role stored for this login ``` 1. `LoginService` checks the password. `LoginController` sets both HttpOnly cookies (`AuthCookies`), so no script diff --git a/src/main/java/auth_service/configuration/security/JwtAuthenticationFilter.java b/src/main/java/auth_service/configuration/security/JwtAuthenticationFilter.java index 79fd4a8..12245de 100644 --- a/src/main/java/auth_service/configuration/security/JwtAuthenticationFilter.java +++ b/src/main/java/auth_service/configuration/security/JwtAuthenticationFilter.java @@ -1,6 +1,7 @@ package auth_service.configuration.security; import auth_service.shared.api.AuthCookies; +import auth_service.shared.security.Role; import auth_service.shared.security.UserPrincipal; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -9,13 +10,15 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.jspecify.annotations.NonNull; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; -import java.util.Collections; +import java.util.Arrays; +import java.util.List; import java.util.Optional; import java.util.stream.Stream; @@ -47,7 +50,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( principal.get(), null, - Collections.emptyList() + authorities(principal.get().role()) ); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); @@ -56,6 +59,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); } + /** + * The active role and every role below it, so hasRole("AUDITOR") also lets an admin in. + */ + private static List authorities(Role role) { + return Arrays.stream(Role.values()).filter(role::atLeast) + .map(each -> new SimpleGrantedAuthority("ROLE_" + each.name())).toList(); + } + private static Optional bearer(HttpServletRequest request) { String header = request.getHeader("Authorization"); return header != null && header.startsWith(BEARER) ? Optional.of(header.substring(BEARER.length())) : Optional.empty(); diff --git a/src/main/java/auth_service/configuration/security/JwtService.java b/src/main/java/auth_service/configuration/security/JwtService.java index dc37999..87f0a2a 100644 --- a/src/main/java/auth_service/configuration/security/JwtService.java +++ b/src/main/java/auth_service/configuration/security/JwtService.java @@ -2,6 +2,7 @@ package auth_service.configuration.security; import auth_service.shared.security.ProfileTicketIssuer; import auth_service.shared.security.PublicKeyPublisher; +import auth_service.shared.security.Role; import auth_service.shared.security.ServiceTokenIssuer; import auth_service.shared.security.SessionProperties; import auth_service.shared.security.TokenGenerator; @@ -38,6 +39,7 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT private static final long SERVICE_TOKEN_EXPIRATION_MS = 60 * 1000; private static final String SERVICE_TOKEN_TYPE = "service"; private static final String ED25519 = "Ed25519"; + private static final String ROLE_CLAIM = "role"; private final JwtConfig jwtConfig; private final SessionProperties sessionProperties; @@ -62,11 +64,12 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT } @Override - public String generateToken(String userId, String email, Long steamId) { + public String generateToken(String userId, String email, Long steamId, Role role) { var builder = Jwts.builder() .header().keyId(jwtConfig.getKid()).and() .subject(email) .claim("userId", userId) + .claim(ROLE_CLAIM, role.name()) .issuedAt(new Date(System.currentTimeMillis())) .expiration(new Date(System.currentTimeMillis() + sessionProperties.getAccessTokenTtl().toMillis())); @@ -126,7 +129,8 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT if (claims.get(TYPE_CLAIM) != null || claims.getSubject() == null || userId == null) { return Optional.empty(); } - return Optional.of(new UserPrincipal(userId, claims.getSubject(), claims.get("steamId", Long.class))); + return Optional.of(new UserPrincipal(userId, claims.getSubject(), claims.get("steamId", Long.class), + Role.parse(claims.get(ROLE_CLAIM, String.class)))); } catch (Exception _) { return Optional.empty(); } diff --git a/src/main/java/auth_service/configuration/security/SecurityConfig.java b/src/main/java/auth_service/configuration/security/SecurityConfig.java index c5686f1..437a0d3 100644 --- a/src/main/java/auth_service/configuration/security/SecurityConfig.java +++ b/src/main/java/auth_service/configuration/security/SecurityConfig.java @@ -98,6 +98,8 @@ public class SecurityConfig { .exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) .authorizeHttpRequests(auth -> auth .requestMatchers(WHITE_LIST_URLS).permitAll() + // The active role: an admin who views as USER gets 403 here. + .requestMatchers("/api/admin/**").hasRole("PLATFORM_ADMIN") .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/auth_service/modules/account/service/AccountService.java b/src/main/java/auth_service/modules/account/service/AccountService.java index e30e9ec..49b82da 100644 --- a/src/main/java/auth_service/modules/account/service/AccountService.java +++ b/src/main/java/auth_service/modules/account/service/AccountService.java @@ -3,6 +3,7 @@ package auth_service.modules.account.service; import auth_service.modules.account.exception.InvalidPasswordException; import auth_service.modules.account.exception.TooManyDeleteAttemptsException; import auth_service.modules.account.repo.AccountRepository; +import auth_service.shared.account.AccountDeleter; import auth_service.shared.security.AttemptLimiter; import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.CacheEvict; @@ -16,7 +17,7 @@ import java.util.Optional; @Service @Slf4j -public class AccountService { +public class AccountService implements AccountDeleter { private static final Duration LOCK = Duration.ofMinutes(15); private static final int MAX_WRONG_PASSWORDS = 5; @@ -53,7 +54,19 @@ public class AccountService { throw new InvalidPasswordException(); } attemptLimiter.reset(key); - accountRepository.flagDeleted(userId, Instant.now()); + flagAndTellCore(userId); + } + + @Override + @CacheEvict(value = {"usersByEmail", "publicStats"}, allEntries = true) + public boolean deleteByAdmin(long userId) { + return flagAndTellCore(userId); + } + + private boolean flagAndTellCore(long userId) { + if (accountRepository.flagDeleted(userId, Instant.now()) == 0) { + return false; + } log.info("Account of user {} flagged as deleted", userId); try { coreAccountService.markDeleted(List.of(userId)); @@ -61,5 +74,6 @@ public class AccountService { log.warn("Core did not take the delete of user {}, the nightly cleanup sends it again: {}", userId, e.getMessage()); } + return true; } } diff --git a/src/main/java/auth_service/modules/admin/api/AdminExceptionController.java b/src/main/java/auth_service/modules/admin/api/AdminExceptionController.java new file mode 100644 index 0000000..8d54c7d --- /dev/null +++ b/src/main/java/auth_service/modules/admin/api/AdminExceptionController.java @@ -0,0 +1,28 @@ +package auth_service.modules.admin.api; + +import auth_service.modules.admin.exception.AdminActionRefusedException; +import auth_service.modules.admin.exception.TargetUserNotFoundException; +import auth_service.shared.api.dto.response.ErrorResponse; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(assignableTypes = AdminUserController.class) +@Order(value = 1) +public class AdminExceptionController { + + @ExceptionHandler(AdminActionRefusedException.class) + public ResponseEntity clientError(AdminActionRefusedException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT).contentType(MediaType.APPLICATION_JSON) + .body(new ErrorResponse(ex.getCode(), ex.getMessage())); + } + + @ExceptionHandler(TargetUserNotFoundException.class) + public ResponseEntity clientError(TargetUserNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).contentType(MediaType.APPLICATION_JSON) + .body(new ErrorResponse("USER_NOT_FOUND", ex.getMessage())); + } +} diff --git a/src/main/java/auth_service/modules/admin/api/AdminUserController.java b/src/main/java/auth_service/modules/admin/api/AdminUserController.java new file mode 100644 index 0000000..2ca87b3 --- /dev/null +++ b/src/main/java/auth_service/modules/admin/api/AdminUserController.java @@ -0,0 +1,56 @@ +package auth_service.modules.admin.api; + +import auth_service.modules.admin.api.dto.request.BlockRequest; +import auth_service.modules.admin.api.dto.response.AdminUserPageResponse; +import auth_service.modules.admin.api.dto.response.AdminUserResponse; +import auth_service.modules.admin.model.AdminUser; +import auth_service.modules.admin.model.AdminUserPage; +import auth_service.modules.admin.service.AdminUserService; +import auth_service.shared.security.UserPrincipal; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/admin/users") +@RequiredArgsConstructor +public class AdminUserController { + + private final AdminUserService adminUserService; + + @GetMapping(version = "1.0") + public ResponseEntity findUsers(@RequestParam(defaultValue = "") String query, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + AdminUserPage found = adminUserService.findUsers(query, page, size); + return ResponseEntity.ok(new AdminUserPageResponse(found.content().stream().map(AdminUserController::toApi).toList(), + found.page(), found.size(), found.totalElements(), found.totalPages())); + } + + @PutMapping(value = "/{userId}/blocked", version = "1.0") + public ResponseEntity setBlocked(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId, + @Valid @RequestBody BlockRequest request) { + adminUserService.setBlocked(principal.getIdAsLong(), userId, request.blocked()); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping(value = "/{userId}", version = "1.0") + public ResponseEntity delete(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId) { + adminUserService.delete(principal.getIdAsLong(), userId); + return ResponseEntity.noContent().build(); + } + + 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()); + } +} diff --git a/src/main/java/auth_service/modules/admin/api/dto/request/BlockRequest.java b/src/main/java/auth_service/modules/admin/api/dto/request/BlockRequest.java new file mode 100644 index 0000000..22811df --- /dev/null +++ b/src/main/java/auth_service/modules/admin/api/dto/request/BlockRequest.java @@ -0,0 +1,6 @@ +package auth_service.modules.admin.api.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record BlockRequest(@NotNull Boolean blocked) { +} diff --git a/src/main/java/auth_service/modules/admin/api/dto/response/AdminUserPageResponse.java b/src/main/java/auth_service/modules/admin/api/dto/response/AdminUserPageResponse.java new file mode 100644 index 0000000..91e338e --- /dev/null +++ b/src/main/java/auth_service/modules/admin/api/dto/response/AdminUserPageResponse.java @@ -0,0 +1,7 @@ +package auth_service.modules.admin.api.dto.response; + +import java.util.List; + +public record AdminUserPageResponse(List content, int page, int size, long totalElements, + int totalPages) { +} diff --git a/src/main/java/auth_service/modules/admin/api/dto/response/AdminUserResponse.java b/src/main/java/auth_service/modules/admin/api/dto/response/AdminUserResponse.java new file mode 100644 index 0000000..ece77f0 --- /dev/null +++ b/src/main/java/auth_service/modules/admin/api/dto/response/AdminUserResponse.java @@ -0,0 +1,8 @@ +package auth_service.modules.admin.api.dto.response; + +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) { +} diff --git a/src/main/java/auth_service/modules/admin/exception/AdminActionRefusedException.java b/src/main/java/auth_service/modules/admin/exception/AdminActionRefusedException.java new file mode 100644 index 0000000..6edb44a --- /dev/null +++ b/src/main/java/auth_service/modules/admin/exception/AdminActionRefusedException.java @@ -0,0 +1,16 @@ +package auth_service.modules.admin.exception; + +import lombok.Getter; + +@Getter +public class AdminActionRefusedException extends RuntimeException { + /** + * SELF_ACTION, TARGET_IS_ADMIN or ALREADY_DELETED + */ + private final String code; + + public AdminActionRefusedException(String code, String message) { + super(message); + this.code = code; + } +} diff --git a/src/main/java/auth_service/modules/admin/exception/TargetUserNotFoundException.java b/src/main/java/auth_service/modules/admin/exception/TargetUserNotFoundException.java new file mode 100644 index 0000000..252f8e4 --- /dev/null +++ b/src/main/java/auth_service/modules/admin/exception/TargetUserNotFoundException.java @@ -0,0 +1,7 @@ +package auth_service.modules.admin.exception; + +public class TargetUserNotFoundException extends RuntimeException { + public TargetUserNotFoundException(long userId) { + super("No user with id " + userId); + } +} diff --git a/src/main/java/auth_service/modules/admin/model/AdminUser.java b/src/main/java/auth_service/modules/admin/model/AdminUser.java new file mode 100644 index 0000000..843a113 --- /dev/null +++ b/src/main/java/auth_service/modules/admin/model/AdminUser.java @@ -0,0 +1,13 @@ +package auth_service.modules.admin.model; + +import auth_service.shared.security.Role; + +import java.time.Instant; +import java.time.LocalDateTime; + +/** + * @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) { +} diff --git a/src/main/java/auth_service/modules/admin/model/AdminUserPage.java b/src/main/java/auth_service/modules/admin/model/AdminUserPage.java new file mode 100644 index 0000000..c516ba6 --- /dev/null +++ b/src/main/java/auth_service/modules/admin/model/AdminUserPage.java @@ -0,0 +1,9 @@ +package auth_service.modules.admin.model; + +import java.util.List; + +/** + * The page shape of Core's library list, so the frontend pages work the same way. + */ +public record AdminUserPage(List content, int page, int size, long totalElements, int totalPages) { +} diff --git a/src/main/java/auth_service/modules/admin/model/TargetState.java b/src/main/java/auth_service/modules/admin/model/TargetState.java new file mode 100644 index 0000000..c23fcfd --- /dev/null +++ b/src/main/java/auth_service/modules/admin/model/TargetState.java @@ -0,0 +1,6 @@ +package auth_service.modules.admin.model; + +import auth_service.shared.security.Role; + +public record TargetState(Role role, boolean deleted) { +} diff --git a/src/main/java/auth_service/modules/admin/repo/AdminAuditRepository.java b/src/main/java/auth_service/modules/admin/repo/AdminAuditRepository.java new file mode 100644 index 0000000..02d14b8 --- /dev/null +++ b/src/main/java/auth_service/modules/admin/repo/AdminAuditRepository.java @@ -0,0 +1,19 @@ +package auth_service.modules.admin.repo; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class AdminAuditRepository { + + private final JdbcTemplate jdbc; + + public AdminAuditRepository(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public void insert(long actorUserId, String action, Long targetUserId, String detail) { + jdbc.update("INSERT INTO admin_audit (actor_user_id, action, target_user_id, detail) VALUES (?, ?, ?, ?)", + actorUserId, action, targetUserId, detail); + } +} diff --git a/src/main/java/auth_service/modules/admin/repo/AdminUserRepository.java b/src/main/java/auth_service/modules/admin/repo/AdminUserRepository.java new file mode 100644 index 0000000..b2d9a1d --- /dev/null +++ b/src/main/java/auth_service/modules/admin/repo/AdminUserRepository.java @@ -0,0 +1,85 @@ +package auth_service.modules.admin.repo; + +import auth_service.modules.admin.model.AdminUser; +import auth_service.modules.admin.model.TargetState; +import auth_service.shared.security.Role; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * Plain SQL on users, because the user and login modules own the entities. + */ +@Repository +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 RowMapper ROW = (rs, row) -> new AdminUser( + rs.getLong("id"), + rs.getString("email"), + rs.getString("display_name"), + Role.parse(rs.getString("role")), + localDateTime(rs.getTimestamp("registration_date")), + 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); + + private final JdbcTemplate jdbc; + + public AdminUserRepository(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + private static LocalDateTime localDateTime(Timestamp timestamp) { + return timestamp != null ? timestamp.toLocalDateTime() : null; + } + + private static Object[] matchArgs(String query) { + String pattern = "%" + query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"; + return new Object[]{query, pattern, pattern}; + } + + /** + * @param query part of the email or display name, any case; empty for all users + */ + public List findPage(String query, 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); + } + + public long count(String query) { + Long count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE " + MATCHES, Long.class, matchArgs(query)); + return count != null ? count : 0L; + } + + public Optional 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), + userId).stream().findFirst(); + } + + /** + * Blocking also ends every login of the user. + */ + @Transactional + public void setBlocked(long userId, boolean blocked, Instant now) { + jdbc.update("UPDATE users SET enabled = ? WHERE id = ?", !blocked, userId); + if (blocked) { + jdbc.update("UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL", + Timestamp.from(now), userId); + } + } +} diff --git a/src/main/java/auth_service/modules/admin/service/AdminUserService.java b/src/main/java/auth_service/modules/admin/service/AdminUserService.java new file mode 100644 index 0000000..a7015bd --- /dev/null +++ b/src/main/java/auth_service/modules/admin/service/AdminUserService.java @@ -0,0 +1,74 @@ +package auth_service.modules.admin.service; + +import auth_service.modules.admin.exception.AdminActionRefusedException; +import auth_service.modules.admin.exception.TargetUserNotFoundException; +import auth_service.modules.admin.model.AdminUserPage; +import auth_service.modules.admin.model.TargetState; +import auth_service.modules.admin.repo.AdminUserRepository; +import auth_service.shared.account.AccountDeleter; +import auth_service.shared.audit.AdminAuditLog; +import auth_service.shared.security.Role; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AdminUserService { + + private static final int MAX_PAGE_SIZE = 100; + + private final AdminUserRepository adminUserRepository; + private final AdminAuditLog adminAuditLog; + private final AccountDeleter accountDeleter; + + public AdminUserPage findUsers(String query, int page, int size) { + String search = query == null ? "" : query.trim(); + 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)); + } + + @Transactional + @CacheEvict(value = {"usersByEmail", "publicStats"}, allEntries = true) + public void setBlocked(long actorUserId, long targetUserId, boolean blocked) { + TargetState target = checkedTarget(actorUserId, targetUserId); + if (target.deleted()) { + throw new AdminActionRefusedException("ALREADY_DELETED", "The account is already deleted"); + } + adminUserRepository.setBlocked(targetUserId, blocked, Instant.now()); + adminAuditLog.record(actorUserId, blocked ? "BLOCK" : "UNBLOCK", targetUserId, null); + log.info("Admin {} {} user {}", actorUserId, blocked ? "blocked" : "unblocked", targetUserId); + } + + /** + * The same flag and nightly cleanup as "Delete my account". A user flagged before is not an error. No + * 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); + if (accountDeleter.deleteByAdmin(targetUserId)) { + adminAuditLog.record(actorUserId, "DELETE", targetUserId, null); + log.info("Admin {} deleted user {}", actorUserId, targetUserId); + } + } + + private TargetState checkedTarget(long actorUserId, long targetUserId) { + if (actorUserId == targetUserId) { + throw new AdminActionRefusedException("SELF_ACTION", "Admins cannot block or delete their own account"); + } + TargetState target = adminUserRepository.findState(targetUserId) + .orElseThrow(() -> new TargetUserNotFoundException(targetUserId)); + if (target.role() == Role.PLATFORM_ADMIN) { + throw new AdminActionRefusedException("TARGET_IS_ADMIN", "Admins cannot block or delete another admin"); + } + return target; + } +} diff --git a/src/main/java/auth_service/modules/admin/service/AuditLogService.java b/src/main/java/auth_service/modules/admin/service/AuditLogService.java new file mode 100644 index 0000000..f6782bd --- /dev/null +++ b/src/main/java/auth_service/modules/admin/service/AuditLogService.java @@ -0,0 +1,18 @@ +package auth_service.modules.admin.service; + +import auth_service.modules.admin.repo.AdminAuditRepository; +import auth_service.shared.audit.AdminAuditLog; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuditLogService implements AdminAuditLog { + + private final AdminAuditRepository adminAuditRepository; + + @Override + public void record(long actorUserId, String action, Long targetUserId, String detail) { + adminAuditRepository.insert(actorUserId, action, targetUserId, detail); + } +} diff --git a/src/main/java/auth_service/modules/login/api/LoginController.java b/src/main/java/auth_service/modules/login/api/LoginController.java index 6c2e3f5..ef408d8 100644 --- a/src/main/java/auth_service/modules/login/api/LoginController.java +++ b/src/main/java/auth_service/modules/login/api/LoginController.java @@ -1,6 +1,7 @@ package auth_service.modules.login.api; import auth_service.modules.login.api.dto.request.LoginRequest; +import auth_service.modules.login.api.dto.request.ViewAsRequest; import auth_service.modules.login.api.dto.response.LoginResponse; import auth_service.modules.login.api.mapper.ApiMapper; import auth_service.modules.login.model.Session; @@ -62,10 +63,28 @@ public class LoginController { @CookieValue(name = AuthCookies.REFRESH_COOKIE, required = false) String refreshToken) { Optional session = refreshTokenService.refresh(refreshToken); if (session.isEmpty()) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .headers(cookies(authCookies.cleared())) - .contentType(MediaType.APPLICATION_JSON) - .body(new ErrorResponse("SESSION_EXPIRED", "Please log in again")); + return sessionExpired(); + } + return ResponseEntity.noContent().headers(cookies(sessionCookies(session.get()))).build(); + } + + private ResponseEntity sessionExpired() { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .headers(cookies(authCookies.cleared())) + .contentType(MediaType.APPLICATION_JSON) + .body(new ErrorResponse("SESSION_EXPIRED", "Please log in again")); + } + + /** + * "View as": the same as a refresh, with the given role for this login. Under /api/auth, because the refresh + * cookie is sent only there. + */ + @PostMapping("/view-as") + public ResponseEntity viewAs(@RequestBody ViewAsRequest request, + @CookieValue(name = AuthCookies.REFRESH_COOKIE, required = false) String refreshToken) { + Optional session = refreshTokenService.switchRole(refreshToken, request.role()); + if (session.isEmpty()) { + return sessionExpired(); } return ResponseEntity.noContent().headers(cookies(sessionCookies(session.get()))).build(); } diff --git a/src/main/java/auth_service/modules/login/api/LoginExceptionController.java b/src/main/java/auth_service/modules/login/api/LoginExceptionController.java index b2574e3..2d5106e 100644 --- a/src/main/java/auth_service/modules/login/api/LoginExceptionController.java +++ b/src/main/java/auth_service/modules/login/api/LoginExceptionController.java @@ -1,7 +1,9 @@ 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.InvalidCredentialsException; +import auth_service.modules.login.exception.RoleNotAllowedException; import auth_service.modules.login.exception.TooManyLoginAttemptsException; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; @@ -38,6 +40,16 @@ public class LoginExceptionController { .body(body); } + @ExceptionHandler(AccountBlockedException.class) + public ResponseEntity clientError(AccountBlockedException ex) { + return response(new ErrorResponse("ACCOUNT_BLOCKED", ex.getMessage()), HttpStatus.FORBIDDEN); + } + + @ExceptionHandler(RoleNotAllowedException.class) + public ResponseEntity clientError(RoleNotAllowedException ex) { + return response(new ErrorResponse("ROLE_NOT_ALLOWED", ex.getMessage()), HttpStatus.FORBIDDEN); + } + @ExceptionHandler(InvalidCredentialsException.class) public ResponseEntity clientError(InvalidCredentialsException ex) { var body = new ErrorResponse("INVALID_CREDENTIALS", ex.getMessage()); diff --git a/src/main/java/auth_service/modules/login/api/dto/request/ViewAsRequest.java b/src/main/java/auth_service/modules/login/api/dto/request/ViewAsRequest.java new file mode 100644 index 0000000..d2f94a6 --- /dev/null +++ b/src/main/java/auth_service/modules/login/api/dto/request/ViewAsRequest.java @@ -0,0 +1,9 @@ +package auth_service.modules.login.api.dto.request; + +import auth_service.shared.security.Role; + +/** + * @param role null goes back to the own role + */ +public record ViewAsRequest(Role role) { +} diff --git a/src/main/java/auth_service/modules/login/exception/AccountBlockedException.java b/src/main/java/auth_service/modules/login/exception/AccountBlockedException.java new file mode 100644 index 0000000..3feaadb --- /dev/null +++ b/src/main/java/auth_service/modules/login/exception/AccountBlockedException.java @@ -0,0 +1,7 @@ +package auth_service.modules.login.exception; + +public class AccountBlockedException extends RuntimeException { + public AccountBlockedException() { + super("This account is blocked"); + } +} diff --git a/src/main/java/auth_service/modules/login/exception/RoleNotAllowedException.java b/src/main/java/auth_service/modules/login/exception/RoleNotAllowedException.java new file mode 100644 index 0000000..4a36663 --- /dev/null +++ b/src/main/java/auth_service/modules/login/exception/RoleNotAllowedException.java @@ -0,0 +1,7 @@ +package auth_service.modules.login.exception; + +public class RoleNotAllowedException extends RuntimeException { + public RoleNotAllowedException() { + super("You cannot act with a role above your own"); + } +} diff --git a/src/main/java/auth_service/modules/login/model/RefreshToken.java b/src/main/java/auth_service/modules/login/model/RefreshToken.java index 208b455..2f071a0 100644 --- a/src/main/java/auth_service/modules/login/model/RefreshToken.java +++ b/src/main/java/auth_service/modules/login/model/RefreshToken.java @@ -1,7 +1,13 @@ package auth_service.modules.login.model; +import auth_service.shared.security.Role; + import java.time.Instant; import java.util.UUID; -public record RefreshToken(long id, long userId, UUID familyId, Instant expiresAt, Instant rotatedAt, Instant revokedAt) { +/** + * @param activeRole the "view as" role of this login, null for the stored role + */ +public record RefreshToken(long id, long userId, UUID familyId, Instant expiresAt, Instant rotatedAt, Instant revokedAt, + Role activeRole) { } diff --git a/src/main/java/auth_service/modules/login/model/User.java b/src/main/java/auth_service/modules/login/model/User.java index 699e2fc..2545f88 100644 --- a/src/main/java/auth_service/modules/login/model/User.java +++ b/src/main/java/auth_service/modules/login/model/User.java @@ -1,5 +1,6 @@ package auth_service.modules.login.model; +import auth_service.shared.security.Role; import lombok.Builder; import lombok.Data; @@ -12,6 +13,8 @@ public class User { private Long steamId; private boolean enabled; private boolean deleted; + @Builder.Default + private Role role = Role.USER; public boolean canLogin() { return enabled && !deleted; diff --git a/src/main/java/auth_service/modules/login/persistence/RefreshTokenRepository.java b/src/main/java/auth_service/modules/login/persistence/RefreshTokenRepository.java index 1bed2eb..0b751e2 100644 --- a/src/main/java/auth_service/modules/login/persistence/RefreshTokenRepository.java +++ b/src/main/java/auth_service/modules/login/persistence/RefreshTokenRepository.java @@ -1,6 +1,7 @@ package auth_service.modules.login.persistence; import auth_service.modules.login.model.RefreshToken; +import auth_service.shared.security.Role; import java.time.Instant; import java.util.Optional; @@ -8,7 +9,10 @@ import java.util.UUID; public interface RefreshTokenRepository { - void save(long userId, UUID familyId, String tokenHash, Instant createdAt, Instant expiresAt); + /** + * @param activeRole null for the stored role + */ + void save(long userId, UUID familyId, String tokenHash, Instant createdAt, Instant expiresAt, Role activeRole); /** * Locks the row until the transaction ends, so two refreshes with the same token run one after the other. diff --git a/src/main/java/auth_service/modules/login/persistence/RefreshTokenRepositoryImpl.java b/src/main/java/auth_service/modules/login/persistence/RefreshTokenRepositoryImpl.java index acddeda..6e6c1b4 100644 --- a/src/main/java/auth_service/modules/login/persistence/RefreshTokenRepositoryImpl.java +++ b/src/main/java/auth_service/modules/login/persistence/RefreshTokenRepositoryImpl.java @@ -1,6 +1,7 @@ package auth_service.modules.login.persistence; import auth_service.modules.login.model.RefreshToken; +import auth_service.shared.security.Role; import auth_service.modules.login.persistence.entities.RefreshTokenEntity; import auth_service.modules.login.persistence.mapper.EntityMapper; import jakarta.persistence.EntityManager; @@ -19,13 +20,14 @@ public class RefreshTokenRepositoryImpl implements RefreshTokenRepository { private EntityManager entityManager; @Override - public void save(long userId, UUID familyId, String tokenHash, Instant createdAt, Instant expiresAt) { + public void save(long userId, UUID familyId, String tokenHash, Instant createdAt, Instant expiresAt, Role activeRole) { RefreshTokenEntity entity = new RefreshTokenEntity(); entity.setUserId(userId); entity.setFamilyId(familyId); entity.setTokenHash(tokenHash); entity.setCreatedAt(createdAt); entity.setExpiresAt(expiresAt); + entity.setActiveRole(activeRole == null ? null : activeRole.name()); entityManager.persist(entity); } diff --git a/src/main/java/auth_service/modules/login/persistence/entities/MinimalUserEntity.java b/src/main/java/auth_service/modules/login/persistence/entities/MinimalUserEntity.java index 42c57b9..9fe6dfe 100644 --- a/src/main/java/auth_service/modules/login/persistence/entities/MinimalUserEntity.java +++ b/src/main/java/auth_service/modules/login/persistence/entities/MinimalUserEntity.java @@ -39,4 +39,8 @@ public class MinimalUserEntity { @Column(name = "deleted_at", insertable = false, updatable = false) private Instant deletedAt; + + // Set by SQL only (the first admin). + @Column(name = "role", insertable = false, updatable = false) + private String role; } diff --git a/src/main/java/auth_service/modules/login/persistence/entities/RefreshTokenEntity.java b/src/main/java/auth_service/modules/login/persistence/entities/RefreshTokenEntity.java index 69d7e3a..852d2be 100644 --- a/src/main/java/auth_service/modules/login/persistence/entities/RefreshTokenEntity.java +++ b/src/main/java/auth_service/modules/login/persistence/entities/RefreshTokenEntity.java @@ -42,4 +42,7 @@ public class RefreshTokenEntity { @Column(name = "revoked_at") private Instant revokedAt; + + @Column(name = "active_role") + private String activeRole; } diff --git a/src/main/java/auth_service/modules/login/persistence/mapper/EntityMapper.java b/src/main/java/auth_service/modules/login/persistence/mapper/EntityMapper.java index 89eed38..adfdf82 100644 --- a/src/main/java/auth_service/modules/login/persistence/mapper/EntityMapper.java +++ b/src/main/java/auth_service/modules/login/persistence/mapper/EntityMapper.java @@ -4,6 +4,7 @@ import auth_service.modules.login.model.RefreshToken; import auth_service.modules.login.model.User; import auth_service.modules.login.persistence.entities.MinimalUserEntity; import auth_service.modules.login.persistence.entities.RefreshTokenEntity; +import auth_service.shared.security.Role; public abstract class EntityMapper { @@ -20,6 +21,7 @@ public abstract class EntityMapper { model.setSteamId(entity.getSteamId()); model.setEnabled(entity.isEnabled()); model.setDeleted(entity.getDeletedAt() != null); + model.setRole(Role.parse(entity.getRole())); } return model; @@ -27,6 +29,7 @@ public abstract class EntityMapper { public static RefreshToken mapToModel(RefreshTokenEntity entity) { return new RefreshToken(entity.getId(), entity.getUserId(), entity.getFamilyId(), entity.getExpiresAt(), - entity.getRotatedAt(), entity.getRevokedAt()); + entity.getRotatedAt(), entity.getRevokedAt(), + entity.getActiveRole() == null ? null : Role.parse(entity.getActiveRole())); } } diff --git a/src/main/java/auth_service/modules/login/service/LoginService.java b/src/main/java/auth_service/modules/login/service/LoginService.java index d4cafc8..a63ef2b 100644 --- a/src/main/java/auth_service/modules/login/service/LoginService.java +++ b/src/main/java/auth_service/modules/login/service/LoginService.java @@ -1,12 +1,14 @@ package auth_service.modules.login.service; import auth_service.shared.security.TokenGenerator; +import auth_service.modules.login.exception.AccountBlockedException; import auth_service.modules.login.exception.InvalidCredentialsException; import auth_service.modules.login.model.Session; import auth_service.modules.login.model.User; import auth_service.modules.login.persistence.MinimalUserRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.cache.annotation.CacheEvict; import org.springframework.stereotype.Service; import java.util.Optional; @@ -36,12 +38,14 @@ public class LoginService { } /** - * Authenticates a user and starts a session. Unknown email, wrong password and disabled - * account all fail with the same InvalidCredentialsException. + * 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. * * @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. */ + // /api/users/me caches the user by email; a role set by SQL must show after the next login. + @CacheEvict(value = "usersByEmail", key = "#userToLogin.email") public Session login(User userToLogin, String clientIp, String previousRefreshToken) { String email = userToLogin.getEmail(); loginAttemptService.acquire(email, clientIp); @@ -50,15 +54,22 @@ public class LoginService { String hash = foundUser.map(User::getPassword).orElse(dummyHash); boolean passwordMatches = passwordEncoder.matches(userToLogin.getPassword(), hash); - if (foundUser.isEmpty() || !passwordMatches || !foundUser.get().canLogin()) { + 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. + 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(); + } User user = foundUser.get(); loginAttemptService.recordSuccess(email, clientIp); minimalUserRepository.updateLoginDate(user.getId()); refreshTokenService.revoke(previousRefreshToken); - String accessToken = tokenGenerator.generateToken(String.valueOf(user.getId()), user.getEmail(), user.getSteamId()); + String accessToken = tokenGenerator.generateToken(String.valueOf(user.getId()), user.getEmail(), user.getSteamId(), + user.getRole()); return new Session(accessToken, refreshTokenService.startFamily(user.getId())); } } diff --git a/src/main/java/auth_service/modules/login/service/RefreshTokenService.java b/src/main/java/auth_service/modules/login/service/RefreshTokenService.java index 7b462df..c09d7a9 100644 --- a/src/main/java/auth_service/modules/login/service/RefreshTokenService.java +++ b/src/main/java/auth_service/modules/login/service/RefreshTokenService.java @@ -5,6 +5,9 @@ import auth_service.modules.login.model.Session; import auth_service.modules.login.model.User; import auth_service.modules.login.persistence.MinimalUserRepository; import auth_service.modules.login.persistence.RefreshTokenRepository; +import auth_service.modules.login.exception.RoleNotAllowedException; +import auth_service.shared.audit.AdminAuditLog; +import auth_service.shared.security.Role; import auth_service.shared.security.SessionProperties; import auth_service.shared.security.TokenGenerator; import lombok.RequiredArgsConstructor; @@ -39,10 +42,11 @@ public class RefreshTokenService { private final MinimalUserRepository userRepository; private final TokenGenerator tokenGenerator; private final SessionProperties sessionProperties; + private final AdminAuditLog adminAuditLog; @Transactional public String startFamily(long userId) { - return issue(userId, UUID.randomUUID(), Instant.now()); + return issue(userId, UUID.randomUUID(), Instant.now(), null); } /** @@ -53,6 +57,22 @@ public class RefreshTokenService { */ @Transactional public Optional refresh(String rawToken) { + return rotate(rawToken, false, null); + } + + /** + * Like refresh, and the new tokens act with another role of this login ("view as"). The login keeps it on + * every refresh until the next switch. + * + * @param role null goes back to the stored role + * @throws RoleNotAllowedException for a role above the stored one + */ + @Transactional + public Optional switchRole(String rawToken, Role role) { + return rotate(rawToken, true, role); + } + + private Optional rotate(String rawToken, boolean switching, Role requested) { if (isBlank(rawToken)) { return Optional.empty(); } @@ -75,11 +95,21 @@ public class RefreshTokenService { refreshTokenRepository.revokeFamily(token.familyId(), now); return Optional.empty(); } - refreshTokenRepository.markRotated(token.id(), now); - String next = issue(token.userId(), token.familyId(), now); User current = user.get(); + Role stored = current.getRole(); + if (switching && requested != null && !stored.atLeast(requested)) { + throw new RoleNotAllowedException(); + } + Role chosen = switching ? requested : token.activeRole(); + // Never above the stored role: a demoted admin loses the rights at the next refresh. + Role active = chosen == null ? stored : Role.lower(chosen, stored); + refreshTokenRepository.markRotated(token.id(), now); + String next = issue(token.userId(), token.familyId(), now, chosen); + if (switching && stored != Role.USER) { + adminAuditLog.record(current.getId(), "VIEW_AS", null, requested == null ? "OWN_ROLE" : requested.name()); + } String accessToken = tokenGenerator.generateToken( - String.valueOf(current.getId()), current.getEmail(), current.getSteamId()); + String.valueOf(current.getId()), current.getEmail(), current.getSteamId(), active); return Optional.of(new Session(accessToken, next)); } @@ -102,11 +132,12 @@ public class RefreshTokenService { log.info("Deleted {} expired refresh tokens", deleted); } - private String issue(long userId, UUID familyId, Instant now) { + private String issue(long userId, UUID familyId, Instant now, Role activeRole) { byte[] bytes = new byte[TOKEN_BYTES]; RANDOM.nextBytes(bytes); String rawToken = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); - refreshTokenRepository.save(userId, familyId, hash(rawToken), now, now.plus(sessionProperties.getRefreshTokenTtl())); + refreshTokenRepository.save(userId, familyId, hash(rawToken), now, now.plus(sessionProperties.getRefreshTokenTtl()), + activeRole); return rawToken; } diff --git a/src/main/java/auth_service/modules/user/api/SteamLinkController.java b/src/main/java/auth_service/modules/user/api/SteamLinkController.java index fc93e5e..76b08c1 100644 --- a/src/main/java/auth_service/modules/user/api/SteamLinkController.java +++ b/src/main/java/auth_service/modules/user/api/SteamLinkController.java @@ -44,7 +44,7 @@ public class SteamLinkController { */ @DeleteMapping(value = "/api/users/steam-link", version = "1.0") public ResponseEntity unlink(@AuthenticationPrincipal UserPrincipal principal) { - String token = userService.unlinkSteamId(principal.getIdAsLong(), principal.email()); + String token = userService.unlinkSteamId(principal.getIdAsLong(), principal.email(), principal.role()); return ResponseEntity.noContent() .header(HttpHeaders.SET_COOKIE, authCookies.access(token).toString()) .build(); diff --git a/src/main/java/auth_service/modules/user/api/UserController.java b/src/main/java/auth_service/modules/user/api/UserController.java index e1804d1..53a9a51 100644 --- a/src/main/java/auth_service/modules/user/api/UserController.java +++ b/src/main/java/auth_service/modules/user/api/UserController.java @@ -35,7 +35,7 @@ public class UserController { // this is the email from the login, if a user is not authenticated, it will not even come to this point. var authenticatedEmail = principal.email(); var profileResponse = this.userService.getUserByEmail(authenticatedEmail); - return ResponseEntity.ok(ApiMapper.toApi(profileResponse)); + return ResponseEntity.ok(ApiMapper.toApi(profileResponse, principal.role())); } @PutMapping(value = "/settings", version = "1.0", produces = "application/json") diff --git a/src/main/java/auth_service/modules/user/api/dto/response/UserProfileResponse.java b/src/main/java/auth_service/modules/user/api/dto/response/UserProfileResponse.java index e63034a..199d368 100644 --- a/src/main/java/auth_service/modules/user/api/dto/response/UserProfileResponse.java +++ b/src/main/java/auth_service/modules/user/api/dto/response/UserProfileResponse.java @@ -5,5 +5,5 @@ import lombok.Builder; import java.time.LocalDateTime; @Builder -public record UserProfileResponse(long id, String email, String role, LocalDateTime lastLogin, LocalDateTime createdDate, String steamId, boolean steamIdVerified, String displayName, SettingsResponse settings) { +public record UserProfileResponse(long id, String email, String role, String activeRole, LocalDateTime lastLogin, LocalDateTime createdDate, String steamId, boolean steamIdVerified, String displayName, SettingsResponse settings) { } diff --git a/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java b/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java index 930f148..4637889 100644 --- a/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java +++ b/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java @@ -9,12 +9,16 @@ import auth_service.modules.user.api.dto.response.UserProfileResponse; import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.User; import auth_service.modules.user.model.UserSettings; +import auth_service.shared.security.Role; public abstract class ApiMapper { private ApiMapper() { } - public static UserProfileResponse toApi(User user) { + /** + * @param activeRole the role of this login ("view as") + */ + public static UserProfileResponse toApi(User user, Role activeRole) { return UserProfileResponse.builder() .id(user.getId()) .email(user.getEmail()) @@ -23,7 +27,8 @@ public abstract class ApiMapper { .steamId(user.getSteamId() == null ? null : String.valueOf(user.getSteamId())) .steamIdVerified(user.isSteamIdVerified()) .displayName(user.getDisplayName()) - .role("USER") + .role(user.getRole().name()) + .activeRole(activeRole.name()) .settings(SettingsResponse.builder() .id(user.getId()) .useDarkTheme(user.getSettings().getTheme().equals("dark")) diff --git a/src/main/java/auth_service/modules/user/model/User.java b/src/main/java/auth_service/modules/user/model/User.java index c46e324..88603aa 100644 --- a/src/main/java/auth_service/modules/user/model/User.java +++ b/src/main/java/auth_service/modules/user/model/User.java @@ -1,5 +1,6 @@ package auth_service.modules.user.model; +import auth_service.shared.security.Role; import lombok.Builder; import lombok.Data; @@ -16,6 +17,8 @@ public class User { private Long steamId; private boolean steamIdVerified; private String displayName; + @Builder.Default + private Role role = Role.USER; private UserSettings settings; } diff --git a/src/main/java/auth_service/modules/user/persistence/entities/UserEntity.java b/src/main/java/auth_service/modules/user/persistence/entities/UserEntity.java index 151e869..46ba911 100644 --- a/src/main/java/auth_service/modules/user/persistence/entities/UserEntity.java +++ b/src/main/java/auth_service/modules/user/persistence/entities/UserEntity.java @@ -41,6 +41,9 @@ public class UserEntity { @Column(name = "deleted_at", insertable = false, updatable = false) private Instant deletedAt; + @Column(name = "role", insertable = false, updatable = false) + private String role; + @Column(name = "display_name", length = 24) private String displayName; diff --git a/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java b/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java index aabc2b4..c6ce456 100644 --- a/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java +++ b/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java @@ -5,6 +5,7 @@ import auth_service.modules.user.model.User; import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.persistence.entities.UserEntity; import auth_service.modules.user.persistence.entities.UserSettingsEntity; +import auth_service.shared.security.Role; import java.util.List; @@ -49,6 +50,7 @@ public abstract class EntityMapper { .steamId(entity.getSteamId()) .steamIdVerified(entity.isSteamIdVerified()) .displayName(entity.getDisplayName()) + .role(Role.parse(entity.getRole())) .settings(settingsModel) .build(); } diff --git a/src/main/java/auth_service/modules/user/service/UserService.java b/src/main/java/auth_service/modules/user/service/UserService.java index f90b350..634b5a8 100644 --- a/src/main/java/auth_service/modules/user/service/UserService.java +++ b/src/main/java/auth_service/modules/user/service/UserService.java @@ -8,6 +8,7 @@ import auth_service.modules.user.model.SteamLinkOutcome; import auth_service.modules.user.model.User; import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.persistence.UserRepository; +import auth_service.shared.security.Role; import auth_service.shared.security.TokenGenerator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -77,10 +78,10 @@ public class UserService { */ @Transactional @CacheEvict(value = "usersByEmail", allEntries = true) - public String unlinkSteamId(long userId, String email) { + public String unlinkSteamId(long userId, String email, Role role) { userRepository.clearSteamId(userId); log.info("Steam ID unlinked for user {}", userId); - return tokenGenerator.generateToken(String.valueOf(userId), email, null); + return tokenGenerator.generateToken(String.valueOf(userId), email, null, role); } @Transactional diff --git a/src/main/java/auth_service/shared/account/AccountDeleter.java b/src/main/java/auth_service/shared/account/AccountDeleter.java new file mode 100644 index 0000000..d59cde6 --- /dev/null +++ b/src/main/java/auth_service/shared/account/AccountDeleter.java @@ -0,0 +1,12 @@ +package auth_service.shared.account; + +/** + * Port for a delete that is not the user's own, e.g. by an admin. The same flag and nightly cleanup as + * "Delete my account". + */ +public interface AccountDeleter { + /** + * @return false when the user was flagged before + */ + boolean deleteByAdmin(long userId); +} diff --git a/src/main/java/auth_service/shared/audit/AdminAuditLog.java b/src/main/java/auth_service/shared/audit/AdminAuditLog.java new file mode 100644 index 0000000..fa294f4 --- /dev/null +++ b/src/main/java/auth_service/shared/audit/AdminAuditLog.java @@ -0,0 +1,11 @@ +package auth_service.shared.audit; + +/** + * Port for the audit table of admin actions, so modules other than admin can write to it. + */ +public interface AdminAuditLog { + /** + * @param targetUserId null for an action without a target user, e.g. "view as" + */ + void record(long actorUserId, String action, Long targetUserId, String detail); +} diff --git a/src/main/java/auth_service/shared/security/Role.java b/src/main/java/auth_service/shared/security/Role.java new file mode 100644 index 0000000..2a1fa07 --- /dev/null +++ b/src/main/java/auth_service/shared/security/Role.java @@ -0,0 +1,30 @@ +package auth_service.shared.security; + +/** + * A ladder: each role has every right of the roles before it. + */ +public enum Role { + USER, AUDITOR, PLATFORM_ADMIN; + + public boolean atLeast(Role other) { + return compareTo(other) >= 0; + } + + public static Role lower(Role a, Role b) { + return a.compareTo(b) <= 0 ? a : b; + } + + /** + * USER for null or an unknown name, e.g. a token from before roles. + */ + public static Role parse(String name) { + if (name == null) { + return USER; + } + try { + return valueOf(name); + } catch (IllegalArgumentException _) { + return USER; + } + } +} diff --git a/src/main/java/auth_service/shared/security/TokenGenerator.java b/src/main/java/auth_service/shared/security/TokenGenerator.java index 5949d23..911dcc7 100644 --- a/src/main/java/auth_service/shared/security/TokenGenerator.java +++ b/src/main/java/auth_service/shared/security/TokenGenerator.java @@ -11,8 +11,9 @@ public interface TokenGenerator { * @param userId The unique identifier of the user. * @param email The email address of the user. * @param steamId The Steam ID of the user. + * @param role The active role of the login. * @return The generated token string. */ - String generateToken(String userId, String email, Long steamId); + String generateToken(String userId, String email, Long steamId, Role role); } diff --git a/src/main/java/auth_service/shared/security/UserPrincipal.java b/src/main/java/auth_service/shared/security/UserPrincipal.java index b195b31..5cf5327 100644 --- a/src/main/java/auth_service/shared/security/UserPrincipal.java +++ b/src/main/java/auth_service/shared/security/UserPrincipal.java @@ -2,12 +2,20 @@ package auth_service.shared.security; import java.security.Principal; +/** + * @param role the active role of this login ("view as"), never above the stored one + */ public record UserPrincipal( String id, String email, - Long steamId + Long steamId, + Role role ) implements Principal { + public UserPrincipal(String id, String email, Long steamId) { + this(id, email, steamId, Role.USER); + } + @Override public String getName() { return id; diff --git a/src/main/resources/db/migration/V14__Roles_And_Audit.sql b/src/main/resources/db/migration/V14__Roles_And_Audit.sql new file mode 100644 index 0000000..c771700 --- /dev/null +++ b/src/main/resources/db/migration/V14__Roles_And_Audit.sql @@ -0,0 +1,18 @@ +-- One role per user, a ladder: each role has every right of the roles before it. Set the first admin by SQL. +ALTER TABLE users ADD COLUMN role VARCHAR(16) NOT NULL DEFAULT 'USER' + CHECK (role IN ('USER', 'AUDITOR', 'PLATFORM_ADMIN')); + +-- "View as": the role this login acts with, copied on every rotation. NULL = the stored role. +ALTER TABLE refresh_tokens ADD COLUMN active_role VARCHAR(16) + CHECK (active_role IN ('USER', 'AUDITOR', 'PLATFORM_ADMIN')); + +-- One row per admin action. No foreign keys: the rows outlive a deleted user. +CREATE TABLE admin_audit ( + id BIGSERIAL PRIMARY KEY, + actor_user_id BIGINT NOT NULL, + action VARCHAR(32) NOT NULL, + target_user_id BIGINT, + detail VARCHAR(64), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_admin_audit_target ON admin_audit (target_user_id); diff --git a/src/test/java/auth_service/configuration/security/JwtServiceTest.java b/src/test/java/auth_service/configuration/security/JwtServiceTest.java index 46083a9..9efa5e6 100644 --- a/src/test/java/auth_service/configuration/security/JwtServiceTest.java +++ b/src/test/java/auth_service/configuration/security/JwtServiceTest.java @@ -1,5 +1,6 @@ package auth_service.configuration.security; +import auth_service.shared.security.Role; import auth_service.shared.security.SessionProperties; import auth_service.shared.security.UserPrincipal; import auth_service.shared.test.AbstractUnitTest; @@ -12,9 +13,11 @@ import org.mockito.Mock; import java.security.KeyFactory; import java.security.KeyPair; import java.security.KeyPairGenerator; +import java.security.PrivateKey; import java.security.PublicKey; import java.security.spec.X509EncodedKeySpec; import java.util.Base64; +import java.util.Date; import java.util.HexFormat; import java.util.List; import java.util.Map; @@ -41,10 +44,12 @@ class JwtServiceTest extends AbstractUnitTest { private JwtService jwtService; private PublicKey publicKey; + private PrivateKey privateKey; @BeforeEach void setUp() throws Exception { KeyPair keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + privateKey = keyPair.getPrivate(); String privateB64 = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()); String publicB64 = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); @@ -61,7 +66,7 @@ class JwtServiceTest extends AbstractUnitTest { @Test void generateToken_Success() { - var token = jwtService.generateToken("0815", "test@example.com", null); + var token = jwtService.generateToken("0815", "test@example.com", null, Role.USER); assertNotNull(token); assertFalse(token.isEmpty()); @@ -69,7 +74,7 @@ class JwtServiceTest extends AbstractUnitTest { @Test void generateToken_setsKidHeader() { - var token = jwtService.generateToken("0815", "test@example.com", null); + var token = jwtService.generateToken("0815", "test@example.com", null, Role.USER); var header = Jwts.parser() .verifyWith(publicKey) @@ -83,7 +88,7 @@ class JwtServiceTest extends AbstractUnitTest { @Test void parseLoginToken_ReturnsTheUser() { - var token = jwtService.generateToken("0815", "test@example.com", 123456789L); + var token = jwtService.generateToken("0815", "test@example.com", 123456789L, Role.USER); assertEquals(Optional.of(new UserPrincipal("0815", "test@example.com", 123456789L)), jwtService.parseLoginToken(token)); @@ -96,7 +101,7 @@ class JwtServiceTest extends AbstractUnitTest { @Test void generateToken_ExpiresAfterTheAccessTokenTtl() { - var token = jwtService.generateToken("0815", "test@example.com", null); + var token = jwtService.generateToken("0815", "test@example.com", null, Role.USER); var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(token).getPayload(); long secondsLeft = (claims.getExpiration().getTime() - System.currentTimeMillis()) / 1000; @@ -156,7 +161,7 @@ class JwtServiceTest extends AbstractUnitTest { System.arraycopy(prefix, 0, der, 0, prefix.length); System.arraycopy(raw, 0, der, prefix.length, raw.length); PublicKey fromJwk = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(der)); - String token = jwtService.generateToken("7", "a@b.de", null); + String token = jwtService.generateToken("7", "a@b.de", null, Role.USER); assertEquals("7", Jwts.parser().verifyWith(fromJwk).build().parseSignedClaims(token).getPayload().get("userId")); } @@ -172,4 +177,20 @@ class JwtServiceTest extends AbstractUnitTest { assertTrue(secondsLeft > 0 && secondsLeft <= 60, "expires in " + secondsLeft + "s"); assertEquals(Optional.empty(), jwtService.parseLoginToken(token)); } + + @Test + void parseLoginToken_ReadsTheRole() { + String token = jwtService.generateToken("7", "a@b.de", null, Role.AUDITOR); + + assertEquals(Role.AUDITOR, jwtService.parseLoginToken(token).orElseThrow().role()); + } + + @Test + void parseLoginToken_ATokenFromBeforeRolesIsAUser() { + String old = Jwts.builder().header().keyId(KID).and().subject("a@b.de").claim("userId", "7") + .expiration(new Date(System.currentTimeMillis() + 60_000)) + .signWith(privateKey, Jwts.SIG.EdDSA).compact(); + + assertEquals(Role.USER, jwtService.parseLoginToken(old).orElseThrow().role()); + } } diff --git a/src/test/java/auth_service/modules/account/service/AccountServiceTest.java b/src/test/java/auth_service/modules/account/service/AccountServiceTest.java index 025f443..31579f5 100644 --- a/src/test/java/auth_service/modules/account/service/AccountServiceTest.java +++ b/src/test/java/auth_service/modules/account/service/AccountServiceTest.java @@ -44,6 +44,7 @@ class AccountServiceTest extends AbstractUnitTest { service = new AccountService(accountRepository, passwordEncoder, coreAccountService); lenient().when(accountRepository.findPasswordHashOfActive(USER_ID)).thenReturn(Optional.of("hash")); lenient().when(passwordEncoder.matches("right", "hash")).thenReturn(true); + lenient().when(accountRepository.flagDeleted(eq(USER_ID), any())).thenReturn(1); } @Test diff --git a/src/test/java/auth_service/modules/admin/api/AdminFlowTest.java b/src/test/java/auth_service/modules/admin/api/AdminFlowTest.java new file mode 100644 index 0000000..9c14146 --- /dev/null +++ b/src/test/java/auth_service/modules/admin/api/AdminFlowTest.java @@ -0,0 +1,234 @@ +package auth_service.modules.admin.api; + +import auth_service.configuration.security.ClientHeaderFilter; +import auth_service.shared.api.AuthCookies; +import auth_service.shared.test.AbstractIntegrationTest; +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.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Roles, "view as" and the admin users API against a real database. + */ +@AutoConfigureMockMvc +class AdminFlowTest extends AbstractIntegrationTest { + + private static final String PASSWORD = "correct horse battery"; + private static final String ADMIN = "admin-flow@example.com"; + private static final String OTHER_ADMIN = "admin-flow-2@example.com"; + private static final String PLAYER = "admin-flow-player@example.com"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbc; + + @Autowired + private PasswordEncoder passwordEncoder; + + private long playerId; + private long otherAdminId; + + private long insertUser(String email, String role) { + jdbc.update("DELETE FROM users WHERE email = ?", email); + return jdbc.queryForObject("INSERT INTO users (email, password, enabled, role, display_name) VALUES (?, ?, true, ?, ?) " + + "RETURNING id", Long.class, email, passwordEncoder.encode(PASSWORD), role, email.substring(0, 16)); + } + + @BeforeEach + void seed() { + insertUser(ADMIN, "PLATFORM_ADMIN"); + otherAdminId = insertUser(OTHER_ADMIN, "PLATFORM_ADMIN"); + playerId = insertUser(PLAYER, "USER"); + } + + private MockHttpServletResponse login(String email) throws Exception { + return mockMvc.perform(post("/api/auth/login").header(ClientHeaderFilter.CLIENT_HEADER, "web") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + email + "\",\"password\":\"" + PASSWORD + "\"}")) + .andReturn().getResponse(); + } + + private record Session(String access, String refresh) { + static Session of(MockHttpServletResponse response) { + return new Session(response.getCookie(AuthCookies.ACCESS_COOKIE).getValue(), + response.getCookie(AuthCookies.REFRESH_COOKIE).getValue()); + } + } + + private ResultActions listUsers(Session session) throws Exception { + return mockMvc.perform(get("/api/admin/users").param("query", "admin-flow") + .cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access()))); + } + + private MockHttpServletResponse viewAs(Session session, String role) throws Exception { + String body = role == null ? "{\"role\":null}" : "{\"role\":\"" + role + "\"}"; + return mockMvc.perform(post("/api/auth/view-as").header(ClientHeaderFilter.CLIENT_HEADER, "web") + .cookie(new Cookie(AuthCookies.REFRESH_COOKIE, session.refresh())) + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andReturn().getResponse(); + } + + private Session refresh(Session session) throws Exception { + MockHttpServletResponse response = mockMvc.perform(post("/api/auth/refresh") + .header(ClientHeaderFilter.CLIENT_HEADER, "web") + .cookie(new Cookie(AuthCookies.REFRESH_COOKIE, session.refresh()))) + .andReturn().getResponse(); + assertThat(response.getStatus()).isEqualTo(204); + return Session.of(response); + } + + private ResultActions block(Session session, long userId, boolean blocked) throws Exception { + return mockMvc.perform(put("/api/admin/users/" + userId + "/blocked").header(ClientHeaderFilter.CLIENT_HEADER, "web") + .cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access())) + .contentType(MediaType.APPLICATION_JSON).content("{\"blocked\":" + blocked + "}")); + } + + private long audits(String action, long target) { + return jdbc.queryForObject("SELECT COUNT(*) FROM admin_audit WHERE action = ? AND target_user_id = ?", + Long.class, action, target); + } + + @Test + void admin_ListsUsersWithSearchAndPaging() throws Exception { + Session admin = Session.of(login(ADMIN)); + + listUsers(admin).andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(3)) + .andExpect(jsonPath("$.content[0].email").value(ADMIN)) + .andExpect(jsonPath("$.content[0].role").value("PLATFORM_ADMIN")); + mockMvc.perform(get("/api/admin/users").param("query", "admin-flow").param("size", "2").param("page", "1") + .cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin.access()))) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.totalPages").value(2)); + } + + @Test + void userAndAnonymous_AreRefused() throws Exception { + listUsers(Session.of(login(PLAYER))).andExpect(status().isForbidden()); + mockMvc.perform(get("/api/admin/users")).andExpect(status().isUnauthorized()); + } + + @Test + void viewAs_LowersTheRightsSurvivesARefreshAndGoesBack() throws Exception { + Session admin = Session.of(login(ADMIN)); + + MockHttpServletResponse asUser = viewAs(admin, "USER"); + assertThat(asUser.getStatus()).isEqualTo(204); + Session user = Session.of(asUser); + listUsers(user).andExpect(status().isForbidden()); + mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, user.access()))) + .andExpect(jsonPath("$.role").value("PLATFORM_ADMIN")) + .andExpect(jsonPath("$.activeRole").value("USER")); + + Session refreshed = refresh(user); + listUsers(refreshed).andExpect(status().isForbidden()); + + Session back = Session.of(viewAs(refreshed, null)); + listUsers(back).andExpect(status().isOk()); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM admin_audit a JOIN users u ON u.id = a.actor_user_id " + + "WHERE u.email = ? AND a.action = 'VIEW_AS'", Long.class, ADMIN)).isEqualTo(2); + } + + @Test + void viewAs_AboveTheOwnRoleIsRefused() throws Exception { + MockHttpServletResponse response = viewAs(Session.of(login(PLAYER)), "PLATFORM_ADMIN"); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getContentAsString()).contains("ROLE_NOT_ALLOWED"); + } + + @Test + void demotedAdmin_LosesTheRightsAtTheNextRefresh() throws Exception { + Session admin = Session.of(login(ADMIN)); + jdbc.update("UPDATE users SET role = 'USER' WHERE email = ?", ADMIN); + + listUsers(refresh(admin)).andExpect(status().isForbidden()); + } + + @Test + void block_EndsTheSessionsAndTheLoginTellsWhy() throws Exception { + Session admin = Session.of(login(ADMIN)); + Session player = Session.of(login(PLAYER)); + + block(admin, playerId, true).andExpect(status().isNoContent()); + + assertThat(login(PLAYER).getContentAsString()).contains("ACCOUNT_BLOCKED"); + assertThat(login(PLAYER).getStatus()).isEqualTo(403); + assertThat(mockMvc.perform(post("/api/auth/refresh").header(ClientHeaderFilter.CLIENT_HEADER, "web") + .cookie(new Cookie(AuthCookies.REFRESH_COOKIE, player.refresh()))).andReturn().getResponse().getStatus()) + .isEqualTo(401); + assertThat(audits("BLOCK", playerId)).isEqualTo(1); + + block(admin, playerId, false).andExpect(status().isNoContent()); + assertThat(login(PLAYER).getStatus()).isEqualTo(200); + } + + @Test + void delete_FlagsTheUser() throws Exception { + Session admin = Session.of(login(ADMIN)); + + mockMvc.perform(delete("/api/admin/users/" + playerId).header(ClientHeaderFilter.CLIENT_HEADER, "web") + .cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin.access()))) + .andExpect(status().isNoContent()); + + assertThat(jdbc.queryForObject("SELECT deleted_at FROM users WHERE id = ?", Object.class, playerId)).isNotNull(); + assertThat(login(PLAYER).getStatus()).isEqualTo(401); + assertThat(audits("DELETE", playerId)).isEqualTo(1); + } + + @Test + void promotedUser_SeesTheNewRoleAfterLoggingInAgain() throws Exception { + Session before = Session.of(login(PLAYER)); + mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, before.access()))) + .andExpect(jsonPath("$.role").value("USER")); + + jdbc.update("UPDATE users SET role = 'PLATFORM_ADMIN' WHERE email = ?", PLAYER); + Session after = Session.of(login(PLAYER)); + + listUsers(after).andExpect(status().isOk()); + mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, after.access()))) + .andExpect(jsonPath("$.activeRole").value("PLATFORM_ADMIN")) + .andExpect(jsonPath("$.role").value("PLATFORM_ADMIN")); + } + + @Test + void viewAs_KeepsTheChoiceThroughADemotionAndBack() throws Exception { + Session auditor = Session.of(viewAs(Session.of(login(ADMIN)), "AUDITOR")); + jdbc.update("UPDATE users SET role = 'USER' WHERE email = ?", ADMIN); + Session demoted = refresh(auditor); + jdbc.update("UPDATE users SET role = 'PLATFORM_ADMIN' WHERE email = ?", ADMIN); + + Session promoted = refresh(demoted); + + mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, promoted.access()))) + .andExpect(jsonPath("$.activeRole").value("AUDITOR")); + listUsers(Session.of(viewAs(promoted, null))).andExpect(status().isOk()); + } + + @Test + void guards_RefuseAnotherAdminAndAnUnknownUser() throws Exception { + Session admin = Session.of(login(ADMIN)); + + block(admin, otherAdminId, true).andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("TARGET_IS_ADMIN")); + block(admin, 999_999_999L, true).andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/auth_service/modules/admin/repo/AdminUserRepositoryTest.java b/src/test/java/auth_service/modules/admin/repo/AdminUserRepositoryTest.java new file mode 100644 index 0000000..482a2a1 --- /dev/null +++ b/src/test/java/auth_service/modules/admin/repo/AdminUserRepositoryTest.java @@ -0,0 +1,86 @@ +package auth_service.modules.admin.repo; + +import auth_service.modules.admin.model.AdminUser; +import auth_service.shared.security.Role; +import auth_service.shared.test.AbstractPersistenceTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +@Import(AdminUserRepository.class) +class AdminUserRepositoryTest extends AbstractPersistenceTest { + + @Autowired + private AdminUserRepository repository; + + @Autowired + private JdbcTemplate jdbc; + + private long alice; + + private long insert(String email, String name) { + return jdbc.queryForObject("INSERT INTO users (email, password, enabled, display_name) VALUES (?, 'x', true, ?) " + + "RETURNING id", Long.class, email, name); + } + + @BeforeEach + void seed() { + jdbc.update("DELETE FROM users"); + alice = insert("alice@example.com", "Alice_1"); + insert("bob@example.com", "Bob"); + insert("carol@sample.org", "100%Carol"); + } + + @Test + void findPage_SearchesEmailAndNameInAnyCase() { + 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); + } + + @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); + } + + @Test + void findPage_Pages() { + 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(); + + assertThat(found.role()).isEqualTo(Role.AUDITOR); + assertThat(found.blocked()).isTrue(); + assertThat(found.steamLinked()).isTrue(); + assertThat(found.deletedAt()).isNotNull(); + } + + @Test + void setBlocked_RevokesTheLoginsAndUnblockKeepsThemRevoked() { + jdbc.update("INSERT INTO refresh_tokens (user_id, family_id, token_hash, created_at, expires_at) " + + "VALUES (?, gen_random_uuid(), 'h1', NOW(), NOW() + interval '1 day')", alice); + + repository.setBlocked(alice, true, Instant.now()); + + assertThat(jdbc.queryForObject("SELECT enabled FROM users WHERE id = ?", Boolean.class, alice)).isFalse(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM refresh_tokens WHERE user_id = ? AND revoked_at IS NULL", + Long.class, alice)).isZero(); + + repository.setBlocked(alice, false, Instant.now()); + assertThat(jdbc.queryForObject("SELECT enabled FROM users WHERE id = ?", Boolean.class, alice)).isTrue(); + } +} diff --git a/src/test/java/auth_service/modules/admin/service/AdminUserServiceTest.java b/src/test/java/auth_service/modules/admin/service/AdminUserServiceTest.java new file mode 100644 index 0000000..2036e38 --- /dev/null +++ b/src/test/java/auth_service/modules/admin/service/AdminUserServiceTest.java @@ -0,0 +1,131 @@ +package auth_service.modules.admin.service; + +import auth_service.modules.admin.exception.AdminActionRefusedException; +import auth_service.modules.admin.exception.TargetUserNotFoundException; +import auth_service.modules.admin.model.TargetState; +import auth_service.modules.admin.repo.AdminUserRepository; +import auth_service.shared.account.AccountDeleter; +import auth_service.shared.audit.AdminAuditLog; +import auth_service.shared.security.Role; +import auth_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class AdminUserServiceTest extends AbstractUnitTest { + + private static final long ADMIN = 1L; + private static final long TARGET = 2L; + + @Mock + private AdminUserRepository adminUserRepository; + + @Mock + private AdminAuditLog adminAuditLog; + + @Mock + private AccountDeleter accountDeleter; + + private AdminUserService service; + + @BeforeEach + void setUp() { + service = new AdminUserService(adminUserRepository, adminAuditLog, accountDeleter); + } + + private void target(Role role, boolean deleted) { + when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(role, deleted))); + } + + private String refusal(Runnable action) { + try { + action.run(); + } catch (AdminActionRefusedException e) { + return e.getCode(); + } + throw new AssertionError("not refused"); + } + + @Test + void block_BlocksAndWritesTheAudit() { + target(Role.USER, false); + + service.setBlocked(ADMIN, TARGET, true); + + verify(adminUserRepository).setBlocked(eq(TARGET), eq(true), any()); + verify(adminAuditLog).record(ADMIN, "BLOCK", TARGET, null); + } + + @Test + void unblock_WritesItsOwnAuditAction() { + target(Role.AUDITOR, false); + + service.setBlocked(ADMIN, TARGET, false); + + verify(adminAuditLog).record(ADMIN, "UNBLOCK", TARGET, null); + } + + @Test + void guards_RefuseTheOwnAccountAnotherAdminAndADeletedUser() { + assertThat(refusal(() -> service.setBlocked(ADMIN, ADMIN, true))).isEqualTo("SELF_ACTION"); + assertThat(refusal(() -> service.delete(ADMIN, ADMIN))).isEqualTo("SELF_ACTION"); + + target(Role.PLATFORM_ADMIN, false); + assertThat(refusal(() -> service.setBlocked(ADMIN, TARGET, true))).isEqualTo("TARGET_IS_ADMIN"); + assertThat(refusal(() -> service.delete(ADMIN, TARGET))).isEqualTo("TARGET_IS_ADMIN"); + + target(Role.USER, true); + assertThat(refusal(() -> service.setBlocked(ADMIN, TARGET, true))).isEqualTo("ALREADY_DELETED"); + + verify(adminUserRepository, never()).setBlocked(anyLong(), anyBoolean(), any()); + verifyNoInteractions(accountDeleter, adminAuditLog); + } + + @Test + void unknownUser_IsNotFound() { + when(adminUserRepository.findState(TARGET)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.delete(ADMIN, TARGET)).isInstanceOf(TargetUserNotFoundException.class); + } + + @Test + void delete_GoesThroughTheAccountFlagAndIsAudited() { + target(Role.USER, false); + when(accountDeleter.deleteByAdmin(TARGET)).thenReturn(true); + + service.delete(ADMIN, TARGET); + + verify(adminAuditLog).record(ADMIN, "DELETE", TARGET, null); + } + + @Test + void delete_OfAnAlreadyFlaggedUserWritesNoAudit() { + target(Role.USER, true); + when(accountDeleter.deleteByAdmin(TARGET)).thenReturn(false); + + service.delete(ADMIN, TARGET); + + verifyNoInteractions(adminAuditLog); + } + + @Test + void findUsers_ClampsTheSizeAndCountsThePages() { + when(adminUserRepository.count("bob")).thenReturn(250L); + + assertThat(service.findUsers(" bob ", -1, 500).totalPages()).isEqualTo(3); + verify(adminUserRepository).findPage("bob", 0, 100); + } +} diff --git a/src/test/java/auth_service/modules/login/service/LoginServiceTest.java b/src/test/java/auth_service/modules/login/service/LoginServiceTest.java index 43ea3f0..a284084 100644 --- a/src/test/java/auth_service/modules/login/service/LoginServiceTest.java +++ b/src/test/java/auth_service/modules/login/service/LoginServiceTest.java @@ -1,6 +1,8 @@ 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.InvalidCredentialsException; import auth_service.modules.login.exception.TooManyLoginAttemptsException; import auth_service.modules.login.model.Session; @@ -75,7 +77,7 @@ class LoginServiceTest extends AbstractUnitTest { .build(); when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser)); when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true); - when(tokenGenerator.generateToken("815", "test@example.com", null)).thenReturn("mock-token"); + when(tokenGenerator.generateToken("815", "test@example.com", null, Role.USER)).thenReturn("mock-token"); when(refreshTokenService.startFamily(815L)).thenReturn("mock-refresh"); // Act @@ -140,7 +142,7 @@ class LoginServiceTest extends AbstractUnitTest { } @Test - void login_DisabledUser() { + void login_BlockedUserWithTheRightPassword_IsToldItIsBlocked() { // Arrange var dbUser = User.builder() .id(815L) @@ -152,8 +154,30 @@ class LoginServiceTest extends AbstractUnitTest { when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true); // Act & Assert + assertThrows(AccountBlockedException.class, () -> loginService.login(user, IP, null)); + verify(tokenGenerator, never()).generateToken(any(), any(), any(), any()); + verify(loginAttemptService).recordSuccess("test@example.com", IP); + } + + @Test + void login_BlockedUserWithAWrongPassword_LooksLikeAnyWrongPassword() { + var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(false).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)); - verify(tokenGenerator, never()).generateToken(any(), any(), any()); + } + + @Test + void login_TheTokenCarriesTheStoredRole() { + var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true) + .role(Role.PLATFORM_ADMIN).build(); + when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser)); + when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true); + + loginService.login(user, IP, null); + + verify(tokenGenerator).generateToken("815", "test@example.com", null, Role.PLATFORM_ADMIN); } @Test diff --git a/src/test/java/auth_service/modules/user/service/UserServiceTest.java b/src/test/java/auth_service/modules/user/service/UserServiceTest.java index 736dc53..45bdfaa 100644 --- a/src/test/java/auth_service/modules/user/service/UserServiceTest.java +++ b/src/test/java/auth_service/modules/user/service/UserServiceTest.java @@ -1,5 +1,6 @@ package auth_service.modules.user.service; +import auth_service.shared.security.Role; import auth_service.modules.user.exception.DisplayNameAlreadyInUseException; import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.model.Appearance; @@ -150,9 +151,9 @@ class UserServiceTest extends AbstractUnitTest { @Test void unlinkSteamId_ClearsItAndReturnsATokenWithoutIt() { - when(tokenGenerator.generateToken("1", "test@example.com", null)).thenReturn("token-without-steam"); + when(tokenGenerator.generateToken("1", "test@example.com", null, Role.USER)).thenReturn("token-without-steam"); - assertEquals("token-without-steam", userService.unlinkSteamId(1L, "test@example.com")); + assertEquals("token-without-steam", userService.unlinkSteamId(1L, "test@example.com", Role.USER)); verify(userRepository).clearSteamId(1L); } diff --git a/src/test/java/auth_service/shared/security/RoleTest.java b/src/test/java/auth_service/shared/security/RoleTest.java new file mode 100644 index 0000000..aca425b --- /dev/null +++ b/src/test/java/auth_service/shared/security/RoleTest.java @@ -0,0 +1,26 @@ +package auth_service.shared.security; + +import auth_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RoleTest extends AbstractUnitTest { + + @Test + void theLadderGoesUserAuditorAdmin() { + assertTrue(Role.PLATFORM_ADMIN.atLeast(Role.AUDITOR)); + assertTrue(Role.AUDITOR.atLeast(Role.AUDITOR)); + assertFalse(Role.USER.atLeast(Role.AUDITOR)); + assertEquals(Role.USER, Role.lower(Role.PLATFORM_ADMIN, Role.USER)); + } + + @Test + void parse_UnknownOrMissingIsUser() { + assertEquals(Role.AUDITOR, Role.parse("AUDITOR")); + assertEquals(Role.USER, Role.parse(null)); + assertEquals(Role.USER, Role.parse("ROOT")); + } +}