Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package fr.enimaloc.catapult.security;

import fr.enimaloc.catapult.domain.account.UserAccount;
import fr.enimaloc.catapult.repository.account.UserAccountRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

/**
* Restores the Twitch login of catapult-api's own session when a secondary link (Steam, Xbox, Battle.net) starts.
*
* <p>The link's callback finds the user in that session ({@link CatapultOAuth2UserService}), which lives in memory:
* a restart of catapult-api, or another replica, loses it while catapult-web still shows the user logged in. So on
* {@code /oauth2/authorization/<provider>}, catapult-web relays the user's JWT as a bearer token, checked by the same
* {@link JwtDecoder} as {@code /api/**}. The user it names replaces the session's, if any: a stale session may still
* hold the account logged in before, and the link must go to the one catapult-web shows.
*
* <p>Must run BEFORE Spring Security's chain, which loads the session's context and redirects to the provider.
*/
@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
@RequiredArgsConstructor
public class LinkSessionRestoreFilter extends OncePerRequestFilter {

static final String PATH_PREFIX = "/oauth2/authorization/";
private static final String BEARER = "Bearer ";
/** Carried by the JWT an admin gets to act as a member ({@link ImpersonationService}). */
static final String IMPERSONATION_ROLE = "ROLE_PREVIOUS_ADMINISTRATOR";

// Resolved on use: a servlet filter is picked up by every web test slice, which has neither
private final ObjectProvider<JwtDecoder> jwtDecoder;
private final ObjectProvider<UserAccountRepository> users;

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI();
// Twitch is the login itself: nothing to restore before it
return !uri.startsWith(PATH_PREFIX) || uri.equals(PATH_PREFIX + "twitch");
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
bearerUser(request)
.filter(user -> !user.getUserAccount().getId().equals(sessionUserId(request.getSession(false))))
.ifPresent(user -> restore(request, user));
chain.doFilter(request, response);
}

private static UUID sessionUserId(HttpSession session) {
if (session != null
&& session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)
instanceof SecurityContext context
&& context.getAuthentication() != null
&& context.getAuthentication().getPrincipal() instanceof CatapultOAuth2User user) {
return user.getUserAccount().getId();
}
return null;
}

private Optional<CatapultOAuth2User> bearerUser(HttpServletRequest request) {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
JwtDecoder decoder = jwtDecoder.getIfAvailable();
UserAccountRepository accounts = users.getIfAvailable();
if (header == null || !header.startsWith(BEARER) || decoder == null || accounts == null) return Optional.empty();
Jwt jwt;
UUID id;
try {
jwt = decoder.decode(header.substring(BEARER.length()).trim());
id = UUID.fromString(jwt.getSubject());
} catch (JwtException | IllegalArgumentException | NullPointerException e) {
log.info("Secondary link started with an unusable token ({})", e.getClass().getSimpleName());
return Optional.empty();
}
List<String> roles = Optional.ofNullable(jwt.getClaimAsStringList("roles")).orElse(List.of());
if (roles.contains(IMPERSONATION_ROLE)) {
// An admin acting as a member: their own provider account must never be linked to the member's
log.info("Secondary link started while impersonating: no session restored");
return Optional.empty();
}
return accounts.findById(id)
.filter(account -> account.getStatus() == UserAccount.Status.ACTIVE)
.map(account -> new CatapultOAuth2User(null, account, roles.contains("ROLE_ADMIN")));
}

private static void restore(HttpServletRequest request, CatapultOAuth2User user) {
HttpSession session = request.getSession(false);
if (session == null) {
request.getSession(true);
} else {
// A session that gains or changes its user gets a new id (session fixation)
request.changeSessionId();
}
SecurityContext context = new SecurityContextImpl(
UsernamePasswordAuthenticationToken.authenticated(user, null, user.getAuthorities()));
request.getSession().setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
log.info("Restored the session of user {} for a secondary link", user.getUserAccount().getId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package fr.enimaloc.catapult.security;

import fr.enimaloc.catapult.domain.account.UserAccount;
import fr.enimaloc.catapult.repository.account.UserAccountRepository;
import jakarta.servlet.FilterChain;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.security.oauth2.jwt.BadJwtException;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;

import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;

/** LinkSessionRestoreFilter: a secondary link finds its user although catapult-api's session was lost. */
class LinkSessionRestoreFilterTest {

private final JwtDecoder decoder = mock(JwtDecoder.class);
private final UserAccountRepository users = mock(UserAccountRepository.class);
private final LinkSessionRestoreFilter filter = new LinkSessionRestoreFilter(provider(decoder), provider(users));

@SuppressWarnings("unchecked")
private static <T> ObjectProvider<T> provider(T bean) {
ObjectProvider<T> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(bean);
return provider;
}

private static UserAccount account(UUID id, UserAccount.Status status) {
UserAccount account = new UserAccount();
account.setId(id);
account.setTwitchId("tw-" + id);
account.setStatus(status);
return account;
}

private static Jwt jwt(UUID subject, List<String> roles) {
return Jwt.withTokenValue("t").header("alg", "HS256").subject(subject.toString()).claim("roles", roles)
.issuedAt(Instant.now()).build();
}

private static MockHttpServletRequest linkStart(String provider, String token) {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/" + provider);
request.setRequestURI("/oauth2/authorization/" + provider);
if (token != null) request.addHeader("Authorization", "Bearer " + token);
return request;
}

private static CatapultOAuth2User sessionUser(MockHttpServletRequest request) {
if (request.getSession(false) == null) return null;
Object context = request.getSession(false)
.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
return context instanceof SecurityContext c && c.getAuthentication() != null
&& c.getAuthentication().getPrincipal() instanceof CatapultOAuth2User user ? user : null;
}

@Test
void aLostSession_getsTheUserTheTokenNames() throws Exception {
UUID id = UUID.randomUUID();
when(decoder.decode("good")).thenReturn(jwt(id, List.of("ROLE_USER", "ROLE_ADMIN")));
when(users.findById(id)).thenReturn(Optional.of(account(id, UserAccount.Status.ACTIVE)));
MockHttpServletRequest request = linkStart("battlenet", "good");
FilterChain chain = mock(FilterChain.class);
MockHttpServletResponse response = new MockHttpServletResponse();

filter.doFilter(request, response, chain);

CatapultOAuth2User user = sessionUser(request);
assertThat(user).isNotNull();
assertThat(user.getUserAccount().getId()).isEqualTo(id);
assertThat(user.getAuthorities()).extracting(GrantedAuthority::getAuthority).contains("ROLE_ADMIN");
verify(chain).doFilter(request, response);
}

@Test
void aStaleSessionOfAnotherUser_isReplaced_underANewSessionId() throws Exception {
UUID stale = UUID.randomUUID();
UUID current = UUID.randomUUID();
when(decoder.decode("good")).thenReturn(jwt(current, List.of("ROLE_USER")));
when(users.findById(current)).thenReturn(Optional.of(account(current, UserAccount.Status.ACTIVE)));
MockHttpServletRequest request = linkStart("xbox", "good");
MockHttpSession session = new MockHttpSession();
CatapultOAuth2User old = new CatapultOAuth2User(null, account(stale, UserAccount.Status.ACTIVE), false);
session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, new SecurityContextImpl(
UsernamePasswordAuthenticationToken.authenticated(old, null, old.getAuthorities())));
request.setSession(session);
String oldId = session.getId();

filter.doFilter(request, new MockHttpServletResponse(), mock(FilterChain.class));

assertThat(sessionUser(request).getUserAccount().getId()).isEqualTo(current);
assertThat(request.getSession(false).getId()).isNotEqualTo(oldId);
}

@Test
void theSessionsOwnUser_isKeptAsIs() throws Exception {
UUID id = UUID.randomUUID();
when(decoder.decode("good")).thenReturn(jwt(id, List.of("ROLE_USER")));
when(users.findById(id)).thenReturn(Optional.of(account(id, UserAccount.Status.ACTIVE)));
MockHttpServletRequest request = linkStart("steam", "good");
MockHttpSession session = new MockHttpSession();
CatapultOAuth2User own = new CatapultOAuth2User(null, account(id, UserAccount.Status.ACTIVE), false);
SecurityContext context = new SecurityContextImpl(
UsernamePasswordAuthenticationToken.authenticated(own, null, own.getAuthorities()));
session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
request.setSession(session);
String id0 = session.getId();

filter.doFilter(request, new MockHttpServletResponse(), mock(FilterChain.class));

assertThat(request.getSession(false).getId()).isEqualTo(id0);
assertThat(request.getSession(false).getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY))
.isSameAs(context);
}

@Test
void anInvalidToken_anUnknownOrInactiveAccount_orNoToken_restoreNothing() throws Exception {
UUID inactive = UUID.randomUUID();
UUID unknown = UUID.randomUUID();
when(decoder.decode("bad")).thenThrow(new BadJwtException("expired"));
when(decoder.decode("not-a-uuid")).thenReturn(
Jwt.withTokenValue("t").header("alg", "HS256").subject("42").build());
when(decoder.decode("inactive")).thenReturn(jwt(inactive, List.of("ROLE_USER")));
when(decoder.decode("unknown")).thenReturn(jwt(unknown, List.of("ROLE_USER")));
when(users.findById(inactive)).thenReturn(Optional.of(account(inactive, UserAccount.Status.INACTIVE)));
when(users.findById(unknown)).thenReturn(Optional.empty());

for (String token : new String[]{"bad", "not-a-uuid", "inactive", "unknown", null}) {
MockHttpServletRequest request = linkStart("battlenet", token);
FilterChain chain = mock(FilterChain.class);
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, chain);
assertThat(request.getSession(false)).as(String.valueOf(token)).isNull();
verify(chain).doFilter(request, response);
}
}

@Test
void anImpersonationToken_restoresNothing() throws Exception {
UUID member = UUID.randomUUID();
when(decoder.decode("impersonation")).thenReturn(
jwt(member, List.of("ROLE_USER", LinkSessionRestoreFilter.IMPERSONATION_ROLE)));
when(users.findById(member)).thenReturn(Optional.of(account(member, UserAccount.Status.ACTIVE)));
MockHttpServletRequest request = linkStart("battlenet", "impersonation");

filter.doFilter(request, new MockHttpServletResponse(), mock(FilterChain.class));

assertThat(request.getSession(false)).isNull();
}

@Test
void theTwitchLogin_andOtherPaths_areLeftAlone() throws Exception {
for (String uri : new String[]{"/oauth2/authorization/twitch", "/login/oauth2/code/battlenet", "/api/x"}) {
MockHttpServletRequest request = new MockHttpServletRequest("GET", uri);
request.addHeader("Authorization", "Bearer good");
filter.doFilter(request, new MockHttpServletResponse(), mock(FilterChain.class));
assertThat(request.getSession(false)).as(uri).isNull();
}
verifyNoInteractions(decoder);
verifyNoInteractions(users);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import fr.enimaloc.catapult.controller.ImpersonationController;
import fr.enimaloc.catapult.service.http.ApiClient;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
Expand Down Expand Up @@ -52,6 +55,7 @@ public class ApiRelayController {
"/api/me/custom-bindings", "/api/me/custom-bindings/**", "/api/me/drops", "/api/games/**",
"/api/v3/api-docs", "/api/v3/api-docs/**",
};
private static final String LINK_PREFIX = "/oauth2/authorization/";
/** A relayed request's body is read whole: these endpoints only take small ones. */
static final int MAX_BODY_BYTES = 2 * 1024 * 1024;
static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
Expand Down Expand Up @@ -139,11 +143,14 @@ HttpRequest upstream(HttpServletRequest request, byte[] body) {
.method(request.getMethod(), body.length == 0
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofByteArray(body));
String linkJwt = linkJwt(request);
for (String name : Collections.list(request.getHeaderNames())) {
String lower = name.toLowerCase(Locale.ROOT);
if (NOT_RELAYED.contains(lower) || FORWARDING.contains(lower)) continue;
if (linkJwt != null && lower.equals("authorization")) continue;
for (String value : Collections.list(request.getHeaders(name))) upstream.header(name, value);
}
if (linkJwt != null) upstream.header("Authorization", "Bearer " + linkJwt);
return upstream
.header("X-Forwarded-For", request.getRemoteAddr())
.header("X-Real-IP", request.getRemoteAddr())
Expand All @@ -153,6 +160,21 @@ HttpRequest upstream(HttpServletRequest request, byte[] body) {
.build();
}

/**
* The logged-in user's JWT when a secondary link (Steam, Xbox, Battle.net) starts: catapult-api's session, which
* its callback reads the user from, may have been lost (restart, other replica), so catapult-api restores it
* from this token (its LinkSessionRestoreFilter). Null for any other request, and while an admin impersonates a
* member: the admin's own account must never be linked to the member's.
*/
private static String linkJwt(HttpServletRequest request) {
String uri = request.getRequestURI();
if (!uri.startsWith(LINK_PREFIX) || uri.equals(LINK_PREFIX + "twitch")) return null;
if (ImpersonationController.impersonating(request)) return null;
HttpSession session = request.getSession(false);
return session != null && session.getAttribute(ApiClient.SESSION_JWT_KEY) instanceof String jwt
&& !jwt.isBlank() ? jwt : null;
}

private void unavailable(HttpServletResponse response) throws IOException {
response.setStatus(HttpStatus.SERVICE_UNAVAILABLE.value());
response.setHeader("Retry-After", "30");
Expand Down
Loading
Loading