Public Access
feat(account): reject tokens of blocked users and old roles
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:
1 parent
35b1db45e6
commit
10b7dc2272
23 files changed
+423
-36
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<UserPrincipal> 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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -64,7 +64,8 @@ public class InternalAccountController {
|
||||
|
||||
private static List<AccountState> 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -23,11 +23,11 @@ public class AccountCleanupRepository {
|
||||
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",
|
||||
"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<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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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<AccountState> 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<AccountState> states) {
|
||||
repository.replaceAll(states);
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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.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<String, Object> claims = login(userId, role, null);
|
||||
claims.put("iat", issuedAt.getEpochSecond());
|
||||
return sign(claims);
|
||||
}
|
||||
|
||||
public static String loginToken(long userId) {
|
||||
return loginToken(userId, Role.USER);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user