feat(security): check role markers on services and read the role claim

Every service method a controller calls carries @RequiresRole, @Public or @Internal; a Spring Security method interceptor checks it while a request is served. Core now grants the active role from the token and every lower one. ArchUnit fails the build for an unmarked method, two markers, a controller calling an @Async method, or a service reaching a method that needs more. The background refreshes moved from LibraryController into the services, because an @Async method is never checked.
This commit is contained in:
lobo committed 2026-09-28 08:50:10 +02:00
1 parent 351cda411d
commit 2596df609e
47 files changed
+1114 -21

No files matched your search

+11 -1
View File
@@ -70,7 +70,9 @@ To Be Played is split into three repositories:
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's
JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay
until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. The token comes from Auth's until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. The token comes from Auth's
`tbp_access` cookie or a `Bearer` header, whichever verifies first. Core reads `userId` and `steamId` from the claims. `tbp_access` cookie or a `Bearer` header, whichever verifies first. Core reads `userId`, `steamId` and the active
`role` (missing or unknown = USER) from the claims and grants that role and every lower one
(`USER < AUDITOR < PLATFORM_ADMIN`).
`POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the `POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the
frontend origin. frontend origin.
A token with a `typ` claim (for example a profile ticket) is rejected on normal endpoints; the profile ticket is A token with a `typ` claim (for example a profile ticket) is rejected on normal endpoints; the profile ticket is
@@ -79,6 +81,14 @@ accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` he
as `Bearer`), never a login, also not in the dev profile. Traefik exposes the path, so the signed token is its as `Bearer`), never a login, also not in the dev profile. Traefik exposes the path, so the signed token is its
only guard. only guard.
Every service method a controller calls carries one role marker, on the method or on its class (the method wins):
`@RequiresRole(Role.X)`, `@Public` (no login, e.g. landing stats; the public profile checks its ticket) or
`@Internal` (only Auth's service token). A Spring Security method interceptor checks it while a request is served;
scheduled jobs and `@Async` work run without a user and are not checked. A denied call answers `401` without a
login, else `403`. The URL rules in `SecurityConfig` stay as the outer guard. ArchUnit fails the build for an
unmarked method, two markers, a controller calling an `@Async` method (the check would never run), and a service
method calling another service's method that needs more.
## API ## API
| Method | Path | Auth | | Method | Path | Auth |
@@ -1,9 +1,12 @@
package core_service.configuration.security; package core_service.configuration.security;
import core_service.shared.security.Role;
import jakarta.servlet.http.Cookie; import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import java.util.Arrays; import java.util.Arrays;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Stream; import java.util.stream.Stream;
@@ -19,6 +22,14 @@ final class AccessTokens {
private AccessTokens() { private AccessTokens() {
} }
/**
* The active role and every role below it, so a check for USER also lets an admin in.
*/
static List<SimpleGrantedAuthority> authorities(Role role) {
return Arrays.stream(Role.values()).filter(role::atLeast)
.map(each -> new SimpleGrantedAuthority("ROLE_" + each.name())).toList();
}
static Stream<String> candidates(HttpServletRequest request) { static Stream<String> candidates(HttpServletRequest request) {
return Stream.of(cookie(request), bearer(request)).flatMap(Optional::stream); return Stream.of(cookie(request), bearer(request)).flatMap(Optional::stream);
} }
@@ -16,7 +16,6 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Collections;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -49,7 +48,7 @@ public class DevJwtAuthenticationFilter extends OncePerRequestFilter {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
principal, principal,
null, null,
Collections.emptyList() AccessTokens.authorities(principal.role())
); );
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken); SecurityContextHolder.getContext().setAuthentication(authToken);
@@ -15,7 +15,6 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Collections;
import java.util.Optional; import java.util.Optional;
@Component("jwtAuthenticationFilter") @Component("jwtAuthenticationFilter")
@@ -40,7 +39,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
principal.get(), principal.get(),
null, null,
Collections.emptyList() AccessTokens.authorities(principal.get().role())
); );
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken); SecurityContextHolder.getContext().setAuthentication(authToken);
@@ -2,6 +2,7 @@ package core_service.configuration.security;
import core_service.shared.security.ProfileTicket; import core_service.shared.security.ProfileTicket;
import core_service.shared.security.ProfileTicketVerifier; import core_service.shared.security.ProfileTicketVerifier;
import core_service.shared.security.Role;
import core_service.shared.security.UserPrincipal; import core_service.shared.security.UserPrincipal;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.JwtParser;
@@ -23,6 +24,7 @@ public class JwtService implements ProfileTicketVerifier {
private static final String TYPE_CLAIM = "typ"; private static final String TYPE_CLAIM = "typ";
private static final String PROFILE_TICKET_TYPE = "public-profile"; private static final String PROFILE_TICKET_TYPE = "public-profile";
private static final String SERVICE_TOKEN_TYPE = "service"; private static final String SERVICE_TOKEN_TYPE = "service";
private static final String ROLE_CLAIM = "role";
private final JwtParser parser; private final JwtParser parser;
@@ -45,7 +47,8 @@ public class JwtService implements ProfileTicketVerifier {
if (claims.get(TYPE_CLAIM) != null || claims.getSubject() == null || userId == null) { if (claims.get(TYPE_CLAIM) != null || claims.getSubject() == null || userId == null) {
return Optional.empty(); 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) instanceof String role ? role : null)));
} catch (Exception e) { } catch (Exception e) {
return Optional.empty(); return Optional.empty();
} }
@@ -0,0 +1,34 @@
package core_service.configuration.security;
import core_service.shared.security.Internal;
import core_service.shared.security.RequiresRole;
import org.springframework.aop.Advisor;
import org.springframework.aop.Pointcut;
import org.springframework.aop.support.Pointcuts;
import org.springframework.aop.support.annotation.AnnotationMatchingPointcut;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Role;
import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
/**
* Runs {@link RoleAuthorizationManager} before every method with a role marker, or of a class with one. A method
* that is only {@code @Public} needs no check.
*/
@Configuration
@EnableMethodSecurity(prePostEnabled = false)
public class MethodSecurityConfig {
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
static Advisor roleAuthorization() {
Pointcut marked = Pointcuts.union(
Pointcuts.union(new AnnotationMatchingPointcut(RequiresRole.class, true),
new AnnotationMatchingPointcut(null, RequiresRole.class, true)),
Pointcuts.union(new AnnotationMatchingPointcut(Internal.class, true),
new AnnotationMatchingPointcut(null, Internal.class, true)));
return new AuthorizationManagerBeforeMethodInterceptor(marked, new RoleAuthorizationManager());
}
}
@@ -0,0 +1,57 @@
package core_service.configuration.security;
import core_service.shared.security.Internal;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import org.aopalliance.intercept.MethodInvocation;
import org.springframework.aop.support.AopUtils;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.authorization.AuthorizationResult;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.web.context.request.RequestContextHolder;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Method;
import java.util.function.Supplier;
class RoleAuthorizationManager implements AuthorizationManager<MethodInvocation> {
private static final AuthorizationDecision GRANTED = new AuthorizationDecision(true);
private static final AuthorizationDecision DENIED = new AuthorizationDecision(false);
@Override
public AuthorizationResult authorize(Supplier<? extends Authentication> authentication, MethodInvocation invocation) {
if (RequestContextHolder.getRequestAttributes() == null) {
return GRANTED;
}
Class<?> targetClass = invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis())
: invocation.getMethod().getDeclaringClass();
Method method = AopUtils.getMostSpecificMethod(invocation.getMethod(), targetClass);
AnnotatedElement marked = isMarked(method) ? method : targetClass;
if (AnnotatedElementUtils.hasAnnotation(marked, Public.class)) {
return GRANTED;
}
if (AnnotatedElementUtils.hasAnnotation(marked, Internal.class)) {
return hasAuthority(authentication.get(), ServiceTokenFilter.SERVICE_AUTHORITY) ? GRANTED : DENIED;
}
RequiresRole required = AnnotatedElementUtils.findMergedAnnotation(marked, RequiresRole.class);
if (required == null) {
return DENIED;
}
return hasAuthority(authentication.get(), "ROLE_" + required.value().name()) ? GRANTED : DENIED;
}
private static boolean isMarked(Method method) {
return AnnotatedElementUtils.hasAnnotation(method, RequiresRole.class)
|| AnnotatedElementUtils.hasAnnotation(method, Public.class)
|| AnnotatedElementUtils.hasAnnotation(method, Internal.class);
}
private static boolean hasAuthority(Authentication authentication, String authority) {
return authentication != null && authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).anyMatch(authority::equals);
}
}
@@ -2,6 +2,7 @@ package core_service.modules.account.service;
import core_service.modules.account.domain.CleanupResult; import core_service.modules.account.domain.CleanupResult;
import core_service.modules.account.repo.AccountCleanupRepository; import core_service.modules.account.repo.AccountCleanupRepository;
import core_service.shared.security.Internal;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.CacheEvict;
@@ -16,6 +17,7 @@ import java.util.Map;
* Auth decides who is deleted and when; Core only checks that a purged user is tombstoned. * Auth decides who is deleted and when; Core only checks that a purged user is tombstoned.
*/ */
@Service @Service
@Internal
@Slf4j @Slf4j
public class AccountCleanupService { public class AccountCleanupService {
@@ -23,8 +23,6 @@ import core_service.modules.library.service.GameDetailService;
import core_service.modules.library.service.ImageRepairService; import core_service.modules.library.service.ImageRepairService;
import core_service.modules.library.service.LibraryQueryService; import core_service.modules.library.service.LibraryQueryService;
import core_service.modules.library.service.LibrarySyncService; import core_service.modules.library.service.LibrarySyncService;
import core_service.modules.library.service.MoodService;
import core_service.modules.library.service.SpeedrunService;
import core_service.modules.library.service.SteamProfileService; import core_service.modules.library.service.SteamProfileService;
import core_service.modules.library.service.SyncCooldownException; import core_service.modules.library.service.SyncCooldownException;
import core_service.modules.library.service.UserStatsService; import core_service.modules.library.service.UserStatsService;
@@ -70,8 +68,6 @@ public class LibraryController {
private final GameDetailService gameDetailService; private final GameDetailService gameDetailService;
private final BacklogService backlogService; private final BacklogService backlogService;
private final SteamProfileService steamProfileService; private final SteamProfileService steamProfileService;
private final MoodService moodService;
private final SpeedrunService speedrunService;
private final ImageRepairService imageRepairService; private final ImageRepairService imageRepairService;
private final UserStatsService userStatsService; private final UserStatsService userStatsService;
@@ -241,11 +237,6 @@ public class LibraryController {
if (principal.steamId() != null) { if (principal.steamId() != null) {
syncService.syncIfStale(userId, String.valueOf(principal.steamId())); syncService.syncIfStale(userId, String.valueOf(principal.steamId()));
} }
if (mood == Mood.SPEEDRUN) {
speedrunService.refreshBacklogInBackground(userId);
} else if (mood != null) {
moodService.refreshBacklogInBackground(userId);
}
return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, safeCount)); return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, safeCount));
} }
@@ -257,10 +248,7 @@ public class LibraryController {
@RequestParam(required = false) Set<StatGroup> groups) { @RequestParam(required = false) Set<StatGroup> groups) {
Long userId = principal.getIdAsLong(); Long userId = principal.getIdAsLong();
Set<StatGroup> asked = groups == null || groups.isEmpty() ? EnumSet.allOf(StatGroup.class) : groups; Set<StatGroup> asked = groups == null || groups.isEmpty() ? EnumSet.allOf(StatGroup.class) : groups;
if (asked.contains(StatGroup.TASTE)) { return ResponseEntity.ok(userStatsService.getOwnStats(userId, asked));
moodService.refreshListedInBackground(userId);
}
return ResponseEntity.ok(userStatsService.getStats(userId, asked));
} }
@GetMapping("/sync-status") @GetMapping("/sync-status")
@@ -7,6 +7,8 @@ import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -15,6 +17,7 @@ import java.time.Instant;
import java.util.Optional; import java.util.Optional;
@Service @Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor @RequiredArgsConstructor
public class BacklogService { public class BacklogService {
@@ -25,6 +25,8 @@ import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository; import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameAchievementRepository; import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -39,6 +41,7 @@ import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class GameDetailService { public class GameDetailService {
@@ -13,6 +13,8 @@ import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.steam_adapter.service.SteamApiService; import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.modules.steam_adapter.service.SteamCdnService; import core_service.modules.steam_adapter.service.SteamCdnService;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
@@ -35,6 +37,7 @@ import java.util.stream.Collectors;
* the job checks the picture itself first, so a false report (ad blocker, bad network) changes nothing. * the job checks the picture itself first, so a false report (ad blocker, bad network) changes nothing.
*/ */
@Service @Service
@RequiresRole(Role.USER)
@Slf4j @Slf4j
public class ImageRepairService { public class ImageRepairService {
@@ -28,6 +28,9 @@ import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserSteamSyncRepository; import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.modules.library.repo.WishlistGameRepository; import core_service.modules.library.repo.WishlistGameRepository;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@@ -50,6 +53,7 @@ import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@Service @Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor @RequiredArgsConstructor
@Transactional(readOnly = true) @Transactional(readOnly = true)
public class LibraryQueryService { public class LibraryQueryService {
@@ -68,6 +72,8 @@ public class LibraryQueryService {
private final GameAchievementSchemaMetaRepository schemaMetaRepository; private final GameAchievementSchemaMetaRepository schemaMetaRepository;
private final UserGameAchievementMetaRepository userAchievementMetaRepository; private final UserGameAchievementMetaRepository userAchievementMetaRepository;
private final UserStatsService userStatsService; private final UserStatsService userStatsService;
private final MoodService moodService;
private final SpeedrunService speedrunService;
@Value("${app.library.zone:Europe/Berlin}") @Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone; private ZoneId zone;
@@ -86,7 +92,15 @@ public class LibraryQueryService {
return new PageImpl<>(games, page.getPageable(), page.getTotalElements()); return new PageImpl<>(games, page.getPageable(), page.getTotalElements());
} }
/**
* With a mood, also refreshes the backlog's moods or speedrun links in the background for the next pick.
*/
public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, Mood mood, List<Long> excludeAppids, int count) { public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, Mood mood, List<Long> excludeAppids, int count) {
if (mood == Mood.SPEEDRUN) {
speedrunService.refreshBacklogInBackground(userId);
} else if (mood != null) {
moodService.refreshBacklogInBackground(userId);
}
return new PickerDto(toDtos(userGameRepository.findPickerGames(userId, maxMinutes, mood, excludeAppids, count))); return new PickerDto(toDtos(userGameRepository.findPickerGames(userId, maxMinutes, mood, excludeAppids, count)));
} }
@@ -197,6 +211,7 @@ public class LibraryQueryService {
/** /**
* @param profileStats the stat group names of the profile ticket * @param profileStats the stat group names of the profile ticket
*/ */
@Public
public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats) { public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats) {
Map<GameStatus, Long> byStatus = userGameRepository.countEveryStatus(userId); Map<GameStatus, Long> byStatus = userGameRepository.countEveryStatus(userId);
List<UserGame> playing = userGameRepository.findListedByStatusIn(userId, List<UserGame> playing = userGameRepository.findListedByStatusIn(userId,
@@ -3,6 +3,8 @@ package core_service.modules.library.service;
import core_service.modules.library.domain.UserSteamSync; import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.BacklogSnapshotRepository; import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.UserSteamSyncRepository; import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -16,6 +18,7 @@ import java.time.ZoneId;
import java.util.List; import java.util.List;
@Service @Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class LibrarySyncService { public class LibrarySyncService {
@@ -11,6 +11,8 @@ import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -32,6 +34,7 @@ import java.util.stream.Stream;
* to the Steam sync. * to the Steam sync.
*/ */
@Service @Service
@RequiresRole(Role.USER)
@Slf4j @Slf4j
public class ManualGameService { public class ManualGameService {
@@ -2,10 +2,13 @@ package core_service.modules.library.service;
import core_service.modules.library.api.dto.SteamProfileDto; import core_service.modules.library.api.dto.SteamProfileDto;
import core_service.modules.steam_adapter.service.SteamApiService; import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor @RequiredArgsConstructor
public class SteamProfileService { public class SteamProfileService {
@@ -8,6 +8,8 @@ import core_service.modules.library.domain.UserStats;
import core_service.modules.library.repo.BacklogSnapshotRepository; import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserStatsRepository; import core_service.modules.library.repo.UserStatsRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -33,10 +35,23 @@ public class UserStatsService {
private final UserGameRepository userGameRepository; private final UserGameRepository userGameRepository;
private final UserStatsRepository userStatsRepository; private final UserStatsRepository userStatsRepository;
private final BacklogSnapshotRepository backlogSnapshotRepository; private final BacklogSnapshotRepository backlogSnapshotRepository;
private final MoodService moodService;
@Value("${app.library.zone:Europe/Berlin}") @Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone; private ZoneId zone;
/**
* The stats page of the user: {@link #getStats}, and for TASTE also a refresh of the listed games' moods in the
* background, not done for a profile visitor.
*/
@RequiresRole(Role.USER)
public UserStatsDto getOwnStats(long userId, Set<StatGroup> groups) {
if (groups.contains(StatGroup.TASTE)) {
moodService.refreshListedInBackground(userId);
}
return getStats(userId, groups);
}
/** /**
* Runs only the queries of the asked groups; the others stay null. * Runs only the queries of the asked groups; the others stay null.
*/ */
@@ -2,6 +2,7 @@ package core_service.modules.stats.service;
import core_service.modules.stats.api.dto.PublicStatsDto; import core_service.modules.stats.api.dto.PublicStatsDto;
import core_service.modules.stats.repo.StatsRepository; import core_service.modules.stats.repo.StatsRepository;
import core_service.shared.security.Public;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -10,6 +11,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
@Service @Service
@Public
@RequiredArgsConstructor @RequiredArgsConstructor
public class PublicStatsService { public class PublicStatsService {
@@ -8,6 +8,8 @@ import core_service.modules.steam_adapter.api.dto.response.SteamProfileAndGamesR
import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse; import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamStoreItemsResponse; import core_service.modules.steam_adapter.api.dto.response.SteamStoreItemsResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamWishlistResponse; import core_service.modules.steam_adapter.api.dto.response.SteamWishlistResponse;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.HttpClientErrorException;
import io.github.resilience4j.ratelimiter.annotation.RateLimiter; import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import io.github.resilience4j.retry.annotation.Retry; import io.github.resilience4j.retry.annotation.Retry;
@@ -27,6 +29,7 @@ import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class SteamApiService { public class SteamApiService {
@@ -0,0 +1,16 @@
package core_service.shared.security;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Callable only with Auth's service token (the /internal calls). See {@link RequiresRole}.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface Internal {
}
@@ -0,0 +1,16 @@
package core_service.shared.security;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Callable without a login, e.g. the landing stats. See {@link RequiresRole}.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface Public {
}
@@ -0,0 +1,20 @@
package core_service.shared.security;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* The lowest active role that may call this service method, or every method of this class. A method marker
* replaces the class marker. Every service method a controller calls carries one of {@link RequiresRole},
* {@link Public} or {@link Internal} (ArchitectureTest). Checked only while an HTTP request is served: scheduled
* jobs and {@code @Async} work run without a user.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface RequiresRole {
Role value();
}
@@ -0,0 +1,26 @@
package core_service.shared.security;
/**
* A ladder: each role has every right of the roles before it. Auth owns the roles; the same enum lives there.
*/
public enum Role {
USER, AUDITOR, PLATFORM_ADMIN;
public boolean atLeast(Role other) {
return compareTo(other) >= 0;
}
/**
* 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;
}
}
}
@@ -2,12 +2,20 @@ package core_service.shared.security;
import java.security.Principal; import java.security.Principal;
/**
* @param role the active role of this login ("view as"), set by Auth
*/
public record UserPrincipal( public record UserPrincipal(
String id, String id,
String email, String email,
Long steamId Long steamId,
Role role
) implements Principal { ) implements Principal {
public UserPrincipal(String id, String email, Long steamId) {
this(id, email, steamId, Role.USER);
}
@Override @Override
public String getName() { public String getName() {
return id; return id;
@@ -100,4 +100,17 @@ class ArchitectureTest extends AbstractArchitectureTest {
.orShould().beAnnotatedWith(org.springframework.web.bind.annotation.ControllerAdvice.class) .orShould().beAnnotatedWith(org.springframework.web.bind.annotation.ControllerAdvice.class)
.orShould().beAnnotatedWith(org.springframework.web.bind.annotation.RestControllerAdvice.class) .orShould().beAnnotatedWith(org.springframework.web.bind.annotation.RestControllerAdvice.class)
.because("Classes in 'api' package named *Controller should be Spring REST controllers."); .because("Classes in 'api' package named *Controller should be Spring REST controllers.");
// ===================================================================================
// 5. ROLE MARKERS (see RoleAnnotationRules)
// ===================================================================================
@ArchTest
static final ArchRule controllers_call_only_marked_service_methods = RoleAnnotationRules.controllers_call_only_marked_service_methods;
@ArchTest
static final ArchRule one_role_marker_per_element = RoleAnnotationRules.one_role_marker_per_element;
@ArchTest
static final ArchRule services_never_call_a_method_that_needs_more = RoleAnnotationRules.services_never_call_a_method_that_needs_more;
} }
@@ -0,0 +1,198 @@
package core_service.architecture;
import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaCodeUnit;
import com.tngtech.archunit.core.domain.JavaCodeUnitAccess;
import com.tngtech.archunit.core.domain.JavaMethod;
import com.tngtech.archunit.core.domain.JavaModifier;
import com.tngtech.archunit.core.domain.properties.HasAnnotations;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
import core_service.shared.security.Internal;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import org.springframework.scheduling.annotation.Async;
import java.lang.annotation.Annotation;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
/**
* The rules for {@link RequiresRole}, {@link Public} and {@link Internal}. Auth has the same rules without
* {@code Internal}.
*/
final class RoleAnnotationRules {
private static final List<Class<? extends Annotation>> MARKERS = List.of(RequiresRole.class, Public.class,
Internal.class);
private static final String APP_PACKAGE = "core_service.";
private RoleAnnotationRules() {
}
/**
* What a marked method needs from its caller. {@code role} is set only for {@link RequiresRole}.
*/
private record Need(Class<? extends Annotation> marker, Role role) {
boolean isMetBy(Need caller) {
if (marker == Public.class) {
return true;
}
if (marker == Internal.class) {
return caller.marker == Internal.class;
}
return caller.marker == RequiresRole.class && caller.role.atLeast(role);
}
@Override
public String toString() {
return marker == RequiresRole.class ? "@RequiresRole(" + role + ")" : "@" + marker.getSimpleName();
}
}
private static long markerCount(HasAnnotations<?> element) {
return MARKERS.stream().filter(element::isAnnotatedWith).count();
}
private static Optional<Need> need(HasAnnotations<?> element) {
for (Class<? extends Annotation> marker : MARKERS) {
if (element.isAnnotatedWith(marker)) {
Role role = marker == RequiresRole.class
? element.getAnnotationOfType(RequiresRole.class).value() : null;
return Optional.of(new Need(marker, role));
}
}
return Optional.empty();
}
private static Optional<Need> effectiveNeed(JavaMethod method) {
Optional<Need> own = need(method);
return own.isPresent() ? own : need(method.getOwner());
}
private record MethodAccess(JavaCodeUnitAccess<?> access, JavaMethod target) {
@Override
public String toString() {
return target.getOwner().getSimpleName() + "." + target.getName() + " called in "
+ access.getSourceCodeLocation();
}
}
/**
* Calls and method references ({@code service::method}) to non-static methods: a proxy never sees a static call,
* so a marker there would promise a check that does not run.
*/
private static List<MethodAccess> methodAccesses(JavaCodeUnit from) {
return Stream.<JavaCodeUnitAccess<?>>concat(from.getMethodCallsFromSelf().stream(),
from.getMethodReferencesFromSelf().stream())
.flatMap(access -> access.getTarget().resolveMember().stream()
.filter(JavaMethod.class::isInstance).map(JavaMethod.class::cast)
.filter(method -> !method.getModifiers().contains(JavaModifier.STATIC))
.map(method -> new MethodAccess(access, method)))
.toList();
}
private static boolean isService(JavaClass javaClass) {
return javaClass.getSimpleName().endsWith("Service") && javaClass.getPackageName().contains(".service");
}
private static boolean isAsync(JavaMethod method) {
return method.isAnnotatedWith(Async.class) || method.getOwner().isAnnotatedWith(Async.class);
}
private static final DescribedPredicate<JavaClass> SERVICE_CLASSES =
DescribedPredicate.describe("are services", RoleAnnotationRules::isService);
/**
* Every service method a controller calls is marked, and is not {@code @Async}: there the check would never run.
*/
static final ArchRule controllers_call_only_marked_service_methods = classes()
.that().resideInAPackage("..api..")
.should(new ArchCondition<>("call only service methods that carry a role marker and are not @Async") {
@Override
public void check(JavaClass controller, ConditionEvents events) {
for (JavaCodeUnit codeUnit : controller.getCodeUnits()) {
for (MethodAccess access : methodAccesses(codeUnit)) {
if (!isService(access.target().getOwner())) {
continue;
}
if (effectiveNeed(access.target()).isEmpty()) {
events.add(SimpleConditionEvent.violated(access.access(), "no role marker on " + access));
}
if (isAsync(access.target())) {
events.add(SimpleConditionEvent.violated(access.access(), "@Async method " + access));
}
}
}
}
})
.because("a new endpoint must not forget the role check");
static final ArchRule one_role_marker_per_element = classes()
.should(new ArchCondition<>("carry at most one role marker on the class and on each method") {
@Override
public void check(JavaClass javaClass, ConditionEvents events) {
if (markerCount(javaClass) > 1) {
events.add(SimpleConditionEvent.violated(javaClass, javaClass.getName() + " has two role markers"));
}
for (JavaMethod method : javaClass.getMethods()) {
if (markerCount(method) > 1) {
events.add(SimpleConditionEvent.violated(method, method.getFullName() + " has two role markers"));
}
}
}
})
.because("the marker must say one thing");
/**
* Starts at every method the proxy checks (not private, not static) and follows every call that is not checked
* again: self calls and calls of unmarked methods. Calls through an interface (a port) end the walk: the
* implementation is not known here.
*/
static final ArchRule services_never_call_a_method_that_needs_more = classes()
.that(SERVICE_CLASSES)
.should(new ArchCondition<>("never call a service method that needs more than the caller") {
@Override
public void check(JavaClass service, ConditionEvents events) {
for (JavaMethod entry : service.getMethods()) {
Optional<Need> caller = effectiveNeed(entry);
if (caller.isEmpty() || entry.getModifiers().contains(JavaModifier.PRIVATE)
|| entry.getModifiers().contains(JavaModifier.STATIC)) {
continue;
}
Set<JavaCodeUnit> seen = new HashSet<>(List.of(entry));
Deque<JavaCodeUnit> toVisit = new ArrayDeque<>(seen);
while (!toVisit.isEmpty()) {
for (MethodAccess access : methodAccesses(toVisit.pop())) {
JavaMethod target = access.target();
Optional<Need> callee = target.getOwner().equals(service) ? Optional.empty()
: effectiveNeed(target);
if (callee.isEmpty()) {
if (target.getOwner().getPackageName().startsWith(APP_PACKAGE) && seen.add(target)) {
toVisit.push(target);
}
} else if (!callee.get().isMetBy(caller.get())) {
events.add(SimpleConditionEvent.violated(access.access(), entry.getName() + " ("
+ caller.get() + ") calls " + access + ", which needs " + callee.get()));
}
}
}
}
}
})
.because("the inner check would refuse a caller the outer check let in");
}
@@ -0,0 +1,113 @@
package core_service.architecture;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.EvaluationResult;
import core_service.architecture.fixture.api.BadController;
import core_service.architecture.fixture.api.GoodController;
import core_service.architecture.fixture.api.MethodReferenceController;
import core_service.architecture.fixture.service.ForwardingService;
import core_service.architecture.fixture.service.InternalCallerService;
import core_service.architecture.fixture.service.InternalMarkedService;
import core_service.architecture.fixture.service.MarkedService;
import core_service.architecture.fixture.service.PublicCallerService;
import core_service.architecture.fixture.service.PublicViaUnmarkedCallerService;
import core_service.architecture.fixture.service.PublicHelperCallerService;
import core_service.architecture.fixture.service.PublicMethodReferenceCallerService;
import core_service.architecture.fixture.service.TwoMarkersService;
import core_service.architecture.fixture.service.UnmarkedService;
import core_service.architecture.fixture.service.UserCallerService;
import core_service.shared.test.AbstractArchitectureTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class RoleAnnotationRulesTest extends AbstractArchitectureTest {
private static List<String> violations(ArchRule rule, Class<?>... classes) {
JavaClasses imported = new ClassFileImporter().importClasses(classes);
EvaluationResult result = rule.evaluate(imported);
return result.getFailureReport().getDetails();
}
@Test
void controllerRule_PassesMarkedMethodsTheMethodMarkerAndStaticHelpers() {
assertThat(violations(RoleAnnotationRules.controllers_call_only_marked_service_methods,
GoodController.class, MarkedService.class, UnmarkedService.class)).isEmpty();
}
@Test
void controllerRule_FindsAnUnmarkedAndAnAsyncMethod() {
List<String> found = violations(RoleAnnotationRules.controllers_call_only_marked_service_methods,
BadController.class, MarkedService.class, UnmarkedService.class);
assertThat(found).hasSize(2);
assertThat(found).anyMatch(line -> line.contains("no role marker on UnmarkedService.bare"));
assertThat(found).anyMatch(line -> line.contains("@Async method MarkedService.later"));
}
@Test
void markerRule_FindsTwoMarkersOnOneMethod() {
assertThat(violations(RoleAnnotationRules.one_role_marker_per_element, TwoMarkersService.class))
.singleElement().asString().contains("both");
}
@Test
void escalationRule_FindsAPublicMethodCallingAUserMethod() {
List<String> found = violations(RoleAnnotationRules.services_never_call_a_method_that_needs_more,
PublicCallerService.class, MarkedService.class);
assertThat(found).singleElement().asString()
.contains("callsUser (@Public)", "MarkedService.plain", "needs @RequiresRole(USER)");
}
@Test
void escalationRule_FindsAUserMethodCallingAnAuditorMethod() {
assertThat(violations(RoleAnnotationRules.services_never_call_a_method_that_needs_more,
UserCallerService.class, MarkedService.class))
.singleElement().asString().contains("needs @RequiresRole(AUDITOR)");
}
@Test
void controllerRule_FindsAnUnmarkedMethodCalledByMethodReference() {
assertThat(violations(RoleAnnotationRules.controllers_call_only_marked_service_methods,
MethodReferenceController.class, UnmarkedService.class))
.anyMatch(line -> line.contains("no role marker on UnmarkedService.bare"));
}
@Test
void escalationRule_FindsAPublicMethodCallingAUserMethodThroughAPrivateHelper() {
assertThat(violations(RoleAnnotationRules.services_never_call_a_method_that_needs_more,
PublicHelperCallerService.class, MarkedService.class))
.anyMatch(line -> line.contains("MarkedService.plain") && line.contains("needs @RequiresRole(USER)"));
}
@Test
void escalationRule_FindsAPublicMethodCallingAUserMethodByMethodReference() {
assertThat(violations(RoleAnnotationRules.services_never_call_a_method_that_needs_more,
PublicMethodReferenceCallerService.class, MarkedService.class))
.anyMatch(line -> line.contains("MarkedService.plain") && line.contains("needs @RequiresRole(USER)"));
}
@Test
void escalationRule_FindsAPublicMethodReachingAUserMethodThroughAnUnmarkedService() {
assertThat(violations(RoleAnnotationRules.services_never_call_a_method_that_needs_more,
PublicViaUnmarkedCallerService.class, ForwardingService.class, MarkedService.class))
.singleElement().asString()
.contains("callsThroughAnUnmarkedService (@Public)", "MarkedService.plain", "needs @RequiresRole(USER)");
}
@Test
void escalationRule_KeepsInternalAndRolesApart() {
List<String> found = violations(RoleAnnotationRules.services_never_call_a_method_that_needs_more,
InternalCallerService.class, InternalMarkedService.class, MarkedService.class);
assertThat(found).hasSize(2);
assertThat(found).anyMatch(line -> line.contains("callsUser (@Internal)") && line.contains("needs @RequiresRole(USER)"));
assertThat(found).anyMatch(line -> line.contains("adminCallsInternal (@RequiresRole(PLATFORM_ADMIN))")
&& line.contains("needs @Internal"));
}
}
@@ -0,0 +1,15 @@
package core_service.architecture.fixture.api;
import core_service.architecture.fixture.service.MarkedService;
import core_service.architecture.fixture.service.UnmarkedService;
public class BadController {
private final UnmarkedService unmarkedService = new UnmarkedService();
private final MarkedService markedService = new MarkedService();
public void handle() {
unmarkedService.bare();
markedService.later();
}
}
@@ -0,0 +1,15 @@
package core_service.architecture.fixture.api;
import core_service.architecture.fixture.service.MarkedService;
import core_service.architecture.fixture.service.UnmarkedService;
public class GoodController {
private final MarkedService markedService = new MarkedService();
public void handle() {
markedService.plain();
markedService.open();
UnmarkedService.helper();
}
}
@@ -0,0 +1,13 @@
package core_service.architecture.fixture.api;
import core_service.architecture.fixture.service.UnmarkedService;
public class MethodReferenceController {
private final UnmarkedService unmarkedService = new UnmarkedService();
public void handle() {
Runnable call = unmarkedService::bare;
call.run();
}
}
@@ -0,0 +1,10 @@
package core_service.architecture.fixture.service;
public class ForwardingService {
private final MarkedService markedService = new MarkedService();
public void forward() {
markedService.plain();
}
}
@@ -0,0 +1,25 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Internal;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
@Internal
public class InternalCallerService {
private final MarkedService markedService = new MarkedService();
private final InternalMarkedService internalMarkedService = new InternalMarkedService();
public void callsInternal() {
internalMarkedService.call();
}
public void callsUser() {
markedService.plain();
}
@RequiresRole(Role.PLATFORM_ADMIN)
public void adminCallsInternal() {
internalMarkedService.call();
}
}
@@ -0,0 +1,10 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Internal;
@Internal
public class InternalMarkedService {
public void call() {
}
}
@@ -0,0 +1,25 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import org.springframework.scheduling.annotation.Async;
@RequiresRole(Role.USER)
public class MarkedService {
public void plain() {
}
@Public
public void open() {
}
@RequiresRole(Role.AUDITOR)
public void audit() {
}
@Async
public void later() {
}
}
@@ -0,0 +1,17 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Public;
@Public
public class PublicCallerService {
private final MarkedService markedService = new MarkedService();
public void callsOpen() {
markedService.open();
}
public void callsUser() {
markedService.plain();
}
}
@@ -0,0 +1,20 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
@RequiresRole(Role.USER)
public class PublicHelperCallerService {
private final MarkedService markedService = new MarkedService();
@Public
public void open() {
helper();
}
private void helper() {
markedService.plain();
}
}
@@ -0,0 +1,17 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
@RequiresRole(Role.USER)
public class PublicMethodReferenceCallerService {
private final MarkedService markedService = new MarkedService();
@Public
public void open() {
Runnable call = markedService::plain;
call.run();
}
}
@@ -0,0 +1,13 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Public;
@Public
public class PublicViaUnmarkedCallerService {
private final ForwardingService forwardingService = new ForwardingService();
public void callsThroughAnUnmarkedService() {
forwardingService.forward();
}
}
@@ -0,0 +1,12 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Internal;
import core_service.shared.security.Public;
public class TwoMarkersService {
@Public
@Internal
public void both() {
}
}
@@ -0,0 +1,11 @@
package core_service.architecture.fixture.service;
public class UnmarkedService {
public void bare() {
}
public static boolean helper() {
return true;
}
}
@@ -0,0 +1,14 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
@RequiresRole(Role.USER)
public class UserCallerService {
private final MarkedService markedService = new MarkedService();
public void callsAuditor() {
markedService.audit();
}
}
@@ -1,6 +1,7 @@
package core_service.configuration.security; package core_service.configuration.security;
import core_service.shared.security.ProfileTicket; import core_service.shared.security.ProfileTicket;
import core_service.shared.security.Role;
import core_service.shared.security.UserPrincipal; import core_service.shared.security.UserPrincipal;
import core_service.shared.test.AbstractUnitTest; import core_service.shared.test.AbstractUnitTest;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
@@ -166,4 +167,33 @@ class JwtServiceTest extends AbstractUnitTest {
void parseLoginToken_ServiceTokenIsNoLogin() { void parseLoginToken_ServiceTokenIsNoLogin() {
assertEquals(Optional.empty(), jwtService.parseLoginToken(signServiceToken(signingKey, "service", 60_000L))); assertEquals(Optional.empty(), jwtService.parseLoginToken(signServiceToken(signingKey, "service", 60_000L)));
} }
private String signTokenWithRole(String role) {
return Jwts.builder()
.subject("test@example.com")
.claim("userId", "0815")
.claim("role", role)
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(signingKey, Jwts.SIG.EdDSA)
.compact();
}
@Test
void parseLoginToken_ReadsTheActiveRole() {
assertEquals(Role.AUDITOR, jwtService.parseLoginToken(signTokenWithRole("AUDITOR")).orElseThrow().role());
}
@Test
void parseLoginToken_WithoutOrWithAnUnknownRole_IsUser() {
assertEquals(Role.USER, jwtService.parseLoginToken(signToken("0815", "test@example.com", null)).orElseThrow().role());
assertEquals(Role.USER, jwtService.parseLoginToken(signTokenWithRole("GOD")).orElseThrow().role());
}
@Test
void parseLoginToken_WithARoleThatIsNoString_IsStillALoginAsUser() {
String token = Jwts.builder().subject("test@example.com").claim("userId", "0815").claim("role", List.of("AUDITOR"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
assertEquals(Role.USER, jwtService.parseLoginToken(token).orElseThrow().role());
}
} }
@@ -20,6 +20,7 @@ import java.security.KeyPair;
import java.security.KeyPairGenerator; import java.security.KeyPairGenerator;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.List;
import java.util.Set; import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -91,6 +92,23 @@ class PublicEndpointsTest {
.andExpect(jsonPath("$.code").value("INVALID_TICKET")); .andExpect(jsonPath("$.code").value("INVALID_TICKET"));
} }
// Without a login: a class role marker on the way would answer 401.
@Test
void publicProfileWithSharedStats_WorksWithoutALogin() throws Exception {
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "1")
.claim("profileStats", List.of("TOP_GAMES", "OVERVIEW")).expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket))
.andExpect(status().isOk())
.andExpect(jsonPath("$.stats.topGames").exists());
}
@Test
void landingStatsArePublic() throws Exception {
mockMvc.perform(get("/api/stats/public")).andExpect(status().isOk());
}
@Test @Test
void versionIsPublic() throws Exception { void versionIsPublic() throws Exception {
mockMvc.perform(get("/api/version")) mockMvc.perform(get("/api/version"))
@@ -0,0 +1,119 @@
package core_service.configuration.security;
import core_service.shared.security.Internal;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractUnitTest;
import org.aopalliance.intercept.MethodInvocation;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
class RoleAuthorizationManagerTest extends AbstractUnitTest {
private final RoleAuthorizationManager manager = new RoleAuthorizationManager();
@RequiresRole(Role.USER)
static class UserMarked {
public void plain() {
}
@RequiresRole(Role.AUDITOR)
public void audit() {
}
@Public
public void open() {
}
}
@Internal
static class InternalMarked {
public void call() {
}
}
@AfterEach
void clearRequest() {
RequestContextHolder.resetRequestAttributes();
}
private static void inRequest() {
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(new MockHttpServletRequest()));
}
private static Authentication login(Role role) {
return new UsernamePasswordAuthenticationToken("42", null, AccessTokens.authorities(role));
}
private static Authentication anonymous() {
return new AnonymousAuthenticationToken("key", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
}
private static Authentication service() {
return new UsernamePasswordAuthenticationToken("tbp-auth", null,
List.of(new SimpleGrantedAuthority(ServiceTokenFilter.SERVICE_AUTHORITY)));
}
private boolean granted(Authentication authentication, Object target, String method) throws Exception {
MethodInvocation invocation = mock(MethodInvocation.class);
// Lenient: without a request the manager does not look at the invocation.
lenient().when(invocation.getThis()).thenReturn(target);
lenient().when(invocation.getMethod()).thenReturn(target.getClass().getMethod(method));
return manager.authorize(() -> authentication, invocation).isGranted();
}
@Test
void classMarker_CoversTheMethodsWithoutOwnMarker() throws Exception {
inRequest();
assertTrue(granted(login(Role.USER), new UserMarked(), "plain"));
assertFalse(granted(anonymous(), new UserMarked(), "plain"));
}
@Test
void methodMarker_ReplacesTheClassMarker() throws Exception {
inRequest();
assertFalse(granted(login(Role.USER), new UserMarked(), "audit"));
assertTrue(granted(anonymous(), new UserMarked(), "open"));
}
@Test
void higherRoles_PassLowerChecks() throws Exception {
inRequest();
assertTrue(granted(login(Role.AUDITOR), new UserMarked(), "audit"));
assertTrue(granted(login(Role.PLATFORM_ADMIN), new UserMarked(), "audit"));
assertTrue(granted(login(Role.PLATFORM_ADMIN), new UserMarked(), "plain"));
}
@Test
void internal_NeedsTheServiceToken() throws Exception {
inRequest();
assertTrue(granted(service(), new InternalMarked(), "call"));
assertFalse(granted(login(Role.PLATFORM_ADMIN), new InternalMarked(), "call"));
assertFalse(granted(service(), new UserMarked(), "plain"));
}
@Test
void withoutARequest_EverythingRuns() throws Exception {
assertTrue(granted(null, new UserMarked(), "audit"));
assertTrue(granted(null, new InternalMarked(), "call"));
}
}
@@ -0,0 +1,137 @@
package core_service.configuration.security;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Jwts;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Import;
import org.springframework.stereotype.Service;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The role markers through the real (non-dev) filter chain, on a probe that only this test sees.
*/
@AutoConfigureMockMvc
@ActiveProfiles(value = "prod", inheritProfiles = false)
@Import({RoleCheckFlowTest.ProbeService.class, RoleCheckFlowTest.ProbeController.class})
class RoleCheckFlowTest extends AbstractIntegrationTest {
@Service
@RequiresRole(Role.USER)
static class ProbeService {
public String user() {
return "user";
}
@RequiresRole(Role.AUDITOR)
public String auditor() {
return "auditor";
}
@Public
public String open() {
return "open";
}
}
@RestController
static class ProbeController {
private final ProbeService probeService;
ProbeController(ProbeService probeService) {
this.probeService = probeService;
}
@GetMapping("/api/role-probe/user")
String user() {
return probeService.user();
}
@GetMapping("/api/role-probe/auditor")
String auditor() {
return probeService.auditor();
}
// Authenticated URL: the marker opens nothing the URL rules close.
@GetMapping("/api/role-probe/open")
String open() {
return probeService.open();
}
// Public URL: only the marker stops an anonymous caller.
@GetMapping("/api/public/role-probe/user")
String publicUser() {
return probeService.user();
}
@GetMapping("/api/public/role-probe/open")
String publicOpen() {
return probeService.open();
}
}
@Autowired
private MockMvc mockMvc;
@Autowired
private ProbeService probeService;
private static String token(Role role) {
return Jwts.builder()
.subject("probe@example.com")
.claim("userId", "4711")
.claim("role", role.name())
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
.compact();
}
private ResultActions call(String path, Role role) throws Exception {
MockHttpServletRequestBuilder request = get("/api/role-probe/" + path);
if (role != null) {
request.cookie(new Cookie(AccessTokens.ACCESS_COOKIE, token(role)));
}
return mockMvc.perform(request);
}
@Test
void user_PassesUserAndIsRefusedAuditor() throws Exception {
call("user", Role.USER).andExpect(status().isOk());
call("open", Role.USER).andExpect(status().isOk());
call("auditor", Role.USER).andExpect(status().isForbidden());
}
@Test
void higherRoles_PassTheLowerChecks() throws Exception {
call("auditor", Role.AUDITOR).andExpect(status().isOk());
call("auditor", Role.PLATFORM_ADMIN).andExpect(status().isOk());
call("user", Role.PLATFORM_ADMIN).andExpect(status().isOk());
}
@Test
void noLogin_OnAPublicUrl_IsStoppedByTheMarker() throws Exception {
mockMvc.perform(get("/api/public/role-probe/user")).andExpect(status().isUnauthorized());
mockMvc.perform(get("/api/public/role-probe/open")).andExpect(status().isOk());
}
@Test
void outsideARequest_TheServiceRuns() {
assertEquals("auditor", probeService.auditor());
}
}
@@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
@@ -29,6 +30,9 @@ import static org.assertj.core.api.Assertions.assertThat;
@Import({UserStatsService.class, UserStatsRepository.class, BacklogSnapshotRepository.class}) @Import({UserStatsService.class, UserStatsRepository.class, BacklogSnapshotRepository.class})
class UserStatsServiceTrendTest extends AbstractPersistenceTest { class UserStatsServiceTrendTest extends AbstractPersistenceTest {
@MockitoBean
private MoodService moodService;
private static final long USER_ID = 1L; private static final long USER_ID = 1L;
@Autowired @Autowired
@@ -19,8 +19,10 @@ import java.util.Base64;
*/ */
@Tag("IntegrationTest") @Tag("IntegrationTest")
// The nightly sync would call Steam if a test ran at 23:55; speedrun.com needs no key, so it is switched off. // The nightly sync would call Steam if a test ran at 23:55; speedrun.com needs no key, so it is switched off.
// Every cached context keeps its own pool: small pools keep them all below Postgres' 100 clients.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "app.speedrun.enabled=false"}) properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "app.speedrun.enabled=false",
"spring.datasource.hikari.maximum-pool-size=4"})
@ActiveProfiles("dev") @ActiveProfiles("dev")
public abstract class AbstractIntegrationTest { public abstract class AbstractIntegrationTest {