fix(security): refuse the access token of a blocked or deleted user

activeUsers cache, 30 s. Core still accepts the token until it expires; X4 (batch 4) tells Core.
This commit is contained in:
lobo committed 2026-09-30 13:41:57 +02:00
1 parent 509280da14
commit 1f94c87a20
10 files changed
+109 -12

No files matched your search

@@ -15,6 +15,8 @@ public class CacheConfig {
// After write, not after access: an entry another path changed without an evict still ages out.
static final Duration DEFAULT_TTL = Duration.ofMinutes(10);
// The real limit after a block or delete: a concurrent read can put the old answer back after the evict.
static final Duration ACTIVE_USERS_TTL = Duration.ofSeconds(30);
@Bean
public CacheManager cacheManager() {
@@ -23,6 +25,11 @@ public class CacheConfig {
.maximumSize(500)
.expireAfterWrite(DEFAULT_TTL)
.recordStats());
cacheManager.registerCustomCache("activeUsers", Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(ACTIVE_USERS_TTL)
.recordStats()
.build());
return cacheManager;
}
}
@@ -1,6 +1,7 @@
package auth_service.configuration.security;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.security.ActiveUserCheck;
import auth_service.shared.security.Role;
import auth_service.shared.security.UserPrincipal;
import jakarta.servlet.FilterChain;
@@ -24,7 +25,8 @@ import java.util.stream.Stream;
/**
* Takes the login token from the access cookie or from a Bearer header (API clients and tests), the first that
* verifies. A stale cookie next to a valid header must not block the request.
* verifies. A stale cookie next to a valid header must not block the request. A blocked or deleted user counts as
* not logged in.
*/
@Component
@RequiredArgsConstructor
@@ -33,6 +35,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String BEARER = "Bearer ";
private final JwtService jwtService;
private final ActiveUserCheck activeUserCheck;
@Override
protected void doFilterInternal(
@@ -45,6 +48,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
.flatMap(Optional::stream)
.map(jwtService::parseLoginToken)
.flatMap(Optional::stream)
.filter(each -> activeUserCheck.isActive(each.getIdAsLong()))
.findFirst();
if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
@@ -9,6 +9,7 @@ import auth_service.shared.security.RequiresRole;
import auth_service.shared.security.Role;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Caching;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@@ -41,7 +42,8 @@ public class AccountService implements AccountDeleter {
* Flags the user as deleted and ends all logins; the nightly cleanup removes the user 30 days later. Core
* hides the user at once; when Core is down, the nightly cleanup tells it.
*/
@CacheEvict(value = {"users", "publicStats"}, allEntries = true)
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#userId"),
@CacheEvict(value = "publicStats", allEntries = true)})
public void deleteAccount(long userId, String password) {
String key = String.valueOf(userId);
if (!attemptLimiter.tryAcquire(key, MAX_WRONG_PASSWORDS)) {
@@ -62,7 +64,8 @@ public class AccountService implements AccountDeleter {
@Override
@RequiresRole(Role.PLATFORM_ADMIN)
@CacheEvict(value = {"users", "publicStats"}, allEntries = true)
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#userId"),
@CacheEvict(value = "publicStats", allEntries = true)})
public boolean deleteByAdmin(long userId) {
return flagAndTellCore(userId);
}
@@ -12,6 +12,7 @@ import auth_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Caching;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -39,7 +40,8 @@ public class AdminUserService {
}
@Transactional
@CacheEvict(value = {"users", "publicStats"}, allEntries = true)
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#targetUserId"),
@CacheEvict(value = "publicStats", allEntries = true)})
public void setBlocked(long actorUserId, long targetUserId, boolean blocked) {
TargetState target = checkedTarget(actorUserId, targetUserId);
if (target.deleted()) {
@@ -0,0 +1,26 @@
package auth_service.modules.login.service;
import auth_service.modules.login.model.User;
import auth_service.modules.login.persistence.MinimalUserRepository;
import auth_service.shared.security.ActiveUserCheck;
import auth_service.shared.security.Public;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
/**
* Runs on every request with a token, so the answer is cached.
*/
@Service
@Public
@RequiredArgsConstructor
public class ActiveUserService implements ActiveUserCheck {
private final MinimalUserRepository minimalUserRepository;
@Override
@Cacheable(value = "activeUsers", key = "#userId")
public boolean isActive(long userId) {
return minimalUserRepository.findById(userId).filter(User::canLogin).isPresent();
}
}
@@ -0,0 +1,8 @@
package auth_service.shared.security;
/**
* Port for the filter: the access token of a blocked or deleted user stops working before it expires.
*/
public interface ActiveUserCheck {
boolean isActive(long userId);
}
@@ -7,10 +7,12 @@ import auth_service.shared.security.TokenGenerator;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.test.AbstractIntegrationTest;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
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.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
@@ -92,8 +94,21 @@ class RoleCheckFlowTest extends AbstractIntegrationTest {
@Autowired
private TokenGenerator tokenGenerator;
@Autowired
private JdbcTemplate jdbc;
private long userId;
// The filter accepts only the token of an existing, active user.
@BeforeEach
void seedUser() {
jdbc.update("DELETE FROM users WHERE email = 'role-check@example.com'");
userId = jdbc.queryForObject("INSERT INTO users (email, password, enabled) VALUES ('role-check@example.com', 'x', true) "
+ "RETURNING id", Long.class);
}
private String token(Role role) {
return tokenGenerator.generateToken("4711", null, role);
return tokenGenerator.generateToken(String.valueOf(userId), null, role);
}
private ResultActions call(String path, Role role) throws Exception {
@@ -156,6 +156,8 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
MockHttpServletResponse session = login();
String access = session.getCookie(AuthCookies.ACCESS_COOKIE).getValue();
String refresh = session.getCookie(AuthCookies.REFRESH_COOKIE).getValue();
mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, access)))
.andExpect(status().isOk());
long usersBefore = totalUsers();
MockHttpServletResponse deleted = deleteAccount(access, PASSWORD).andExpect(status().isNoContent())
@@ -168,6 +170,8 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
.cookie(new Cookie(AuthCookies.REFRESH_COOKIE, refresh)))
.andExpect(status().isUnauthorized());
assertThat(login().getStatus()).isEqualTo(401);
mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, access)))
.andExpect(status().isUnauthorized());
mockMvc.perform(get("/api/public/profiles/" + NAME)).andExpect(status().isNotFound());
assertThat(totalUsers()).isEqualTo(usersBefore - 1);
}
@@ -169,9 +169,12 @@ class AdminFlowTest extends AbstractIntegrationTest {
void block_EndsTheSessionsAndTheLoginTellsWhy() throws Exception {
Session admin = Session.of(login(ADMIN));
Session player = Session.of(login(PLAYER));
assertThat(me(player)).isEqualTo(200);
block(admin, playerId, true).andExpect(status().isNoContent());
assertThat(me(player)).isEqualTo(401);
assertThat(login(PLAYER).getContentAsString()).contains("ACCOUNT_BLOCKED");
assertThat(login(PLAYER).getStatus()).isEqualTo(403);
assertThat(mockMvc.perform(post("/api/auth/refresh").header(ClientHeaderFilter.CLIENT_HEADER, "web")
@@ -181,11 +184,32 @@ class AdminFlowTest extends AbstractIntegrationTest {
block(admin, playerId, false).andExpect(status().isNoContent());
assertThat(login(PLAYER).getStatus()).isEqualTo(200);
assertThat(me(player)).isEqualTo(200);
}
@Test
void blockedCookieNextToAValidBearer_UsesTheBearer() throws Exception {
Session admin = Session.of(login(ADMIN));
Session player = Session.of(login(PLAYER));
block(admin, playerId, true).andExpect(status().isNoContent());
mockMvc.perform(get("/api/users/me")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, player.access()))
.header("Authorization", "Bearer " + admin.access()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value(ADMIN));
}
private int me(Session session) throws Exception {
return mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access())))
.andReturn().getResponse().getStatus();
}
@Test
void delete_FlagsTheUser() throws Exception {
Session admin = Session.of(login(ADMIN));
Session player = Session.of(login(PLAYER));
assertThat(me(player)).isEqualTo(200);
mockMvc.perform(delete("/api/admin/users/" + playerId).header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin.access())))
@@ -193,6 +217,7 @@ class AdminFlowTest extends AbstractIntegrationTest {
assertThat(jdbc.queryForObject("SELECT deleted_at FROM users WHERE id = ?", Object.class, playerId)).isNotNull();
assertThat(login(PLAYER).getStatus()).isEqualTo(401);
assertThat(me(player)).isEqualTo(401);
assertThat(audits("DELETE", playerId)).isEqualTo(1);
}