feat(account): reject tokens of blocked users and old roles
Build, Test & Push Core Service / quality-check (push) Successful in 1m23s
Build, Test & Push Core Service / docker (push) Successful in 1m30s

V32 stores the last role change per user, sent by Auth. A token
issued before that change, or of a blocked user, gets 401 within
about 30 s instead of working until it expires.
This commit is contained in:
lobo committed 2026-10-04 20:09:22 +02:00
1 parent 35b1db45e6
commit 10b7dc2272
23 files changed
+423 -36

No files matched your search

+6 -4
View File
@@ -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 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 | | `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/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 | | `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 | | `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 | | `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data |
| `stats` | Public numbers for the landing page | | `stats` | Public numbers for the landing page |
| `version` | `GET /api/version`: commit and build time of the running image | | `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 | | `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 | | `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 | | `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 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 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 `blocked_user`: no nightly sync, no new sync run, not in the landing stats. `JwtAuthenticationFilter` refuses a blocked
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 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 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 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. nightly sync (23:55) runs with the old ID. Unblock and the nightly `PUT` lift the block.
@@ -8,6 +8,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered; import org.springframework.core.Ordered;
import java.time.Duration;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@Configuration @Configuration
@@ -22,6 +23,11 @@ public class CacheConfig {
cacheManager.setCaffeine(Caffeine.newBuilder() cacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES) .expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(1000)); .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; return cacheManager;
} }
} }
@@ -1,5 +1,6 @@
package core_service.configuration.security; package core_service.configuration.security;
import core_service.shared.security.LoginStateCheck;
import core_service.shared.security.UserPrincipal; import core_service.shared.security.UserPrincipal;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
@@ -17,12 +18,16 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Optional; 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") @Component("jwtAuthenticationFilter")
@Profile("!dev") @Profile("!dev")
@RequiredArgsConstructor @RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService; private final JwtService jwtService;
private final LoginStateCheck loginStateCheck;
@Override @Override
protected void doFilterInternal( protected void doFilterInternal(
@@ -34,6 +39,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
Optional<UserPrincipal> principal = AccessTokens.candidates(request) Optional<UserPrincipal> principal = AccessTokens.candidates(request)
.map(jwtService::parseLoginToken) .map(jwtService::parseLoginToken)
.flatMap(Optional::stream) .flatMap(Optional::stream)
.filter(each -> loginStateCheck.state(each.getIdAsLong()).accepts(each.issuedAt()))
.findFirst(); .findFirst();
if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) { if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
@@ -58,7 +58,8 @@ public class JwtService implements ProfileTicketVerifier {
if (userId == null) { if (userId == null) {
return Optional.empty(); 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) { } catch (Exception e) {
return Optional.empty(); return Optional.empty();
} }
@@ -64,7 +64,8 @@ public class InternalAccountController {
private static List<AccountState> toStates(AccountStatesRequest request) { private static List<AccountState> toStates(AccountStatesRequest request) {
return request.accounts().stream() 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(); .toList();
} }
@@ -4,14 +4,16 @@ import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.util.List; import java.util.List;
public record AccountStatesRequest(@NotNull List<@Valid AccountStateDto> accounts) { 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, public record AccountStateDto(@NotNull Long userId, Long steamId, @Size(max = 100) String displayName,
boolean blocked) { boolean blocked, Instant roleChangedAt) {
} }
} }
@@ -1,10 +1,13 @@
package core_service.modules.account.domain; package core_service.modules.account.domain;
import java.time.Instant;
/** /**
* What Core copies from Auth for one user. * 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 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 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) {
} }
@@ -23,11 +23,11 @@ public class AccountCleanupRepository {
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement", static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "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", "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. // Every user with data has a row in one of these; deleteRows then removes the child rows too.
private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game", private static final List<String> 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; private final NamedParameterJdbcTemplate jdbc;
@@ -1,16 +1,19 @@
package core_service.modules.account.repo; package core_service.modules.account.repo;
import core_service.modules.account.domain.AccountState; import core_service.modules.account.domain.AccountState;
import core_service.shared.security.LoginState;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.util.Collection; import java.util.Collection;
import java.util.List; 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. * Plain SQL, because the library module owns the entities.
*/ */
@Repository @Repository
@@ -27,6 +30,12 @@ public class AccountStateRepository {
+ "on conflict (user_id) do update set display_name = excluded.display_name", + "on conflict (user_id) do update set display_name = excluded.display_name",
params.addValue("displayName", state.displayName())); 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()) { if (state.blocked()) {
jdbc.update("insert into blocked_user (user_id) values (:userId) on conflict do nothing", params); jdbc.update("insert into blocked_user (user_id) values (:userId) on conflict do nothing", params);
} else { } 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. * After this, exactly the blocked users of the list are blocked.
*/ */
@@ -24,14 +24,14 @@ public class AccountStateService {
private final AccountStateRepository repository; private final AccountStateRepository repository;
@Transactional @Transactional
@CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true) @CacheEvict(value = {"publicStats", "publicStatGames", "loginStates"}, allEntries = true)
public void apply(List<AccountState> states) { public void apply(List<AccountState> states) {
states.forEach(repository::apply); states.forEach(repository::apply);
log.info("Account state: {} users updated", states.size()); log.info("Account state: {} users updated", states.size());
} }
@Transactional @Transactional
@CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true) @CacheEvict(value = {"publicStats", "publicStatGames", "loginStates"}, allEntries = true)
public void replaceAll(List<AccountState> states) { public void replaceAll(List<AccountState> states) {
repository.replaceAll(states); repository.replaceAll(states);
log.info("Account state: {} users replaced", states.size()); log.info("Account state: {} users replaced", states.size());
@@ -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);
}
}
@@ -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)));
}
}
@@ -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);
}
@@ -1,17 +1,24 @@
package core_service.shared.security; package core_service.shared.security;
import java.security.Principal; import java.security.Principal;
import java.time.Instant;
import java.util.Optional; 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( public record UserPrincipal(
String id, String id,
Long steamId, Long steamId,
Role role Role role,
Instant issuedAt
) implements Principal { ) implements Principal {
public UserPrincipal(String id, Long steamId, Role role) {
this(id, steamId, role, null);
}
public UserPrincipal(String id, Long steamId) { public UserPrincipal(String id, Long steamId) {
this(id, steamId, Role.USER); this(id, steamId, Role.USER);
} }
@@ -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
);
@@ -1,9 +1,13 @@
package core_service.configuration.security; 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.security.UserPrincipal;
import core_service.shared.test.AbstractUnitTest; import core_service.shared.test.AbstractUnitTest;
import jakarta.servlet.http.Cookie; import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.Mock; import org.mockito.Mock;
import org.springframework.mock.web.MockFilterChain; 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.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import java.time.Instant;
import java.util.Optional; import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.lenient;
/** /**
@@ -24,10 +30,19 @@ import static org.mockito.Mockito.lenient;
class AccessTokenFiltersTest extends AbstractUnitTest { class AccessTokenFiltersTest extends AbstractUnitTest {
private static final UserPrincipal USER = new UserPrincipal("42", 76561198000000042L); private static final UserPrincipal USER = new UserPrincipal("42", 76561198000000042L);
private static final Instant ROLE_CHANGE = Instant.parse("2026-10-04T12:00:00Z");
@Mock @Mock
private JwtService jwtService; private JwtService jwtService;
@Mock
private LoginStateCheck loginStateCheck;
@BeforeEach
void everyoneMayLogIn() {
lenient().when(loginStateCheck.state(anyLong())).thenReturn(LoginState.NONE);
}
@AfterEach @AfterEach
void clearContext() { void clearContext() {
SecurityContextHolder.clearContext(); SecurityContextHolder.clearContext();
@@ -39,6 +54,10 @@ class AccessTokenFiltersTest extends AbstractUnitTest {
return authentication == null ? null : authentication.getPrincipal(); return authentication == null ? null : authentication.getPrincipal();
} }
private JwtAuthenticationFilter jwtFilter() {
return new JwtAuthenticationFilter(jwtService, loginStateCheck);
}
private void validToken(String token) { private void validToken(String token) {
lenient().when(jwtService.parseLoginToken(token)).thenReturn(Optional.of(USER)); lenient().when(jwtService.parseLoginToken(token)).thenReturn(Optional.of(USER));
} }
@@ -49,7 +68,7 @@ class AccessTokenFiltersTest extends AbstractUnitTest {
MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "good")); request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "good"));
assertEquals(USER, principalAfter(new JwtAuthenticationFilter(jwtService), request)); assertEquals(USER, principalAfter(jwtFilter(), request));
} }
@Test @Test
@@ -58,7 +77,7 @@ class AccessTokenFiltersTest extends AbstractUnitTest {
MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("Authorization", "Bearer good"); request.addHeader("Authorization", "Bearer good");
assertEquals(USER, principalAfter(new JwtAuthenticationFilter(jwtService), request)); assertEquals(USER, principalAfter(jwtFilter(), request));
} }
@Test @Test
@@ -69,7 +88,7 @@ class AccessTokenFiltersTest extends AbstractUnitTest {
request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "stale")); request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "stale"));
request.addHeader("Authorization", "Bearer good"); request.addHeader("Authorization", "Bearer good");
assertEquals(USER, principalAfter(new JwtAuthenticationFilter(jwtService), request)); assertEquals(USER, principalAfter(jwtFilter(), request));
} }
@Test @Test
@@ -78,7 +97,54 @@ class AccessTokenFiltersTest extends AbstractUnitTest {
MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "bad")); 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 @Test
@@ -15,6 +15,7 @@ import org.springframework.web.client.RestClient;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.KeyPairGenerator; import java.security.KeyPairGenerator;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.time.Instant;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
@@ -65,8 +66,12 @@ class JwtServiceTest extends AbstractUnitTest {
void parseLoginToken_ReturnsTheUser() { void parseLoginToken_ReturnsTheUser() {
String token = signToken("0815", 123456789L); String token = signToken("0815", 123456789L);
assertEquals(Optional.of(new UserPrincipal("0815", 123456789L)), UserPrincipal principal = jwtService.parseLoginToken(token).orElseThrow();
jwtService.parseLoginToken(token));
assertEquals("0815", principal.id());
assertEquals(123456789L, principal.steamId());
assertEquals(Role.USER, principal.role());
assertFalse(principal.issuedAt().isAfter(Instant.now()));
} }
@Test @Test
@@ -15,6 +15,8 @@ import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import tools.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List; import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; 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 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 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_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() { private static String serviceToken() {
@@ -131,6 +134,27 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
.andExpect(status().isBadRequest()); .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. // The calls as Auth's CoreAccountService sends them.
@Test @Test
void contractCalls_AreAnswered() throws Exception { void contractCalls_AreAnswered() throws Exception {
@@ -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());
}
}
@@ -1,6 +1,7 @@
package core_service.modules.account.repo; package core_service.modules.account.repo;
import core_service.modules.account.domain.AccountState; import core_service.modules.account.domain.AccountState;
import core_service.shared.security.LoginState;
import core_service.shared.test.AbstractPersistenceTest; import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; 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.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -29,6 +32,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
void seed() { void seed() {
jdbc.update("delete from blocked_user"); jdbc.update("delete from blocked_user");
jdbc.update("delete from account_name"); jdbc.update("delete from account_name");
jdbc.update("delete from account_role_change");
for (long userId : List.of(1L, 2L)) { for (long userId : List.of(1L, 2L)) {
jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, user_seen_at) " 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)); + "values (?, ?, now(), now())", userId, String.valueOf(OLD_STEAM_ID));
@@ -46,24 +50,24 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void displayName_IsStoredAndReplacedAndAStateWithoutOneKeepsIt() { void displayName_IsStoredAndReplacedAndAStateWithoutOneKeepsIt() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, "lobo", false)); repository.apply(new AccountState(1L, OLD_STEAM_ID, "lobo", false, null));
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false)); 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"); 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"); assertThat(jdbc.queryForList("select display_name from account_name", String.class)).containsExactly("wolf");
} }
@Test @Test
void block_StopsTheNightlySyncAndUnblockLiftsTheMarker() { 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(blocked()).containsExactly(1L);
assertThat(sync(1L).get("user_seen_at")).isNull(); assertThat(sync(1L).get("user_seen_at")).isNull();
assertThat(sync(2L).get("user_seen_at")).isNotNull(); 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(); 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. // steam_id stays: the next sync with the new ID compares with it and drops the old account's games.
@Test @Test
void newSteamId_StopsTheNightlySyncOfTheOldIdAndSyncsOnTheNextVisit() { 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("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
assertThat(sync(1L).get("last_synced_at")).isNull(); assertThat(sync(1L).get("last_synced_at")).isNull();
@@ -80,14 +84,14 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void sameSteamId_KeepsTheLastSync() { 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(); assertThat(sync(1L).get("last_synced_at")).isNotNull();
} }
@Test @Test
void noSteamId_StopsTheNightlySync() { 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("user_seen_at")).isNull();
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID)); assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
@@ -95,7 +99,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void userWithoutASyncRow_GetsOnlyTheBlock() { 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(blocked()).containsExactly(3L);
assertThat(jdbc.queryForList("select user_id from user_steam_sync where user_id = 3", Long.class)).isEmpty(); 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 @Test
void replaceAll_LiftsTheBlocksAuthNoLongerSends() { void replaceAll_LiftsTheBlocksAuthNoLongerSends() {
repository.apply(new AccountState(1L, 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)); 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); assertThat(blocked()).containsExactly(2L);
repository.replaceAll(List.of()); repository.replaceAll(List.of());
assertThat(blocked()).isEmpty(); 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);
}
} }
@@ -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));
}
}
@@ -9,6 +9,7 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.UncheckedIOException; import java.io.UncheckedIOException;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.time.Instant;
import java.util.Date; import java.util.Date;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
@@ -78,6 +79,15 @@ public final class ContractTokens {
return sign(login(userId, role, null)); 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<String, Object> claims = login(userId, role, null);
claims.put("iat", issuedAt.getEpochSecond());
return sign(claims);
}
public static String loginToken(long userId) { public static String loginToken(long userId) {
return loginToken(userId, Role.USER); return loginToken(userId, Role.USER);
} }
@@ -37,12 +37,12 @@
"applyStates": { "applyStates": {
"method": "POST", "method": "POST",
"path": "/internal/accounts/states", "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": { "replaceStates": {
"method": "PUT", "method": "PUT",
"path": "/internal/accounts/states", "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": { "purge": {
"method": "POST", "method": "POST",