Skip to content
Merged
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,4 @@ MINIO_SECRET_KEY=minioadmin1234
# REDIS_PORT=6379
# REDIS_PASSWORD=
# REDIS_TIMEOUT=1s
# REDIS_CONNECT_TIMEOUT=1s
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.SimpMessageType;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
Expand All @@ -16,14 +16,15 @@

import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

/**
* STOMP CONNECT 프레임의 JWT를 검증해 WebSocket 세션에 Principal을 부착한다.
* STOMP 연결 프레임의 JWT를 검증해 WebSocket 세션에 Principal을 부착한다.
*
* <p>WebSocket 자체는 그냥 양방향 파이프를 열어줄 뿐, "이 메시지가 연결 요청인지 구독인지" 같은
* 구조가 없다. STOMP는 그 파이프 위에 CONNECT·SUBSCRIBE·SEND 같은 프레임 타입을 정의해 의미
* 있는 대화를 가능하게 하는 프로토콜이고, 클라이언트는 파이프가 열리면 규격상 반드시 CONNECT
* 프레임을 제일 먼저 보내야 한다. 이 Interceptor는 그 첫 CONNECT 프레임을 가로채 "로그인한
* 있는 대화를 가능하게 하는 프로토콜이고, 클라이언트는 파이프가 열리면 규격상 반드시 CONNECT 또는
* STOMP 프레임을 제일 먼저 보내야 한다. 이 Interceptor는 그 연결 프레임을 가로채 "로그인한
* 사용자인가"를 확인하는 문지기이며, JWT가 없거나 무효하면 그 자리에서 연결을 끊는다.
*
* <p>HTTP 핸드셰이크(/ws)는 {@code SecurityConfig}에서 permitAll로 열려 있다. 네이티브
Expand All @@ -33,10 +34,15 @@
* com.opensource.docgrid.domain.dashboard.websocket.DashboardSubscriptionAuthorizationInterceptor}가
* 별도로 담당한다.
*
* <p>토큰 파싱과 {@code JwtProvider} 검증은 {@code JwtAuthenticationFilter}의 HTTP 경로와
* 같은 규칙을 그대로 재사용한다. 다만 결과를 담는 곳이 다르다 — HTTP는 요청 하나로 끝나 매번
* {@code SecurityContextHolder}를 새로 채우지만, WebSocket은 연결이 오래 유지되는 세션이라
* {@code accessor.setUser()}로 세션 자체에 Principal을 붙여 이후 모든 프레임에서 재사용한다.
* <p>토큰 파싱·서명·만료·{@code jti} 블랙리스트 검증은 {@code JwtAuthenticationFilter}의 HTTP
* 경로와 같은 인증 계약을 적용한다. 다만 Redis 장애 정책은 다르다. HTTP는 전체 API 가용성을 위해
* fail-open하지만, WebSocket은 REST 폴링으로 기능을 유지할 수 있고 장애 중 허용한 세션이 복구 뒤에도
* 남으므로 신규 연결을 fail-closed한다.
*
* <p>HTTP는 요청 하나로 끝나 매번 {@code SecurityContextHolder}를 새로 채우지만, WebSocket은 연결이
* 오래 유지되는 세션이라 {@code accessor.setUser()}로 세션 자체에 Principal을 붙여 이후 프레임에서
* 재사용한다. 이 검증은 신규 연결에만 적용되며 이미 열린 세션의 로그아웃·만료 반영은 별도 세션 수명
* 주기에서 다룬다.
*
* <p>이때 Accessor는 반드시 {@link MessageHeaderAccessor#getAccessor}로 가져와야 한다.
* {@code StompHeaderAccessor.wrap(message)}는 검증 전용 복사본이라 그 위에 {@code setUser()}를
Expand All @@ -45,27 +51,37 @@
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class StompAuthChannelInterceptor implements ChannelInterceptor {

private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
private static final String INVALID_AUTHENTICATION_MESSAGE = "유효하지 않은 인증 정보입니다.";

private final JwtProvider jwtProvider;
private final TokenBlacklistService tokenBlacklistService;
private final RoleAuthorityService roleAuthorityService;

@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

// 인증은 세션 시작 시점(CONNECT) 한 번만 하면 된다. SUBSCRIBE 등 이후 프레임은 그대로 통과시킨다.
if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
// 1. CONNECT와 STOMP는 모두 같은 연결 메시지다. SUBSCRIBE 등 이후 프레임은 그대로 통과시킨다.
if (accessor != null && SimpMessageType.CONNECT.equals(accessor.getMessageType())) {
String token = resolveToken(accessor);
// JWT가 유효해야 신원을 확인한 것으로 본다. 없거나 무효하면 여기서 바로 연결을 끊는다.
// 2. JWT의 서명·형식·만료가 유효해야 신원을 확인한 것으로 본다.
Claims claims = token == null ? null : jwtProvider.getClaimsIfValid(token);
if (claims == null) {
throw new AccessDeniedException("유효하지 않은 인증 정보입니다.");
throw invalidAuthentication();
}

// 3. 폐기할 수 없는 토큰과 로그아웃 토큰, 폐기 여부를 확인할 수 없는 연결을 거부한다.
String jti = claims.get("jti", String.class);
if (isRevokedOrUnverifiable(jti)) {
throw invalidAuthentication();
}

// 4. 거부되지 않은 토큰에 대해서만 현재 권한을 조회하고 세션 Principal을 만든다.
String email = claims.getSubject();
Long userId = claims.get("userId", Long.class);
List<String> roles = roleAuthorityService.getRoles(userId);
Expand All @@ -82,6 +98,23 @@ public Message<?> preSend(Message<?> message, MessageChannel channel) {
return message;
}

private boolean isRevokedOrUnverifiable(String jti) {
if (!StringUtils.hasText(jti)) {
return true;
}

try {
return tokenBlacklistService.isBlacklisted(jti);
} catch (RuntimeException exception) {
log.error("Redis 블랙리스트 조회 실패로 STOMP 연결을 거부합니다: {}", exception.getMessage());
return true;
}
}

private AccessDeniedException invalidAuthentication() {
return new AccessDeniedException(INVALID_AUTHENTICATION_MESSAGE);
}

private String resolveToken(StompHeaderAccessor accessor) {
String bearer = accessor.getFirstNativeHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearer) && bearer.startsWith(BEARER_PREFIX)) {
Expand Down
4 changes: 3 additions & 1 deletion backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ spring:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
# 인증 요청마다 동기 조회하므로, Redis가 응답하지 않을 때 fail-open이 빠르게 동작하도록 짧게 제한한다.
# 인증 경로의 동기 Redis 조회가 장애 시 오래 대기하지 않도록 읽기·접속 시간을 제한한다.
# HTTP 인증은 조회 실패 시 fail-open하고, REST 폴링 대체 경로가 있는 STOMP 신규 연결은 fail-closed한다.
timeout: ${REDIS_TIMEOUT:1s}
connect-timeout: ${REDIS_CONNECT_TIMEOUT:1s}
ai:
mcp:
server:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package com.opensource.docgrid.domain.auth.integration;

import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.BDDMockito.given;

import java.net.URI;
import java.security.Principal;
import java.time.Duration;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.user.SimpUser;
import org.springframework.messaging.simp.user.SimpUserRegistry;
import org.springframework.security.core.Authentication;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketHttpHeaders;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import com.opensource.docgrid.domain.auth.jwt.JwtProvider;
import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService;
import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService;

import io.jsonwebtoken.Claims;

/**
* 실제 WebSocket 서버에 raw CONNECT·STOMP 프레임을 보내 두 명령이 동일한 JWT 및 블랙리스트
* 인증 경계를 통과하는지 검증한다. Broker의 CONNECTED 응답만 보지 않고 {@link SimpUserRegistry}에
* Principal이 등록됐는지 확인해 익명 연결을 인증 성공으로 오판하지 않는다.
*/
@Tag("integration")
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DisplayName("STOMP 연결 인증 통합 테스트")
class StompConnectionAuthenticationIntegrationTest {

private static final long TIMEOUT_SECONDS = 5;
private static final String USER_EMAIL = "stomp-auth-user@example.com";

@LocalServerPort
private int port;

@Autowired
private JwtProvider jwtProvider;

@Autowired
private TokenBlacklistService tokenBlacklistService;

@Autowired
private SimpUserRegistry simpUserRegistry;

@MockitoBean
private RoleAuthorityService roleAuthorityService;

@DynamicPropertySource
static void configureJwt(DynamicPropertyRegistry registry) {
registry.add("jwt.secret", () -> "docgrid-stomp-connection-authentication-integration-test-secret");
}

@Test
@DisplayName("정상 케이스: 유효한 JWT를 담은 STOMP 명령은 인증 Principal을 세션에 등록한다")
void authenticatesStompCommand_whenTokenValid() throws Exception {
// Given
given(roleAuthorityService.getRoles(1L)).willReturn(List.of("ADMIN"));
String token = jwtProvider.generateToken(1L, USER_EMAIL);
RawStompFrameHandler handler = new RawStompFrameHandler();
WebSocketSession session = openSession(handler);

try {
// When
session.sendMessage(new TextMessage(connectFrame(StompCommand.STOMP, token)));

// Then — CONNECTED만으로는 익명 세션과 구분되지 않으므로 등록된 Principal까지 확인한다.
await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS))
.until(() -> handler.hasFrameStartingWith("CONNECTED"));
await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS)).untilAsserted(() -> {
SimpUser user = simpUserRegistry.getUser(USER_EMAIL);
assertThat(user).isNotNull();
Principal principal = user.getPrincipal();
assertThat(principal).isInstanceOf(Authentication.class);
});
} finally {
closeSession(session);
}
}

@Test
@DisplayName("예외 케이스: 토큰 없는 STOMP 명령은 연결을 거부한다")
void rejectsStompCommand_whenTokenMissing() throws Exception {
// Given
RawStompFrameHandler handler = new RawStompFrameHandler();
WebSocketSession session = openSession(handler);

try {
// When
session.sendMessage(new TextMessage(connectFrame(StompCommand.STOMP, null)));

// Then
awaitRejected(handler);
} finally {
closeSession(session);
}
}

@ParameterizedTest
@EnumSource(value = StompCommand.class, names = {"CONNECT", "STOMP"})
@DisplayName("예외 케이스: 로그아웃 토큰은 두 연결 명령에서 모두 세션을 만들지 못한다")
void rejectsConnection_whenTokenBlacklisted(StompCommand command) throws Exception {
// Given
String email = command.name().toLowerCase() + "-blacklisted@example.com";
String token = jwtProvider.generateToken(2L, email);
Claims claims = jwtProvider.getClaimsIfValid(token);
tokenBlacklistService.blacklist(claims.get("jti", String.class), 60L);
RawStompFrameHandler handler = new RawStompFrameHandler();
WebSocketSession session = openSession(handler);

try {
// When
session.sendMessage(new TextMessage(connectFrame(command, token)));

// Then
awaitRejected(handler);
assertThat(simpUserRegistry.getUser(email)).isNull();
} finally {
closeSession(session);
}
}

private WebSocketSession openSession(RawStompFrameHandler handler) throws Exception {
return new StandardWebSocketClient()
.execute(handler, new WebSocketHttpHeaders(), URI.create(webSocketUrl()))
.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
}

private void awaitRejected(RawStompFrameHandler handler) {
await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS))
.until(() -> handler.hasFrameStartingWith("ERROR") || handler.isClosed()
|| handler.getTransportError() != null);
}

private void closeSession(WebSocketSession session) throws Exception {
if (session.isOpen()) {
session.close();
}
}

private String webSocketUrl() {
return "ws://localhost:" + port + "/ws/websocket";
}

private String connectFrame(StompCommand command, String token) {
String authorization = token == null ? "" : "Authorization:Bearer " + token + "\n";
return command.name()
+ "\naccept-version:1.2\n"
+ authorization
+ "heart-beat:0,0\n\n\0";
}

/**
* 서버가 비동기로 전달하는 STOMP Text frame과 종료·전송 오류를 Thread-safe하게 수집한다.
*/
private static final class RawStompFrameHandler extends TextWebSocketHandler {

private final Queue<String> frames = new ConcurrentLinkedQueue<>();
private final AtomicReference<Throwable> transportError = new AtomicReference<>();
private volatile boolean closed;

@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
frames.add(message.getPayload());
}

@Override
public void handleTransportError(WebSocketSession session, Throwable exception) {
transportError.compareAndSet(null, exception);
}

@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
closed = true;
}

private boolean hasFrameStartingWith(String command) {
return frames.stream().anyMatch(frame -> frame.startsWith(command));
}

private Throwable getTransportError() {
return transportError.get();
}

private boolean isClosed() {
return closed;
}
}
}
Loading
Loading