Skip to content

Commit 2d4d566

Browse files
authored
Merge pull request #361 from DocGrid/fix/360
fix: 열린 STOMP 세션에 인증 상태 변경 반영
2 parents e79a57e + ebc785a commit 2d4d566

20 files changed

Lines changed: 1565 additions & 9 deletions

‎.env.example‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,6 @@ MINIO_SECRET_KEY=minioadmin1234
9393
# REDIS_PASSWORD=
9494
# REDIS_TIMEOUT=1s
9595
# REDIS_CONNECT_TIMEOUT=1s
96+
# 열린 STOMP 세션의 token 만료·blacklist·역할 변경을 다시 확인하는 주기와 일괄 조회 크기입니다.
97+
# STOMP_SESSION_REVALIDATION_INTERVAL=5s
98+
# STOMP_SESSION_REVALIDATION_BATCH_SIZE=500
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.opensource.docgrid.domain.auth.config;
2+
3+
import org.springframework.context.annotation.Configuration;
4+
import org.springframework.scheduling.annotation.EnableScheduling;
5+
6+
/**
7+
* 열린 STOMP 세션 재검증 스케줄러를 다른 도메인의 Worker·Dashboard 설정과 독립적으로 활성화한다.
8+
*
9+
* <p>{@code @EnableScheduling}을 여러 설정에서 선언해도 Spring은 하나의 scheduling infrastructure로
10+
* 처리한다. 인증 수명 검증이 다른 기능의 활성화 여부에 따라 조용히 멈추지 않도록 별도 경계를 둔다.
11+
*/
12+
@Configuration
13+
@EnableScheduling
14+
public class StompSessionSchedulingConfig {
15+
}

‎backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/StompAuthChannelInterceptor.java‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package com.opensource.docgrid.domain.auth.jwt;
22

3+
import java.time.Instant;
4+
import java.util.Date;
35
import java.util.List;
6+
import java.util.Set;
47

58
import org.springframework.messaging.Message;
69
import org.springframework.messaging.MessageChannel;
@@ -14,6 +17,9 @@
1417
import org.springframework.stereotype.Component;
1518
import org.springframework.util.StringUtils;
1619

20+
import com.opensource.docgrid.domain.auth.websocket.StompSessionAuthorization;
21+
import com.opensource.docgrid.domain.auth.websocket.StompSessionRegistry;
22+
1723
import io.jsonwebtoken.Claims;
1824
import lombok.RequiredArgsConstructor;
1925
import lombok.extern.slf4j.Slf4j;
@@ -41,8 +47,8 @@
4147
*
4248
* <p>HTTP는 요청 하나로 끝나 매번 {@code SecurityContextHolder}를 새로 채우지만, WebSocket은 연결이
4349
* 오래 유지되는 세션이라 {@code accessor.setUser()}로 세션 자체에 Principal을 붙여 이후 프레임에서
44-
* 재사용한다. 이 검증은 신규 연결에만 적용되며 이미 열린 세션의 로그아웃·만료 반영은 별도 세션 수명
45-
* 주기에서 다룬다.
50+
* 재사용한다. 연결 당시의 jti·만료 시각·role snapshot은 {@code StompSessionRegistry}에도 등록하고,
51+
* 별도 재검증 작업이 열린 세션의 로그아웃·만료·역할 변경을 확인한다.
4652
*
4753
* <p>이때 Accessor는 반드시 {@link MessageHeaderAccessor#getAccessor}로 가져와야 한다.
4854
* {@code StompHeaderAccessor.wrap(message)}는 검증 전용 복사본이라 그 위에 {@code setUser()}를
@@ -61,6 +67,7 @@ public class StompAuthChannelInterceptor implements ChannelInterceptor {
6167
private final JwtProvider jwtProvider;
6268
private final TokenBlacklistService tokenBlacklistService;
6369
private final RoleAuthorityService roleAuthorityService;
70+
private final StompSessionRegistry stompSessionRegistry;
6471

6572
@Override
6673
public Message<?> preSend(Message<?> message, MessageChannel channel) {
@@ -81,9 +88,16 @@ public Message<?> preSend(Message<?> message, MessageChannel channel) {
8188
throw invalidAuthentication();
8289
}
8390

84-
// 4. 거부되지 않은 토큰에 대해서만 현재 권한을 조회하고 세션 Principal을 만든다.
91+
// 4. 수명 검증에 필요한 식별자가 없으면 추적할 수 없는 연결이므로 거부한다.
8592
String email = claims.getSubject();
8693
Long userId = claims.get("userId", Long.class);
94+
Date expiration = claims.getExpiration();
95+
String sessionId = accessor.getSessionId();
96+
if (userId == null || expiration == null || !StringUtils.hasText(sessionId)) {
97+
throw invalidAuthentication();
98+
}
99+
100+
// 5. 거부되지 않은 토큰에 대해서만 현재 권한을 조회하고 세션 Principal을 만든다.
87101
List<String> roles = roleAuthorityService.getRoles(userId);
88102
List<SimpleGrantedAuthority> authorities = roles.stream()
89103
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
@@ -92,6 +106,17 @@ public Message<?> preSend(Message<?> message, MessageChannel channel) {
92106
UsernamePasswordAuthenticationToken authentication =
93107
new UsernamePasswordAuthenticationToken(email, null, authorities);
94108
authentication.setDetails(userId);
109+
110+
// 6. 물리 연결이 이미 추적 중일 때만 인증을 완료해 검사에서 빠지는 세션을 만들지 않는다.
111+
StompSessionAuthorization authorization = new StompSessionAuthorization(
112+
userId,
113+
jti,
114+
Instant.ofEpochMilli(expiration.getTime()),
115+
Set.copyOf(roles)
116+
);
117+
if (!stompSessionRegistry.authenticate(sessionId, authorization)) {
118+
throw invalidAuthentication();
119+
}
95120
accessor.setUser(authentication);
96121
}
97122

‎backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistService.java‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
package com.opensource.docgrid.domain.auth.jwt;
22

33
import java.time.Duration;
4+
import java.util.Collection;
5+
import java.util.HashSet;
6+
import java.util.List;
7+
import java.util.Set;
48

59
import org.springframework.data.redis.core.StringRedisTemplate;
610
import org.springframework.stereotype.Component;
711

812
import lombok.RequiredArgsConstructor;
913

14+
/**
15+
* 로그아웃한 access token의 jti를 Redis에 보관하고 단건·일괄 폐기 여부 조회를 제공한다.
16+
*
17+
* <p>HTTP 인증은 단건 조회를 사용하고, 열린 STOMP 세션 재검증은 네트워크 왕복이 세션 수만큼
18+
* 늘지 않도록 MGET 기반 일괄 조회를 사용한다. Redis 장애에 대한 fail-open·fail-closed 결정은 각
19+
* 호출 경로가 자신의 가용성 요구에 맞게 담당한다.
20+
*/
1021
@Component
1122
@RequiredArgsConstructor
1223
public class TokenBlacklistService {
@@ -22,4 +33,34 @@ public void blacklist(String jti, long ttlSeconds) {
2233
public boolean isBlacklisted(String jti) {
2334
return Boolean.TRUE.equals(redisTemplate.hasKey(KEY_PREFIX + jti));
2435
}
36+
37+
/**
38+
* 여러 STOMP 세션의 token 폐기 상태를 Redis MGET 한 번으로 확인한다.
39+
*
40+
* <p>응답 누락을 정상 token으로 오인하면 기존 연결이 계속 살아남으므로, Redis가 요청 key와 같은
41+
* 개수의 결과를 주지 않으면 검증 실패로 처리한다. 호출자는 WebSocket fail-closed 정책에 따라
42+
* 검사 대상 세션을 종료한다.
43+
*/
44+
public Set<String> findBlacklistedJtis(Collection<String> jtis) {
45+
List<String> distinctJtis = jtis.stream().distinct().toList();
46+
if (distinctJtis.isEmpty()) {
47+
return Set.of();
48+
}
49+
50+
List<String> keys = distinctJtis.stream()
51+
.map(jti -> KEY_PREFIX + jti)
52+
.toList();
53+
List<String> values = redisTemplate.opsForValue().multiGet(keys);
54+
if (values == null || values.size() != keys.size()) {
55+
throw new IllegalStateException("Redis blacklist 일괄 조회 결과가 완전하지 않습니다.");
56+
}
57+
58+
Set<String> blacklisted = new HashSet<>();
59+
for (int index = 0; index < distinctJtis.size(); index++) {
60+
if (values.get(index) != null) {
61+
blacklisted.add(distinctJtis.get(index));
62+
}
63+
}
64+
return Set.copyOf(blacklisted);
65+
}
2566
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.opensource.docgrid.domain.auth.websocket;
2+
3+
import java.time.Instant;
4+
import java.util.Objects;
5+
import java.util.Set;
6+
7+
/**
8+
* STOMP CONNECT가 성공한 시점의 token 식별자·만료 시각·역할 snapshot을 세션 수명 검증에 전달한다.
9+
*
10+
* <p>JWT 원문과 이메일은 보관하지 않는다. 역할은 순서와 중복에 영향을 받지 않도록 불변 Set으로
11+
* 정규화하며, 이후 검사에서 Redis blacklist와 DB의 현재 역할을 이 snapshot과 비교한다.
12+
*/
13+
public record StompSessionAuthorization(
14+
Long userId,
15+
String jti,
16+
Instant expiresAt,
17+
Set<String> roles
18+
) {
19+
20+
public StompSessionAuthorization {
21+
Objects.requireNonNull(userId, "userId는 필수입니다.");
22+
Objects.requireNonNull(jti, "jti는 필수입니다.");
23+
Objects.requireNonNull(expiresAt, "expiresAt은 필수입니다.");
24+
roles = Set.copyOf(Objects.requireNonNull(roles, "roles는 필수입니다."));
25+
}
26+
27+
public boolean isExpired(Instant now) {
28+
return !now.isBefore(expiresAt);
29+
}
30+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package com.opensource.docgrid.domain.auth.websocket;
2+
3+
import java.io.IOException;
4+
import java.util.ArrayList;
5+
import java.util.List;
6+
import java.util.concurrent.ConcurrentHashMap;
7+
import java.util.concurrent.ConcurrentMap;
8+
9+
import org.springframework.stereotype.Component;
10+
import org.springframework.web.socket.CloseStatus;
11+
import org.springframework.web.socket.WebSocketSession;
12+
13+
import lombok.extern.slf4j.Slf4j;
14+
15+
/**
16+
* 현재 Backend 인스턴스가 소유한 물리 WebSocket 연결과 STOMP 인증 snapshot을 함께 관리한다.
17+
*
18+
* <p>물리 연결은 WebSocket decorator가 먼저 등록하고, CONNECT 인증이 끝난 뒤 같은 sessionId에
19+
* 인증 snapshot을 결합한다. 주기 검사는 인증 완료 세션만 읽으며, 종료와 인증이 경합해도 하나의
20+
* ConcurrentMap entry를 기준으로 정리해 닫힌 연결이 다시 등록되는 것을 막는다.
21+
*
22+
* <p>이 registry는 로컬 전송 자원만 관리한다. 여러 Backend 인스턴스는 각자 자신의 registry를
23+
* 검사하고 공통 Redis·DB 상태를 읽으므로 분산 session registry나 lock이 필요하지 않다.
24+
*/
25+
@Component
26+
@Slf4j
27+
public class StompSessionRegistry {
28+
29+
private static final CloseStatus AUTHORIZATION_INVALID = CloseStatus.POLICY_VIOLATION;
30+
31+
private final ConcurrentMap<String, SessionState> sessions = new ConcurrentHashMap<>();
32+
33+
public void registerTransport(WebSocketSession session) {
34+
SessionState previous = sessions.putIfAbsent(session.getId(), new SessionState(session));
35+
if (previous != null) {
36+
throw new IllegalStateException("이미 등록된 WebSocket sessionId입니다.");
37+
}
38+
}
39+
40+
public boolean authenticate(String sessionId, StompSessionAuthorization authorization) {
41+
return sessions.computeIfPresent(sessionId, (ignored, state) -> {
42+
state.authenticate(authorization);
43+
return state;
44+
}) != null;
45+
}
46+
47+
public void remove(String sessionId) {
48+
sessions.remove(sessionId);
49+
}
50+
51+
public List<SessionSnapshot> authenticatedSessions() {
52+
List<SessionSnapshot> snapshots = new ArrayList<>();
53+
sessions.forEach((sessionId, state) -> {
54+
if (!state.session().isOpen()) {
55+
sessions.remove(sessionId, state);
56+
return;
57+
}
58+
StompSessionAuthorization authorization = state.authorization();
59+
if (authorization != null) {
60+
snapshots.add(new SessionSnapshot(sessionId, authorization));
61+
}
62+
});
63+
return List.copyOf(snapshots);
64+
}
65+
66+
public int authenticatedSessionCount() {
67+
return (int) sessions.values().stream()
68+
.filter(state -> state.session().isOpen() && state.authorization() != null)
69+
.count();
70+
}
71+
72+
public boolean close(String sessionId) {
73+
SessionState state = sessions.get(sessionId);
74+
if (state == null) {
75+
return false;
76+
}
77+
if (!state.session().isOpen()) {
78+
sessions.remove(sessionId, state);
79+
return false;
80+
}
81+
82+
try {
83+
state.session().close(AUTHORIZATION_INVALID);
84+
sessions.remove(sessionId, state);
85+
return true;
86+
} catch (IOException | RuntimeException exception) {
87+
// 추적 정보를 남겨 다음 검사에서 다시 닫을 수 있게 한다. 식별 정보는 로그에 노출하지 않는다.
88+
log.warn("유효하지 않은 STOMP WebSocket 세션 종료에 실패했습니다: {}", exception.getMessage());
89+
return false;
90+
}
91+
}
92+
93+
/** 주기 검사에 필요한 sessionId와 불변 인증 snapshot만 노출하는 조회 경계다. */
94+
public record SessionSnapshot(String sessionId, StompSessionAuthorization authorization) {
95+
}
96+
97+
/** 물리 연결과 CONNECT 이후 추가되는 인증 snapshot을 하나의 map entry에 보관한다. */
98+
private static final class SessionState {
99+
100+
private final WebSocketSession session;
101+
private volatile StompSessionAuthorization authorization;
102+
103+
private SessionState(WebSocketSession session) {
104+
this.session = session;
105+
}
106+
107+
private WebSocketSession session() {
108+
return session;
109+
}
110+
111+
private StompSessionAuthorization authorization() {
112+
return authorization;
113+
}
114+
115+
private void authenticate(StompSessionAuthorization authorization) {
116+
this.authorization = authorization;
117+
}
118+
}
119+
}

0 commit comments

Comments
 (0)