diff --git a/readme.md b/readme.md index 591508e..7ee4f7f 100644 --- a/readme.md +++ b/readme.md @@ -179,7 +179,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public | | `GET` | `/api/stats/public/games?key=&arg=&page=` (the games behind a landing number, per game `libraries` and `minutes`; `400 BAD_ARG`) | public | | `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token | -| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked}]}`, `displayName` optional, at most 100 characters, kept in `account_name` for the audit log; POST updates, PUT replaces the whole set; `204`) | service token | +| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked, roleChangedAt}]}`, `displayName` and `roleChangedAt` optional, `displayName` at most 100 characters, kept in `account_name` for the audit log, `roleChangedAt` kept in `account_role_change`; POST updates, PUT replaces the whole set; `204`) | service token | | `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token | | `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public | @@ -236,7 +236,7 @@ flowchart LR | `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data | | `stats` | Public numbers for the landing page | | `version` | `GET /api/version`: commit and build time of the running image | -| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, display names in `account_name`, Steam ID changes stop the nightly sync | +| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, role changes in `account_role_change`, display names in `account_name`, Steam ID changes stop the nightly sync | | `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures | | `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search | | `speedrun_adapter` | speedrun.com client: game search by name, verified runs | @@ -426,8 +426,10 @@ The purge also removes the user's `account_name` row and link requests (`game_li Auth sends a user's state (`POST /internal/accounts/states`) after a Steam link, an unlink, a Steam ID another account took by a verified link, a display name change, a block and an unblock; its nightly job sends every user (`PUT`). A blocked user is in -`blocked_user`: no nightly sync, no new sync run, not in the landing stats. The access token still works in Core until -it expires (max 15 min). A new Steam ID or no Steam ID takes the user out of the nightly sync; the next visit syncs +`blocked_user`: no nightly sync, no new sync run, not in the landing stats. `JwtAuthenticationFilter` refuses a blocked +user's token and a token issued before the user's last role change (`account_role_change`, sent as `roleChangedAt`), +through the cache `loginStates` (30 s, evicted by every state call). An Auth that does not send `roleChangedAt` changes +only the blocks. A new Steam ID or no Steam ID takes the user out of the nightly sync; the next visit syncs the ID of the token (Core's copy changes only then, so that sync sees the change and drops the old account's games). A visit with an access token that still has the old ID (max 15 min) can sync it once more and put the user back on the nightly list; the nightly `PUT` at 04:15 takes the user off again, so at most one nightly sync (23:55) runs with the old ID. Unblock and the nightly `PUT` lift the block. diff --git a/src/main/java/core_service/configuration/CacheConfig.java b/src/main/java/core_service/configuration/CacheConfig.java index b95c082..7bb5550 100644 --- a/src/main/java/core_service/configuration/CacheConfig.java +++ b/src/main/java/core_service/configuration/CacheConfig.java @@ -8,6 +8,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; +import java.time.Duration; import java.util.concurrent.TimeUnit; @Configuration @@ -22,6 +23,11 @@ public class CacheConfig { cacheManager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(10, TimeUnit.MINUTES) .maximumSize(1000)); + // The real limit after a block or a role change: the state call evicts it, a missed call heals after 30 s. + cacheManager.registerCustomCache("loginStates", Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofSeconds(30)) + .build()); return cacheManager; } } diff --git a/src/main/java/core_service/configuration/security/JwtAuthenticationFilter.java b/src/main/java/core_service/configuration/security/JwtAuthenticationFilter.java index 6b828eb..721210a 100644 --- a/src/main/java/core_service/configuration/security/JwtAuthenticationFilter.java +++ b/src/main/java/core_service/configuration/security/JwtAuthenticationFilter.java @@ -1,5 +1,6 @@ package core_service.configuration.security; +import core_service.shared.security.LoginStateCheck; import core_service.shared.security.UserPrincipal; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -17,12 +18,16 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.Optional; +/** + * A blocked user and a token issued before the last role change of its user count as not logged in. + */ @Component("jwtAuthenticationFilter") @Profile("!dev") @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; + private final LoginStateCheck loginStateCheck; @Override protected void doFilterInternal( @@ -34,6 +39,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { Optional principal = AccessTokens.candidates(request) .map(jwtService::parseLoginToken) .flatMap(Optional::stream) + .filter(each -> loginStateCheck.state(each.getIdAsLong()).accepts(each.issuedAt())) .findFirst(); if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) { UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( diff --git a/src/main/java/core_service/configuration/security/JwtService.java b/src/main/java/core_service/configuration/security/JwtService.java index fed7540..9bdf1d2 100644 --- a/src/main/java/core_service/configuration/security/JwtService.java +++ b/src/main/java/core_service/configuration/security/JwtService.java @@ -58,7 +58,8 @@ public class JwtService implements ProfileTicketVerifier { if (userId == null) { return Optional.empty(); } - return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class), role(claims))); + return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class), role(claims), + claims.getIssuedAt() == null ? null : claims.getIssuedAt().toInstant())); } catch (Exception e) { return Optional.empty(); } diff --git a/src/main/java/core_service/modules/account/api/InternalAccountController.java b/src/main/java/core_service/modules/account/api/InternalAccountController.java index b45fa50..7a8ba4e 100644 --- a/src/main/java/core_service/modules/account/api/InternalAccountController.java +++ b/src/main/java/core_service/modules/account/api/InternalAccountController.java @@ -64,7 +64,8 @@ public class InternalAccountController { private static List toStates(AccountStatesRequest request) { return request.accounts().stream() - .map(account -> new AccountState(account.userId(), account.steamId(), account.displayName(), account.blocked())) + .map(account -> new AccountState(account.userId(), account.steamId(), account.displayName(), + account.blocked(), account.roleChangedAt())) .toList(); } diff --git a/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java b/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java index b682812..b152ce2 100644 --- a/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java +++ b/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java @@ -4,14 +4,16 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import java.time.Instant; import java.util.List; public record AccountStatesRequest(@NotNull List<@Valid AccountStateDto> accounts) { /** - * @param displayName null from an Auth that does not send it yet + * @param displayName null from an Auth that does not send it yet + * @param roleChangedAt null from an Auth that does not send it yet */ public record AccountStateDto(@NotNull Long userId, Long steamId, @Size(max = 100) String displayName, - boolean blocked) { + boolean blocked, Instant roleChangedAt) { } } diff --git a/src/main/java/core_service/modules/account/domain/AccountState.java b/src/main/java/core_service/modules/account/domain/AccountState.java index 4dc4c65..cb5d402 100644 --- a/src/main/java/core_service/modules/account/domain/AccountState.java +++ b/src/main/java/core_service/modules/account/domain/AccountState.java @@ -1,10 +1,13 @@ package core_service.modules.account.domain; +import java.time.Instant; + /** * What Core copies from Auth for one user. * * @param steamId null when the user has no Steam ID (unlinked, or taken by another account's verified link) * @param displayName null when Auth did not send one; Core then keeps the stored name + * @param roleChangedAt null from an Auth that does not send it; Core then keeps the stored value */ -public record AccountState(long userId, Long steamId, String displayName, boolean blocked) { +public record AccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt) { } diff --git a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java index 1055157..b67bf70 100644 --- a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java +++ b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java @@ -23,11 +23,11 @@ public class AccountCleanupRepository { static final List TABLES = List.of("user_game_status_change", "user_game_achievement", "user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped", - "blocked_user", "deleted_user"); + "account_role_change", "blocked_user", "deleted_user"); // Every user with data has a row in one of these; deleteRows then removes the child rows too. private static final List ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game", - "game_import", "blocked_user", "deleted_user", "account_name"); + "game_import", "blocked_user", "account_role_change", "deleted_user", "account_name"); private final NamedParameterJdbcTemplate jdbc; diff --git a/src/main/java/core_service/modules/account/repo/AccountStateRepository.java b/src/main/java/core_service/modules/account/repo/AccountStateRepository.java index 7375dc8..da9ef3a 100644 --- a/src/main/java/core_service/modules/account/repo/AccountStateRepository.java +++ b/src/main/java/core_service/modules/account/repo/AccountStateRepository.java @@ -1,16 +1,19 @@ package core_service.modules.account.repo; import core_service.modules.account.domain.AccountState; +import core_service.shared.security.LoginState; import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; +import java.sql.Timestamp; import java.util.Collection; import java.util.List; /** - * Blocks in blocked_user, the display name in account_name, and what a Steam ID change means for user_steam_sync. + * Blocks in blocked_user, role changes in account_role_change, the display name in account_name, and what a Steam ID + * change means for user_steam_sync. * Plain SQL, because the library module owns the entities. */ @Repository @@ -27,6 +30,12 @@ public class AccountStateRepository { + "on conflict (user_id) do update set display_name = excluded.display_name", params.addValue("displayName", state.displayName())); } + if (state.roleChangedAt() != null) { + jdbc.update("insert into account_role_change (user_id, changed_at) values (:userId, :roleChangedAt) " + + "on conflict (user_id) do update set " + + "changed_at = greatest(account_role_change.changed_at, excluded.changed_at)", + params.addValue("roleChangedAt", Timestamp.from(state.roleChangedAt()))); + } if (state.blocked()) { jdbc.update("insert into blocked_user (user_id) values (:userId) on conflict do nothing", params); } else { @@ -44,6 +53,17 @@ public class AccountStateRepository { } } + public LoginState findLoginState(long userId) { + return jdbc.queryForObject(""" + select exists(select 1 from blocked_user where user_id = :userId) as blocked, + (select changed_at from account_role_change where user_id = :userId) as changed_at""", + new MapSqlParameterSource("userId", userId), + (rs, rowNum) -> { + Timestamp changedAt = rs.getTimestamp("changed_at"); + return new LoginState(rs.getBoolean("blocked"), changedAt == null ? null : changedAt.toInstant()); + }); + } + /** * After this, exactly the blocked users of the list are blocked. */ diff --git a/src/main/java/core_service/modules/account/service/AccountStateService.java b/src/main/java/core_service/modules/account/service/AccountStateService.java index 87fe8ff..4bb4390 100644 --- a/src/main/java/core_service/modules/account/service/AccountStateService.java +++ b/src/main/java/core_service/modules/account/service/AccountStateService.java @@ -24,14 +24,14 @@ public class AccountStateService { private final AccountStateRepository repository; @Transactional - @CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true) + @CacheEvict(value = {"publicStats", "publicStatGames", "loginStates"}, allEntries = true) public void apply(List states) { states.forEach(repository::apply); log.info("Account state: {} users updated", states.size()); } @Transactional - @CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true) + @CacheEvict(value = {"publicStats", "publicStatGames", "loginStates"}, allEntries = true) public void replaceAll(List states) { repository.replaceAll(states); log.info("Account state: {} users replaced", states.size()); diff --git a/src/main/java/core_service/modules/account/service/LoginStateService.java b/src/main/java/core_service/modules/account/service/LoginStateService.java new file mode 100644 index 0000000..dee1324 --- /dev/null +++ b/src/main/java/core_service/modules/account/service/LoginStateService.java @@ -0,0 +1,23 @@ +package core_service.modules.account.service; + +import core_service.modules.account.repo.AccountStateRepository; +import core_service.shared.security.LoginState; +import core_service.shared.security.LoginStateCheck; +import core_service.shared.security.Public; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +@Service +@Public +@RequiredArgsConstructor +public class LoginStateService implements LoginStateCheck { + + private final AccountStateRepository repository; + + @Override + @Cacheable(value = "loginStates", key = "#userId") + public LoginState state(long userId) { + return repository.findLoginState(userId); + } +} diff --git a/src/main/java/core_service/shared/security/LoginState.java b/src/main/java/core_service/shared/security/LoginState.java new file mode 100644 index 0000000..aa63b70 --- /dev/null +++ b/src/main/java/core_service/shared/security/LoginState.java @@ -0,0 +1,20 @@ +package core_service.shared.security; + +import java.time.Instant; + +/** + * What Core knows from Auth about the login of one user. + * + * @param roleChangedAt null when Auth never sent a role change + */ +public record LoginState(boolean blocked, Instant roleChangedAt) { + + public static final LoginState NONE = new LoginState(false, null); + + /** + * A token issued before roleChangedAt is refused. roleChangedAt is the start of the second after the change. + */ + public boolean accepts(Instant issuedAt) { + return !blocked && (roleChangedAt == null || (issuedAt != null && !issuedAt.isBefore(roleChangedAt))); + } +} diff --git a/src/main/java/core_service/shared/security/LoginStateCheck.java b/src/main/java/core_service/shared/security/LoginStateCheck.java new file mode 100644 index 0000000..923a939 --- /dev/null +++ b/src/main/java/core_service/shared/security/LoginStateCheck.java @@ -0,0 +1,9 @@ +package core_service.shared.security; + +/** + * Port of the login filter, implemented by the account module. + */ +public interface LoginStateCheck { + + LoginState state(long userId); +} diff --git a/src/main/java/core_service/shared/security/UserPrincipal.java b/src/main/java/core_service/shared/security/UserPrincipal.java index 01d4296..c5d8daa 100644 --- a/src/main/java/core_service/shared/security/UserPrincipal.java +++ b/src/main/java/core_service/shared/security/UserPrincipal.java @@ -1,17 +1,24 @@ package core_service.shared.security; import java.security.Principal; +import java.time.Instant; import java.util.Optional; /** - * @param role the active role of this login ("view as"), set by Auth + * @param role the active role of this login ("view as"), set by Auth + * @param issuedAt {@code iat} of the token, null when it has none */ public record UserPrincipal( String id, Long steamId, - Role role + Role role, + Instant issuedAt ) implements Principal { + public UserPrincipal(String id, Long steamId, Role role) { + this(id, steamId, role, null); + } + public UserPrincipal(String id, Long steamId) { this(id, steamId, Role.USER); } diff --git a/src/main/resources/db/migration/V32__Account_Role_Change.sql b/src/main/resources/db/migration/V32__Account_Role_Change.sql new file mode 100644 index 0000000..e85a4b7 --- /dev/null +++ b/src/main/resources/db/migration/V32__Account_Role_Change.sql @@ -0,0 +1,6 @@ +-- When Auth last changed a user's role on the admin page (X4 path, like blocked_user). A login token issued before +-- this time is refused. +CREATE TABLE account_role_change ( + user_id BIGINT PRIMARY KEY, + changed_at TIMESTAMPTZ NOT NULL +); diff --git a/src/test/java/core_service/configuration/security/AccessTokenFiltersTest.java b/src/test/java/core_service/configuration/security/AccessTokenFiltersTest.java index 7a30415..01487b7 100644 --- a/src/test/java/core_service/configuration/security/AccessTokenFiltersTest.java +++ b/src/test/java/core_service/configuration/security/AccessTokenFiltersTest.java @@ -1,9 +1,13 @@ package core_service.configuration.security; +import core_service.shared.security.LoginState; +import core_service.shared.security.LoginStateCheck; +import core_service.shared.security.Role; import core_service.shared.security.UserPrincipal; import core_service.shared.test.AbstractUnitTest; import jakarta.servlet.http.Cookie; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.springframework.mock.web.MockFilterChain; @@ -12,10 +16,12 @@ import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import java.time.Instant; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.lenient; /** @@ -24,10 +30,19 @@ import static org.mockito.Mockito.lenient; class AccessTokenFiltersTest extends AbstractUnitTest { private static final UserPrincipal USER = new UserPrincipal("42", 76561198000000042L); + private static final Instant ROLE_CHANGE = Instant.parse("2026-10-04T12:00:00Z"); @Mock private JwtService jwtService; + @Mock + private LoginStateCheck loginStateCheck; + + @BeforeEach + void everyoneMayLogIn() { + lenient().when(loginStateCheck.state(anyLong())).thenReturn(LoginState.NONE); + } + @AfterEach void clearContext() { SecurityContextHolder.clearContext(); @@ -39,6 +54,10 @@ class AccessTokenFiltersTest extends AbstractUnitTest { return authentication == null ? null : authentication.getPrincipal(); } + private JwtAuthenticationFilter jwtFilter() { + return new JwtAuthenticationFilter(jwtService, loginStateCheck); + } + private void validToken(String token) { lenient().when(jwtService.parseLoginToken(token)).thenReturn(Optional.of(USER)); } @@ -49,7 +68,7 @@ class AccessTokenFiltersTest extends AbstractUnitTest { MockHttpServletRequest request = new MockHttpServletRequest(); request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "good")); - assertEquals(USER, principalAfter(new JwtAuthenticationFilter(jwtService), request)); + assertEquals(USER, principalAfter(jwtFilter(), request)); } @Test @@ -58,7 +77,7 @@ class AccessTokenFiltersTest extends AbstractUnitTest { MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader("Authorization", "Bearer good"); - assertEquals(USER, principalAfter(new JwtAuthenticationFilter(jwtService), request)); + assertEquals(USER, principalAfter(jwtFilter(), request)); } @Test @@ -69,7 +88,7 @@ class AccessTokenFiltersTest extends AbstractUnitTest { request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "stale")); request.addHeader("Authorization", "Bearer good"); - assertEquals(USER, principalAfter(new JwtAuthenticationFilter(jwtService), request)); + assertEquals(USER, principalAfter(jwtFilter(), request)); } @Test @@ -78,7 +97,54 @@ class AccessTokenFiltersTest extends AbstractUnitTest { MockHttpServletRequest request = new MockHttpServletRequest(); request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "bad")); - assertNull(principalAfter(new JwtAuthenticationFilter(jwtService), request)); + assertNull(principalAfter(jwtFilter(), request)); + } + + private static UserPrincipal issuedAt(Instant issuedAt) { + return new UserPrincipal("42", 76561198000000042L, Role.AUDITOR, issuedAt); + } + + private MockHttpServletRequest bearer(String token) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + token); + return request; + } + + @Test + void tokenFromBeforeTheRoleChange_StaysAnonymous() throws Exception { + lenient().when(jwtService.parseLoginToken("old")).thenReturn(Optional.of(issuedAt(ROLE_CHANGE.minusSeconds(5)))); + lenient().when(loginStateCheck.state(42)).thenReturn(new LoginState(false, ROLE_CHANGE)); + + assertNull(principalAfter(jwtFilter(), bearer("old"))); + } + + @Test + void tokenFromAfterTheRoleChange_Authenticates() throws Exception { + UserPrincipal fresh = issuedAt(ROLE_CHANGE.plusSeconds(1)); + lenient().when(jwtService.parseLoginToken("new")).thenReturn(Optional.of(fresh)); + lenient().when(loginStateCheck.state(42)).thenReturn(new LoginState(false, ROLE_CHANGE)); + + assertEquals(fresh, principalAfter(jwtFilter(), bearer("new"))); + } + + @Test + void blockedUser_StaysAnonymous() throws Exception { + validToken("good"); + lenient().when(loginStateCheck.state(42)).thenReturn(new LoginState(true, null)); + + assertNull(principalAfter(jwtFilter(), bearer("good"))); + } + + @Test + void staleCookieNextToAFreshBearer_TheBearerWins() throws Exception { + UserPrincipal fresh = issuedAt(ROLE_CHANGE.plusSeconds(1)); + lenient().when(jwtService.parseLoginToken("stale")).thenReturn(Optional.of(issuedAt(ROLE_CHANGE.minusSeconds(5)))); + lenient().when(jwtService.parseLoginToken("new")).thenReturn(Optional.of(fresh)); + lenient().when(loginStateCheck.state(42)).thenReturn(new LoginState(false, ROLE_CHANGE)); + MockHttpServletRequest request = bearer("new"); + request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "stale")); + + assertEquals(fresh, principalAfter(jwtFilter(), request)); } @Test diff --git a/src/test/java/core_service/configuration/security/JwtServiceTest.java b/src/test/java/core_service/configuration/security/JwtServiceTest.java index be8f56c..6c1c82a 100644 --- a/src/test/java/core_service/configuration/security/JwtServiceTest.java +++ b/src/test/java/core_service/configuration/security/JwtServiceTest.java @@ -15,6 +15,7 @@ import org.springframework.web.client.RestClient; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.PrivateKey; +import java.time.Instant; import java.util.Base64; import java.util.Date; import java.util.List; @@ -65,8 +66,12 @@ class JwtServiceTest extends AbstractUnitTest { void parseLoginToken_ReturnsTheUser() { String token = signToken("0815", 123456789L); - assertEquals(Optional.of(new UserPrincipal("0815", 123456789L)), - jwtService.parseLoginToken(token)); + UserPrincipal principal = jwtService.parseLoginToken(token).orElseThrow(); + + assertEquals("0815", principal.id()); + assertEquals(123456789L, principal.steamId()); + assertEquals(Role.USER, principal.role()); + assertFalse(principal.issuedAt().isAfter(Instant.now())); } @Test diff --git a/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java b/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java index 5d721de..02271b6 100644 --- a/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java +++ b/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java @@ -15,6 +15,8 @@ import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import tools.jackson.databind.JsonNode; +import java.sql.Timestamp; +import java.time.Instant; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -45,6 +47,7 @@ class InternalAccountControllerTest extends AbstractIntegrationTest { jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID); jdbc.update("delete from blocked_user where user_id in (?, 42)", USER_ID); jdbc.update("delete from account_name where user_id in (?, 42)", USER_ID); + jdbc.update("delete from account_role_change where user_id in (?, 42)", USER_ID); } private static String serviceToken() { @@ -131,6 +134,27 @@ class InternalAccountControllerTest extends AbstractIntegrationTest { .andExpect(status().isBadRequest()); } + @Test + void stateCallWithoutARoleChange_AnOldAuth_StoresTheBlockAndNoRoleChange() throws Exception { + send(post("/internal/accounts/states"), "{\"accounts\":[{\"userId\":" + USER_ID + ",\"blocked\":true}]}", + serviceToken()).andExpect(status().isNoContent()); + + assertThat(jdbc.queryForList("select user_id from blocked_user where user_id = ?", Long.class, USER_ID)) + .containsExactly(USER_ID); + assertThat(jdbc.queryForList("select user_id from account_role_change where user_id = ?", Long.class, USER_ID)) + .isEmpty(); + } + + @Test + void stateCallWithARoleChange_StoresIt() throws Exception { + JsonNode call = ContractTokens.internalCall("applyStates"); + + send(contractRequest(call), call.path("request").toString(), serviceToken()).andExpect(status().isNoContent()); + + assertThat(jdbc.queryForObject("select changed_at from account_role_change where user_id = 42", + Timestamp.class).toInstant()).isEqualTo(Instant.parse("2026-10-04T12:00:00Z")); + } + // The calls as Auth's CoreAccountService sends them. @Test void contractCalls_AreAnswered() throws Exception { diff --git a/src/test/java/core_service/modules/account/api/LoginStateFlowTest.java b/src/test/java/core_service/modules/account/api/LoginStateFlowTest.java new file mode 100644 index 0000000..a3c3898 --- /dev/null +++ b/src/test/java/core_service/modules/account/api/LoginStateFlowTest.java @@ -0,0 +1,89 @@ +package core_service.modules.account.api; + +import core_service.shared.security.Role; +import core_service.shared.test.AbstractIntegrationTest; +import core_service.shared.test.ContractTokens; +import org.junit.jupiter.api.AfterEach; +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.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; + +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.result.MockMvcResultMatchers.status; + +/** + * A role change or a block that Auth sends reaches the real login filter at once (the state call evicts the cache). + */ +@AutoConfigureMockMvc +@ActiveProfiles(value = "prod", inheritProfiles = false) +@Import(LoginStateFlowTest.ProbeController.class) +class LoginStateFlowTest extends AbstractIntegrationTest { + + private static final long USER_ID = 910_002L; + private static final Instant ISSUED = Instant.parse("2026-10-04T12:00:00Z"); + + @RestController + static class ProbeController { + @GetMapping("/api/login-state-probe") + String probe() { + return "ok"; + } + } + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbc; + + @AfterEach + void cleanUp() { + jdbc.update("delete from blocked_user where user_id = ?", USER_ID); + jdbc.update("delete from account_role_change where user_id = ?", USER_ID); + } + + private ResultActions probe(Instant issuedAt) throws Exception { + return mockMvc.perform(get("/api/login-state-probe") + .header("Authorization", "Bearer " + ContractTokens.loginToken(USER_ID, Role.AUDITOR, issuedAt))); + } + + private void sendState(boolean blocked, Instant roleChangedAt) throws Exception { + String changed = roleChangedAt == null ? "" : ",\"roleChangedAt\":\"" + roleChangedAt + "\""; + mockMvc.perform(post("/internal/accounts/states").contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + ContractTokens.serviceToken()) + .content("{\"accounts\":[{\"userId\":" + USER_ID + ",\"blocked\":" + blocked + changed + "}]}")) + .andExpect(status().isNoContent()); + } + + @Test + void roleChange_RefusesTheOldTokenAndAcceptsANewOne() throws Exception { + probe(ISSUED).andExpect(status().isOk()); + + sendState(false, ISSUED.plusSeconds(1)); + + probe(ISSUED).andExpect(status().isUnauthorized()); + probe(ISSUED.plusSeconds(1)).andExpect(status().isOk()); + } + + @Test + void block_RefusesTheTokenAndUnblockAcceptsItAgain() throws Exception { + probe(ISSUED).andExpect(status().isOk()); + + sendState(true, null); + probe(ISSUED).andExpect(status().isUnauthorized()); + + sendState(false, null); + probe(ISSUED).andExpect(status().isOk()); + } +} diff --git a/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java b/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java index 2c1c14c..dcd9ab0 100644 --- a/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java +++ b/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java @@ -1,6 +1,7 @@ package core_service.modules.account.repo; import core_service.modules.account.domain.AccountState; +import core_service.shared.security.LoginState; import core_service.shared.test.AbstractPersistenceTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -8,6 +9,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Import; import org.springframework.jdbc.core.JdbcTemplate; +import java.sql.Timestamp; +import java.time.Instant; import java.util.List; import java.util.Map; @@ -29,6 +32,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { void seed() { jdbc.update("delete from blocked_user"); jdbc.update("delete from account_name"); + jdbc.update("delete from account_role_change"); for (long userId : List.of(1L, 2L)) { jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, user_seen_at) " + "values (?, ?, now(), now())", userId, String.valueOf(OLD_STEAM_ID)); @@ -46,24 +50,24 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { @Test void displayName_IsStoredAndReplacedAndAStateWithoutOneKeepsIt() { - repository.apply(new AccountState(1L, OLD_STEAM_ID, "lobo", false)); - repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, "lobo", false, null)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null)); assertThat(jdbc.queryForObject("select display_name from account_name where user_id = 1", String.class)).isEqualTo("lobo"); - repository.apply(new AccountState(1L, OLD_STEAM_ID, "wolf", false)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, "wolf", false, null)); assertThat(jdbc.queryForList("select display_name from account_name", String.class)).containsExactly("wolf"); } @Test void block_StopsTheNightlySyncAndUnblockLiftsTheMarker() { - repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, null)); assertThat(blocked()).containsExactly(1L); assertThat(sync(1L).get("user_seen_at")).isNull(); assertThat(sync(2L).get("user_seen_at")).isNotNull(); - repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null)); assertThat(blocked()).isEmpty(); } @@ -71,7 +75,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { // steam_id stays: the next sync with the new ID compares with it and drops the old account's games. @Test void newSteamId_StopsTheNightlySyncOfTheOldIdAndSyncsOnTheNextVisit() { - repository.apply(new AccountState(1L, NEW_STEAM_ID, null, false)); + repository.apply(new AccountState(1L, NEW_STEAM_ID, null, false, null)); assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID)); assertThat(sync(1L).get("last_synced_at")).isNull(); @@ -80,14 +84,14 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { @Test void sameSteamId_KeepsTheLastSync() { - repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null)); assertThat(sync(1L).get("last_synced_at")).isNotNull(); } @Test void noSteamId_StopsTheNightlySync() { - repository.apply(new AccountState(1L, null, null, false)); + repository.apply(new AccountState(1L, null, null, false, null)); assertThat(sync(1L).get("user_seen_at")).isNull(); assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID)); @@ -95,7 +99,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { @Test void userWithoutASyncRow_GetsOnlyTheBlock() { - repository.apply(new AccountState(3L, NEW_STEAM_ID, null, true)); + repository.apply(new AccountState(3L, NEW_STEAM_ID, null, true, null)); assertThat(blocked()).containsExactly(3L); assertThat(jdbc.queryForList("select user_id from user_steam_sync where user_id = 3", Long.class)).isEmpty(); @@ -103,13 +107,55 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { @Test void replaceAll_LiftsTheBlocksAuthNoLongerSends() { - repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true)); - repository.apply(new AccountState(2L, OLD_STEAM_ID, null, true)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, null)); + repository.apply(new AccountState(2L, OLD_STEAM_ID, null, true, null)); - repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, true))); + repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, true, null))); assertThat(blocked()).containsExactly(2L); repository.replaceAll(List.of()); assertThat(blocked()).isEmpty(); } + + private Instant changedAt(long userId) { + return jdbc.queryForObject("select changed_at from account_role_change where user_id = ?", + Timestamp.class, userId).toInstant(); + } + + @Test + void roleChangedAt_IsStoredOverwrittenAndANullKeepsIt() { + Instant first = Instant.parse("2026-10-04T12:00:00Z"); + Instant second = first.plusSeconds(60); + + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, first)); + assertThat(changedAt(1L)).isEqualTo(first); + + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, null)); + assertThat(changedAt(1L)).isEqualTo(first); + + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, second)); + assertThat(changedAt(1L)).isEqualTo(second); + } + + @Test + void anOlderRoleChangeArrivingLateDoesNotLowerTheStoredOne() { + Instant first = Instant.parse("2026-10-04T12:00:01Z"); + Instant second = first.plusSeconds(1); + + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, second)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false, first)); + + assertThat(changedAt(1L)).isEqualTo(second); + } + + @Test + void findLoginState_ReadsTheBlockAndTheRoleChange() { + Instant change = Instant.parse("2026-10-04T12:00:00Z"); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true, change)); + repository.apply(new AccountState(2L, OLD_STEAM_ID, null, false, null)); + + assertThat(repository.findLoginState(1L)).isEqualTo(new LoginState(true, change)); + assertThat(repository.findLoginState(2L)).isEqualTo(LoginState.NONE); + assertThat(repository.findLoginState(99L)).isEqualTo(LoginState.NONE); + } } diff --git a/src/test/java/core_service/shared/security/LoginStateTest.java b/src/test/java/core_service/shared/security/LoginStateTest.java new file mode 100644 index 0000000..f2aa732 --- /dev/null +++ b/src/test/java/core_service/shared/security/LoginStateTest.java @@ -0,0 +1,41 @@ +package core_service.shared.security; + +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LoginStateTest extends AbstractUnitTest { + + private static final Instant CHANGE = Instant.parse("2026-10-04T12:00:00Z"); + + @Test + void none_AcceptsAnyToken() { + assertTrue(LoginState.NONE.accepts(CHANGE)); + assertTrue(LoginState.NONE.accepts(null)); + } + + @Test + void blocked_RefusesEveryToken() { + assertFalse(new LoginState(true, null).accepts(CHANGE)); + } + + @Test + void tokenFromBeforeTheRoleChange_IsRefused() { + assertFalse(new LoginState(false, CHANGE).accepts(CHANGE.minusSeconds(1))); + } + + @Test + void tokenFromTheChangeSecondOrLater_IsAccepted() { + assertTrue(new LoginState(false, CHANGE).accepts(CHANGE)); + assertTrue(new LoginState(false, CHANGE).accepts(CHANGE.plusSeconds(1))); + } + + @Test + void tokenWithoutIssueTime_IsRefusedAfterARoleChange() { + assertFalse(new LoginState(false, CHANGE).accepts(null)); + } +} diff --git a/src/test/java/core_service/shared/test/ContractTokens.java b/src/test/java/core_service/shared/test/ContractTokens.java index 85e4cad..f0b81f7 100644 --- a/src/test/java/core_service/shared/test/ContractTokens.java +++ b/src/test/java/core_service/shared/test/ContractTokens.java @@ -9,6 +9,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; import java.security.PrivateKey; +import java.time.Instant; import java.util.Date; import java.util.LinkedHashMap; import java.util.List; @@ -78,6 +79,15 @@ public final class ContractTokens { return sign(login(userId, role, null)); } + /** + * A login token with a set {@code iat}, as Auth signs it. + */ + public static String loginToken(long userId, Role role, Instant issuedAt) { + Map claims = login(userId, role, null); + claims.put("iat", issuedAt.getEpochSecond()); + return sign(claims); + } + public static String loginToken(long userId) { return loginToken(userId, Role.USER); } diff --git a/src/test/resources/contract/auth-core-contract.json b/src/test/resources/contract/auth-core-contract.json index 87bbb4f..c24e2a8 100644 --- a/src/test/resources/contract/auth-core-contract.json +++ b/src/test/resources/contract/auth-core-contract.json @@ -37,12 +37,12 @@ "applyStates": { "method": "POST", "path": "/internal/accounts/states", - "request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true}]} + "request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true, "roleChangedAt": "2026-10-04T12:00:00Z"}]} }, "replaceStates": { "method": "PUT", "path": "/internal/accounts/states", - "request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false}]} + "request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false, "roleChangedAt": null}]} }, "purge": { "method": "POST",