feat(admin): add roles, view as and the admin users API
Build, Test & Push Auth Service / quality-check (push) Successful in 39s
Build, Test & Push Auth Service / docker (push) Successful in 1m20s

users.role (USER < AUDITOR < PLATFORM_ADMIN) goes into the access token as the active role. POST /api/auth/view-as lowers the own login and is kept per refresh token, never above the stored role. /api/admin/users lists, blocks and deletes users; every action is written to admin_audit. A blocked login answers 403 ACCOUNT_BLOCKED, only after the right password.
This commit is contained in:
lobo committed 2026-09-27 12:18:10 +02:00
1 parent 7dc2fb613e
commit 2ba0d99148
55 files changed
+1184 -43

No files matched your search

+19
View File
@@ -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
@@ -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<SimpleGrantedAuthority> authorities(Role role) {
return Arrays.stream(Role.values()).filter(role::atLeast)
.map(each -> new SimpleGrantedAuthority("ROLE_" + each.name())).toList();
}
private static Optional<String> bearer(HttpServletRequest request) {
String header = request.getHeader("Authorization");
return header != null && header.startsWith(BEARER) ? Optional.of(header.substring(BEARER.length())) : Optional.empty();
@@ -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();
}
@@ -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()
)
@@ -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;
}
}
@@ -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<ErrorResponse> clientError(AdminActionRefusedException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT).contentType(MediaType.APPLICATION_JSON)
.body(new ErrorResponse(ex.getCode(), ex.getMessage()));
}
@ExceptionHandler(TargetUserNotFoundException.class)
public ResponseEntity<ErrorResponse> clientError(TargetUserNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).contentType(MediaType.APPLICATION_JSON)
.body(new ErrorResponse("USER_NOT_FOUND", ex.getMessage()));
}
}
@@ -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<AdminUserPageResponse> 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<Void> 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<Void> 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());
}
}
@@ -0,0 +1,6 @@
package auth_service.modules.admin.api.dto.request;
import jakarta.validation.constraints.NotNull;
public record BlockRequest(@NotNull Boolean blocked) {
}
@@ -0,0 +1,7 @@
package auth_service.modules.admin.api.dto.response;
import java.util.List;
public record AdminUserPageResponse(List<AdminUserResponse> content, int page, int size, long totalElements,
int totalPages) {
}
@@ -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) {
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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) {
}
@@ -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<AdminUser> content, int page, int size, long totalElements, int totalPages) {
}
@@ -0,0 +1,6 @@
package auth_service.modules.admin.model;
import auth_service.shared.security.Role;
public record TargetState(Role role, boolean deleted) {
}
@@ -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);
}
}
@@ -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<AdminUser> 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<AdminUser> 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<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),
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);
}
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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> 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<ErrorResponse> 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> session = refreshTokenService.switchRole(refreshToken, request.role());
if (session.isEmpty()) {
return sessionExpired();
}
return ResponseEntity.noContent().headers(cookies(sessionCookies(session.get()))).build();
}
@@ -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<ErrorResponse> clientError(AccountBlockedException ex) {
return response(new ErrorResponse("ACCOUNT_BLOCKED", ex.getMessage()), HttpStatus.FORBIDDEN);
}
@ExceptionHandler(RoleNotAllowedException.class)
public ResponseEntity<ErrorResponse> clientError(RoleNotAllowedException ex) {
return response(new ErrorResponse("ROLE_NOT_ALLOWED", ex.getMessage()), HttpStatus.FORBIDDEN);
}
@ExceptionHandler(InvalidCredentialsException.class)
public ResponseEntity<ErrorResponse> clientError(InvalidCredentialsException ex) {
var body = new ErrorResponse("INVALID_CREDENTIALS", ex.getMessage());
@@ -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) {
}
@@ -0,0 +1,7 @@
package auth_service.modules.login.exception;
public class AccountBlockedException extends RuntimeException {
public AccountBlockedException() {
super("This account is blocked");
}
}
@@ -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");
}
}
@@ -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) {
}
@@ -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;
@@ -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.
@@ -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);
}
@@ -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;
}
@@ -42,4 +42,7 @@ public class RefreshTokenEntity {
@Column(name = "revoked_at")
private Instant revokedAt;
@Column(name = "active_role")
private String activeRole;
}
@@ -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()));
}
}
@@ -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()));
}
}
@@ -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<Session> 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<Session> switchRole(String rawToken, Role role) {
return rotate(rawToken, true, role);
}
private Optional<Session> 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;
}
@@ -44,7 +44,7 @@ public class SteamLinkController {
*/
@DeleteMapping(value = "/api/users/steam-link", version = "1.0")
public ResponseEntity<Void> 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();
@@ -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")
@@ -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) {
}
@@ -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"))
@@ -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;
}
@@ -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;
@@ -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();
}
@@ -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
@@ -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);
}
@@ -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);
}
@@ -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;
}
}
}
@@ -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);
}
@@ -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;
@@ -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);
@@ -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());
}
}
@@ -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
@@ -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());
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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
@@ -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);
}
@@ -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"));
}
}