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

@@ -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",