From bd91c41a5f6fbf18731e901017b9aae3e389b927 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sat, 15 Aug 2026 14:09:07 +0900 Subject: [PATCH 1/8] =?UTF-8?q?chore:=20Redis=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20docker-compose=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그아웃 토큰 블랙리스트 저장소로 Redis를 쓰기 위한 인프라 설정. spring-boot-starter-data-redis 의존성, docker-compose redis 서비스, application.yml/.env.example 접속 설정을 추가한다. --- .env.example | 6 ++++++ backend/build.gradle | 1 + backend/src/main/resources/application.yml | 5 +++++ docker-compose.yml | 16 ++++++++++++++++ 4 files changed, 28 insertions(+) diff --git a/.env.example b/.env.example index cdddbe2..9ffba54 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,9 @@ MINIO_BUCKET=docgrid # Ollama RAG LLM 서버 - application.yml에 이미 기본값(http://localhost:11434)이 있어 docker-compose 기본 포트를 쓰면 설정 불필요. # 기본값과 다른 포트/호스트를 쓸 때만 주석 해제 # OLLAMA_SERVER_URL=http://localhost:11434 + +# Redis (로그아웃 토큰 블랙리스트) - application.yml에 이미 기본값(localhost:6379, 인증 없음)이 있어 +# docker-compose 기본 포트를 쓰면 설정 불필요. 기본값과 다를 때만 주석 해제 +# REDIS_HOST=localhost +# REDIS_PORT=6379 +# REDIS_PASSWORD= diff --git a/backend/build.gradle b/backend/build.gradle index 2408ab1..d779a45 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -29,6 +29,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' implementation 'io.minio:minio:8.5.17' diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 8313075..cf8e3c3 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -13,6 +13,11 @@ spring: # 전역 OSIV를 끄고, WebMvcConfig에서 /mcp를 제외한 나머지 경로에만 다시 등록한다. # /mcp는 MCP Streamable HTTP 응답 처리 방식과 OSIV가 충돌해 DB 커넥션이 누수됐다(#120). open-in-view: false + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} ai: mcp: server: diff --git a/docker-compose.yml b/docker-compose.yml index d3a73b3..8609e4d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,21 @@ services: networks: - docgrid-local + redis: + image: redis:7-alpine + container_name: docgrid-redis + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - docgrid-local + networks: docgrid-local: @@ -83,3 +98,4 @@ volumes: minio-data: huggingface-cache: ollama-data: + redis-data: From 2963b66158daa33d4a57df12b1aa003252c79f87 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sat, 15 Aug 2026 14:09:17 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20JWT=20jti=20claim=20=EB=B0=8F=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EB=B8=94=EB=9E=99=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발급되는 모든 JWT에 jti(고유 식별자) claim을 추가하고, filter/controller가 공유할 수 있도록 Authorization 헤더 파싱 로직을 JwtProvider로 옮긴다. Redis에 jti를 TTL과 함께 저장/조회하는 TokenBlacklistService를 추가한다. --- .../docgrid/domain/auth/jwt/JwtProvider.java | 12 +++++++++ .../auth/jwt/TokenBlacklistService.java | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistService.java diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtProvider.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtProvider.java index 1b1ffa8..23608bf 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtProvider.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtProvider.java @@ -3,16 +3,19 @@ import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.List; +import java.util.UUID; import javax.crypto.SecretKey; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -37,12 +40,21 @@ public String generateToken(Long userId, String email, List roles) { .subject(email) .claim("userId", userId) .claim("roles", roles) + .claim("jti", UUID.randomUUID().toString()) .issuedAt(now) .expiration(expiry) .signWith(secretKey) .compact(); } + public static String resolveToken(HttpServletRequest request) { + String bearer = request.getHeader("Authorization"); + if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { + return bearer.substring(7); + } + return null; + } + public Claims getClaimsIfValid(String token) { try { return Jwts.parser() diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistService.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistService.java new file mode 100644 index 0000000..a47134f --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistService.java @@ -0,0 +1,25 @@ +package com.opensource.docgrid.domain.auth.jwt; + +import java.time.Duration; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class TokenBlacklistService { + + private static final String KEY_PREFIX = "auth:blacklist:"; + + private final StringRedisTemplate redisTemplate; + + public void blacklist(String jti, long ttlSeconds) { + redisTemplate.opsForValue().set(KEY_PREFIX + jti, "1", Duration.ofSeconds(ttlSeconds)); + } + + public boolean isBlacklisted(String jti) { + return Boolean.TRUE.equals(redisTemplate.hasKey(KEY_PREFIX + jti)); + } +} From ec30810ba02e66771501d1774d3b21d20d0f8cee Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sat, 15 Aug 2026 14:09:27 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20API=20=EB=B0=8F=20=EC=9D=B8=EC=A6=9D=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EB=B8=94=EB=9E=99=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /auth/logout에서 현재 토큰의 jti를 잔여 만료 시간만큼 블랙리스트에 등록한다. JwtAuthenticationFilter는 매 요청마다 jti 블랙리스트 여부를 확인하며, Redis 조회가 실패하면 인증을 막지 않고 계속 진행한다(fail-open). --- .../auth/controller/AuthController.java | 9 +++++++++ .../auth/jwt/JwtAuthenticationFilter.java | 18 +++++++++++------- .../service/command/AuthCommandService.java | 19 +++++++++++++++++++ .../docgrid/global/config/SecurityConfig.java | 4 +++- 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/controller/AuthController.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/controller/AuthController.java index 547358f..2e42de0 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/auth/controller/AuthController.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/controller/AuthController.java @@ -13,6 +13,7 @@ import com.opensource.docgrid.domain.auth.dto.response.LoginResponse; import com.opensource.docgrid.domain.auth.dto.response.MeResponse; import com.opensource.docgrid.domain.auth.dto.response.SignupResponse; +import com.opensource.docgrid.domain.auth.jwt.JwtProvider; import com.opensource.docgrid.domain.auth.service.command.AuthCommandService; import com.opensource.docgrid.domain.auth.service.query.AuthQueryService; import com.opensource.docgrid.global.common.response.ApiResponse; @@ -21,6 +22,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -50,4 +52,11 @@ public ResponseEntity> login(@RequestBody @Valid Logi public ResponseEntity> getMe(@Parameter(hidden = true) @CurrentUser Long userId) { return ResponseUtils.ok(authQueryService.getMe(userId)); } + + @Operation(summary = "로그아웃", description = "현재 사용 중인 액세스 토큰을 무효화합니다. 무효화된 토큰은 만료 전이라도 이후 요청에 사용할 수 없습니다. Authorization: Bearer {token} 헤더가 필요합니다.") + @PostMapping("/logout") + public ResponseEntity> logout(HttpServletRequest request) { + authCommandService.logout(JwtProvider.resolveToken(request)); + return ResponseUtils.noContent(); + } } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilter.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilter.java index d1ffbf4..3b29d2c 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilter.java @@ -15,22 +15,25 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtProvider jwtProvider; + private final TokenBlacklistService tokenBlacklistService; @Override @SuppressWarnings("unchecked") protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String token = resolveToken(request); + String token = JwtProvider.resolveToken(request); if (StringUtils.hasText(token)) { Claims claims = jwtProvider.getClaimsIfValid(token); - if (claims != null) { + if (claims != null && !isBlacklisted(claims.get("jti", String.class))) { Long userId = claims.get("userId", Long.class); String email = claims.getSubject(); List roles = (List) claims.get("roles"); @@ -50,11 +53,12 @@ protected void doFilterInternal(HttpServletRequest request, filterChain.doFilter(request, response); } - private String resolveToken(HttpServletRequest request) { - String bearer = request.getHeader("Authorization"); - if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { - return bearer.substring(7); + private boolean isBlacklisted(String jti) { + try { + return tokenBlacklistService.isBlacklisted(jti); + } catch (Exception e) { + log.error("Redis 블랙리스트 조회 실패, 인증을 계속 진행합니다: {}", e.getMessage()); + return false; } - return null; } } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandService.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandService.java index 5fb8c85..30ac43a 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandService.java @@ -1,5 +1,7 @@ package com.opensource.docgrid.domain.auth.service.command; +import java.time.Duration; +import java.time.Instant; import java.time.LocalDateTime; import java.util.List; @@ -12,6 +14,7 @@ import com.opensource.docgrid.domain.auth.dto.response.LoginResponse; import com.opensource.docgrid.domain.auth.dto.response.SignupResponse; import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; import com.opensource.docgrid.domain.user.entity.Department; import com.opensource.docgrid.domain.user.entity.Role; import com.opensource.docgrid.domain.user.entity.User; @@ -25,6 +28,7 @@ import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; +import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -40,6 +44,7 @@ public class AuthCommandService { private final UserRoleRepository userRoleRepository; private final PasswordEncoder passwordEncoder; private final JwtProvider jwtProvider; + private final TokenBlacklistService tokenBlacklistService; public SignupResponse signup(SignupRequest request) { if (userRepository.existsByEmail(request.email())) { @@ -97,4 +102,18 @@ public LoginResponse login(LoginRequest request) { return LoginResponse.of(token, jwtProvider.getExpirationSeconds(), user.getId(), user.getEmail(), roles); } + + public void logout(String token) { + Claims claims = jwtProvider.getClaimsIfValid(token); + if (claims == null) { + return; + } + + String jti = claims.get("jti", String.class); + long remainingSeconds = Duration.between(Instant.now(), claims.getExpiration().toInstant()).getSeconds(); + + if (remainingSeconds > 0) { + tokenBlacklistService.blacklist(jti, remainingSeconds); + } + } } diff --git a/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java b/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java index c2e522c..501cdfc 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java @@ -14,6 +14,7 @@ import com.opensource.docgrid.domain.auth.jwt.JwtAuthenticationFilter; import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; import com.opensource.docgrid.domain.mcp.security.McpApiKeyAuthFilter; import com.opensource.docgrid.domain.mcp.service.command.McpAccessTokenCommandService; @@ -26,6 +27,7 @@ public class SecurityConfig { private final CorsConfigurationSource corsConfigurationSource; private final JwtProvider jwtProvider; + private final TokenBlacklistService tokenBlacklistService; private final McpAccessTokenCommandService mcpAccessTokenCommandService; @Bean @@ -55,7 +57,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { * - JwtAuthenticationFilter → 웹 로그인(JWT), /mcp/tokens 등 일반 API 담당 * - McpApiKeyAuthFilter → Claude Desktop API 키, /mcp 경로만 담당 */ - .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new JwtAuthenticationFilter(jwtProvider, tokenBlacklistService), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(new McpApiKeyAuthFilter(mcpAccessTokenCommandService), UsernamePasswordAuthenticationFilter.class); return http.build(); } From 896cd5021141e6c79c2fcb72120f906867ee5ed1 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sat, 15 Aug 2026 14:09:39 +0900 Subject: [PATCH 4/8] =?UTF-8?q?test:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EA=B8=B0=EB=8A=A5=20=EB=8B=A8=EC=9C=84/=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthCommandService.logout, TokenBlacklistService, JwtAuthenticationFilter의 블랙리스트 검증(정상/차단/Redis 장애 fail-open)에 대한 단위 테스트와, 실제 Postgres·Redis로 로그인→로그아웃→블랙리스트 등록을 확인하는 통합 테스트를 추가한다. SecurityConfig가 TokenBlacklistService를 요구하게 되면서 깨진 기존 @WebMvcTest 5개에 mock bean을 추가해 복구한다. --- .../AuthLogoutIntegrationTest.java | 64 ++++++++++++++ .../auth/jwt/JwtAuthenticationFilterTest.java | 83 +++++++++++++++++++ .../auth/jwt/TokenBlacklistServiceTest.java | 56 +++++++++++++ .../command/AuthCommandServiceTest.java | 39 +++++++++ .../IndexingJobAdminControllerTest.java | 2 + .../IndexingJobAdminQueryControllerTest.java | 2 + .../controller/SyncAdminControllerTest.java | 2 + .../controller/AdminUserControllerTest.java | 2 + .../controller/WorkerAdminControllerTest.java | 4 + 9 files changed, 254 insertions(+) create mode 100644 backend/src/test/java/com/opensource/docgrid/domain/auth/integration/AuthLogoutIntegrationTest.java create mode 100644 backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilterTest.java create mode 100644 backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistServiceTest.java diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/AuthLogoutIntegrationTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/AuthLogoutIntegrationTest.java new file mode 100644 index 0000000..d144884 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/AuthLogoutIntegrationTest.java @@ -0,0 +1,64 @@ +package com.opensource.docgrid.domain.auth.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import com.opensource.docgrid.domain.auth.dto.request.LoginRequest; +import com.opensource.docgrid.domain.auth.dto.request.SignupRequest; +import com.opensource.docgrid.domain.auth.dto.response.LoginResponse; +import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; +import com.opensource.docgrid.domain.auth.service.command.AuthCommandService; +import com.opensource.docgrid.domain.user.repository.DepartmentRepository; + +import io.jsonwebtoken.Claims; + +/** + * 실제 PostgreSQL·Redis에서 로그인한 토큰이 로그아웃 이후 블랙리스트에 등록되어 + * 더 이상 인증에 쓰일 수 없는 상태가 되는지 검증한다. + */ +@Tag("integration") +@ActiveProfiles("test") +@SpringBootTest +@DisplayName("로그아웃 PostgreSQL·Redis 통합 테스트") +class AuthLogoutIntegrationTest { + + @Autowired + private AuthCommandService authCommandService; + + @Autowired + private TokenBlacklistService tokenBlacklistService; + + @Autowired + private JwtProvider jwtProvider; + + @Autowired + private DepartmentRepository departmentRepository; + + @Test + @DisplayName("로그인 후 로그아웃하면 발급된 토큰의 jti가 블랙리스트에 등록된다") + void logout_blacklistsIssuedToken() { + Long departmentId = departmentRepository.findAll().get(0).getId(); + String email = "logout-integration-" + UUID.randomUUID() + "@test.com"; + authCommandService.signup(new SignupRequest(email, "password1234", "로그아웃통합테스트", departmentId)); + + LoginResponse loginResponse = authCommandService.login(new LoginRequest(email, "password1234")); + String token = loginResponse.accessToken(); + Claims claims = jwtProvider.getClaimsIfValid(token); + String jti = claims.get("jti", String.class); + + assertThat(tokenBlacklistService.isBlacklisted(jti)).isFalse(); + + authCommandService.logout(token); + + assertThat(tokenBlacklistService.isBlacklisted(jti)).isTrue(); + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilterTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilterTest.java new file mode 100644 index 0000000..aa8a2e1 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilterTest.java @@ -0,0 +1,83 @@ +package com.opensource.docgrid.domain.auth.jwt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; + +@ExtendWith(MockitoExtension.class) +@DisplayName("JwtAuthenticationFilter 단위 테스트") +class JwtAuthenticationFilterTest { + + private static final String TEST_SECRET = "test-secret-key-for-jwt-authentication-filter-unit-test"; + + @Mock + private TokenBlacklistService tokenBlacklistService; + + private JwtProvider jwtProvider; + private JwtAuthenticationFilter filter; + + @BeforeEach + void setUp() { + jwtProvider = new JwtProvider(TEST_SECRET, 3600L); + filter = new JwtAuthenticationFilter(jwtProvider, tokenBlacklistService); + SecurityContextHolder.clearContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("유효하고 블랙리스트에 없는 토큰이면 인증에 성공한다") + void doFilter_authenticates_whenTokenValidAndNotBlacklisted() throws Exception { + String token = jwtProvider.generateToken(1L, "user@test.com", List.of("USER")); + given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false); + + filter.doFilter(requestWithToken(token), new MockHttpServletResponse(), new MockFilterChain()); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); + } + + @Test + @DisplayName("블랙리스트에 등록된 토큰이면 인증하지 않는다") + void doFilter_doesNotAuthenticate_whenTokenBlacklisted() throws Exception { + String token = jwtProvider.generateToken(1L, "user@test.com", List.of("USER")); + given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(true); + + filter.doFilter(requestWithToken(token), new MockHttpServletResponse(), new MockFilterChain()); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + @DisplayName("블랙리스트 조회가 실패해도(Redis 장애) 인증은 계속 진행된다") + void doFilter_authenticates_whenBlacklistCheckFails() throws Exception { + String token = jwtProvider.generateToken(1L, "user@test.com", List.of("USER")); + given(tokenBlacklistService.isBlacklisted(anyString())).willThrow(new RuntimeException("redis down")); + + filter.doFilter(requestWithToken(token), new MockHttpServletResponse(), new MockFilterChain()); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); + } + + private MockHttpServletRequest requestWithToken(String token) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + token); + return request; + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistServiceTest.java new file mode 100644 index 0000000..10b3ba4 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/TokenBlacklistServiceTest.java @@ -0,0 +1,56 @@ +package com.opensource.docgrid.domain.auth.jwt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import java.time.Duration; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TokenBlacklistService 단위 테스트") +class TokenBlacklistServiceTest { + + @InjectMocks + private TokenBlacklistService tokenBlacklistService; + + @Mock + private StringRedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @Test + @DisplayName("blacklist 호출 시 jti를 남은 만료 시간만큼 TTL로 저장한다") + void blacklist_setsKeyWithTtl() { + given(redisTemplate.opsForValue()).willReturn(valueOperations); + + tokenBlacklistService.blacklist("test-jti", 600L); + + then(valueOperations).should().set("auth:blacklist:test-jti", "1", Duration.ofSeconds(600)); + } + + @Test + @DisplayName("블랙리스트에 등록된 jti는 isBlacklisted가 true를 반환한다") + void isBlacklisted_returnsTrue_whenKeyExists() { + given(redisTemplate.hasKey("auth:blacklist:test-jti")).willReturn(true); + + assertThat(tokenBlacklistService.isBlacklisted("test-jti")).isTrue(); + } + + @Test + @DisplayName("블랙리스트에 없는 jti는 isBlacklisted가 false를 반환한다") + void isBlacklisted_returnsFalse_whenKeyMissing() { + given(redisTemplate.hasKey("auth:blacklist:test-jti")).willReturn(false); + + assertThat(tokenBlacklistService.isBlacklisted("test-jti")).isFalse(); + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandServiceTest.java index cd65437..68cdfb8 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandServiceTest.java @@ -4,9 +4,14 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.longThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import java.time.Instant; +import java.util.Date; import java.util.List; import java.util.Optional; @@ -24,6 +29,7 @@ import com.opensource.docgrid.domain.auth.dto.response.SignupResponse; import com.opensource.docgrid.domain.auth.fixture.AuthFixture; import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; import com.opensource.docgrid.domain.user.entity.Department; import com.opensource.docgrid.domain.user.entity.Role; import com.opensource.docgrid.domain.user.entity.User; @@ -36,6 +42,8 @@ import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; +import io.jsonwebtoken.Claims; + @ExtendWith(MockitoExtension.class) @DisplayName("AuthCommandService 단위 테스트") class AuthCommandServiceTest { @@ -61,6 +69,9 @@ class AuthCommandServiceTest { @Mock private JwtProvider jwtProvider; + @Mock + private TokenBlacklistService tokenBlacklistService; + // ==================== signup ==================== @Test @@ -199,4 +210,32 @@ void login_throws_when_accountInactive() { .isInstanceOf(DocGridException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ACCOUNT_INACTIVE); } + + // ==================== logout ==================== + + @Test + @DisplayName("유효한 토큰으로 로그아웃하면 잔여 만료 시간만큼 블랙리스트에 등록한다") + void logout_blacklistsToken_whenTokenValid() { + String token = "access-token"; + String jti = "test-jti"; + Claims claims = mock(Claims.class); + given(jwtProvider.getClaimsIfValid(token)).willReturn(claims); + given(claims.get("jti", String.class)).willReturn(jti); + given(claims.getExpiration()).willReturn(Date.from(Instant.now().plusSeconds(600))); + + authCommandService.logout(token); + + then(tokenBlacklistService).should().blacklist(eq(jti), longThat(ttl -> ttl > 0 && ttl <= 600)); + } + + @Test + @DisplayName("유효하지 않은 토큰으로 로그아웃하면 블랙리스트에 등록하지 않는다") + void logout_doesNothing_whenTokenInvalid() { + String token = "invalid-token"; + given(jwtProvider.getClaimsIfValid(token)).willReturn(null); + + authCommandService.logout(token); + + then(tokenBlacklistService).shouldHaveNoInteractions(); + } } diff --git a/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminControllerTest.java index 85cc066..8ffc76f 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminControllerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminControllerTest.java @@ -27,6 +27,7 @@ import org.springframework.web.cors.CorsConfigurationSource; import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; import com.opensource.docgrid.domain.mcp.service.command.McpAccessTokenCommandService; import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; import com.opensource.docgrid.domain.document.service.DocumentParsingService; @@ -114,6 +115,7 @@ class IndexingJobAdminControllerTest { @MockitoBean private IndexingJobAdminQueryService indexingJobAdminQueryService; @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; @MockitoBean private JwtProvider jwtProvider; + @MockitoBean private TokenBlacklistService tokenBlacklistService; @MockitoBean private McpAccessTokenCommandService mcpAccessTokenCommandService; @MockitoBean private CorsConfigurationSource corsConfigurationSource; diff --git a/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminQueryControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminQueryControllerTest.java index e89a362..2f65160 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminQueryControllerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminQueryControllerTest.java @@ -24,6 +24,7 @@ import org.springframework.web.cors.CorsConfigurationSource; import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; import com.opensource.docgrid.domain.document.service.DocumentParsingService; import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingEventResponse; @@ -72,6 +73,7 @@ class IndexingJobAdminQueryControllerTest { @MockitoBean private EmbeddingJobManualRetryService embeddingJobManualRetryService; @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; @MockitoBean private JwtProvider jwtProvider; + @MockitoBean private TokenBlacklistService tokenBlacklistService; @MockitoBean private McpAccessTokenCommandService mcpAccessTokenCommandService; @MockitoBean private CorsConfigurationSource corsConfigurationSource; diff --git a/backend/src/test/java/com/opensource/docgrid/domain/sync/controller/SyncAdminControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/sync/controller/SyncAdminControllerTest.java index f8f2e5f..7de2807 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/sync/controller/SyncAdminControllerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/sync/controller/SyncAdminControllerTest.java @@ -25,6 +25,7 @@ import org.springframework.web.cors.CorsConfigurationSource; import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; import com.opensource.docgrid.domain.mcp.service.command.McpAccessTokenCommandService; import com.opensource.docgrid.domain.sync.dto.response.SyncAdminSummaryResponse; import com.opensource.docgrid.domain.sync.dto.response.SyncEventSummaryResponse; @@ -49,6 +50,7 @@ class SyncAdminControllerTest { @MockitoBean private SyncAdminCommandService syncAdminCommandService; @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; @MockitoBean private JwtProvider jwtProvider; + @MockitoBean private TokenBlacklistService tokenBlacklistService; @MockitoBean private McpAccessTokenCommandService mcpAccessTokenCommandService; @MockitoBean private CorsConfigurationSource corsConfigurationSource; diff --git a/backend/src/test/java/com/opensource/docgrid/domain/user/controller/AdminUserControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/user/controller/AdminUserControllerTest.java index 17e4760..d7ec162 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/user/controller/AdminUserControllerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/user/controller/AdminUserControllerTest.java @@ -20,6 +20,7 @@ import org.springframework.web.cors.CorsConfigurationSource; import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; import com.opensource.docgrid.domain.mcp.service.command.McpAccessTokenCommandService; import com.opensource.docgrid.domain.user.dto.response.AdminUserResponse; import com.opensource.docgrid.domain.user.enums.UserStatus; @@ -44,6 +45,7 @@ class AdminUserControllerTest { @MockitoBean private AdminUserQueryService adminUserQueryService; @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; @MockitoBean private JwtProvider jwtProvider; + @MockitoBean private TokenBlacklistService tokenBlacklistService; @MockitoBean private McpAccessTokenCommandService mcpAccessTokenCommandService; @MockitoBean private CorsConfigurationSource corsConfigurationSource; diff --git a/backend/src/test/java/com/opensource/docgrid/domain/worker/controller/WorkerAdminControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/worker/controller/WorkerAdminControllerTest.java index 1ea10d4..dc4b885 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/worker/controller/WorkerAdminControllerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/worker/controller/WorkerAdminControllerTest.java @@ -24,6 +24,7 @@ import com.opensource.docgrid.domain.worker.fixture.WorkerNodeFixture; import com.opensource.docgrid.domain.worker.service.query.WorkerNodeQueryService; import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; import com.opensource.docgrid.domain.mcp.service.command.McpAccessTokenCommandService; import com.opensource.docgrid.global.config.SecurityConfig; @@ -46,6 +47,9 @@ class WorkerAdminControllerTest { @MockitoBean private JwtProvider jwtProvider; + @MockitoBean + private TokenBlacklistService tokenBlacklistService; + @MockitoBean private McpAccessTokenCommandService mcpAccessTokenCommandService; From 3a212bcbe0e402853d3619b765c51dec3af7dbc0 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sat, 15 Aug 2026 14:09:46 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=A0=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthProvider에 signOut()을 추가해 사용자가 명시적으로 로그아웃할 때 POST /auth/logout을 먼저 호출한다. 백엔드 호출이 실패해도 클라이언트 세션(sessionStorage 토큰)은 항상 정리된다. 토큰이 이미 무효인 자동 정리 경로(refresh 실패, AUTH_EXPIRED_EVENT)는 기존 logout()을 그대로 쓴다. --- frontend/app/components/AuthProvider.tsx | 16 +++++++++++++++- frontend/app/features/AccountPage.tsx | 8 ++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/frontend/app/components/AuthProvider.tsx b/frontend/app/components/AuthProvider.tsx index ea207ea..20e5e04 100644 --- a/frontend/app/components/AuthProvider.tsx +++ b/frontend/app/components/AuthProvider.tsx @@ -9,6 +9,7 @@ type AuthContextValue = { loading: boolean; login: (email: string, password: string) => Promise; logout: () => void; + signOut: () => Promise; refresh: () => Promise; }; @@ -23,6 +24,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setUser(null); }, []); + const signOut = useCallback(async () => { + try { + await apiRequest("/auth/logout", { method: "POST" }); + } catch { + // 백엔드 로그아웃이 실패해도 클라이언트 세션은 항상 정리한다. + } finally { + logout(); + } + }, [logout]); + const refresh = useCallback(async () => { const token = window.sessionStorage.getItem(ACCESS_TOKEN_KEY); if (!token) { @@ -66,7 +77,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return me; }, []); - const value = useMemo(() => ({ user, loading, login, logout, refresh }), [user, loading, login, logout, refresh]); + const value = useMemo( + () => ({ user, loading, login, logout, signOut, refresh }), + [user, loading, login, logout, signOut, refresh], + ); return {children}; } diff --git a/frontend/app/features/AccountPage.tsx b/frontend/app/features/AccountPage.tsx index 57a3f24..a26ed66 100644 --- a/frontend/app/features/AccountPage.tsx +++ b/frontend/app/features/AccountPage.tsx @@ -4,13 +4,13 @@ import { useAuth } from "../components/AuthProvider"; import { PageHeading, StatusPill, formatDate, initials } from "../components/ui"; export function AccountPage() { - const { user, logout } = useAuth(); + const { user, signOut } = useAuth(); if (!user) return null; - function signOut() { - logout(); + async function handleSignOut() { + await signOut(); window.location.replace("/login"); } - return
로그아웃} />
{initials(user.name)}

{user.name}

{user.email}
{user.roles.map((role) => )}
상태
{user.status}
사용자 ID
#{user.userId}

계정 정보

/auth/me 응답

이름
{user.name}
닉네임
{user.nickname ?? "—"}
부서
{user.departmentName ?? "—"}
부서 ID
{user.departmentId ?? "—"}
가입 시각
{formatDate(user.createdAt)}
마지막 로그인
{formatDate(user.lastLoginAt)}

역할

백엔드 인가 규칙에 사용되는 역할입니다.

{user.roles.map((role) =>
{role === "ADMIN" ? "운영 대시보드와 관리자 API에 접근할 수 있습니다." : role === "DOCUMENT_MANAGER" ? "문서 운영 권한을 가집니다." : "일반 문서 검색과 컬렉션 기능을 사용할 수 있습니다."}
)}
; + return
로그아웃} />
{initials(user.name)}

{user.name}

{user.email}
{user.roles.map((role) => )}
상태
{user.status}
사용자 ID
#{user.userId}

계정 정보

/auth/me 응답

이름
{user.name}
닉네임
{user.nickname ?? "—"}
부서
{user.departmentName ?? "—"}
부서 ID
{user.departmentId ?? "—"}
가입 시각
{formatDate(user.createdAt)}
마지막 로그인
{formatDate(user.lastLoginAt)}

역할

백엔드 인가 규칙에 사용되는 역할입니다.

{user.roles.map((role) =>
{role === "ADMIN" ? "운영 대시보드와 관리자 API에 접근할 수 있습니다." : role === "DOCUMENT_MANAGER" ? "문서 운영 권한을 가집니다." : "일반 문서 검색과 컬렉션 기능을 사용할 수 있습니다."}
)}
; } From 6f04b25939fb57347a98780500612da2c9c07dbf Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sat, 15 Aug 2026 14:09:51 +0900 Subject: [PATCH 6/8] =?UTF-8?q?docs:=20#186=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그아웃 API 배경, Redis 블랙리스트 설계 결정(fail-open 정책 포함), API 명세와 에러 케이스를 정리한다. --- docs/design/kangcheolung-#186-logout.md | 60 +++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/design/kangcheolung-#186-logout.md diff --git a/docs/design/kangcheolung-#186-logout.md b/docs/design/kangcheolung-#186-logout.md new file mode 100644 index 0000000..86ac520 --- /dev/null +++ b/docs/design/kangcheolung-#186-logout.md @@ -0,0 +1,60 @@ +# #186 로그아웃 API 구현 + 프론트 연동 + +closes #186 + +--- + +## 배경 + +백엔드에 로그아웃 API가 없었다. JWT는 완전 stateless(`JwtProvider`)라 만료(기본 1시간) 전까지는 어떤 토큰도 무효화할 방법이 없었고, 프론트(`AuthProvider.tsx`)의 `logout()`도 `sessionStorage`에서 토큰을 지우는 클라이언트 전용 정리에 불과했다. 사용자가 로그아웃해도 탈취된 토큰이 있다면 만료 전까지 계속 유효하다는 문제가 있었다. + +## 설계 결정 + +- **무효화 저장소: Redis.** DB 블랙리스트 테이블 대안도 검토했으나(기존 `mcp_access_tokens` 발급/폐기 패턴 재사용 가능), Redis의 TTL 자동 만료가 로그아웃처럼 "곧 사라질 데이터"에 더 정확히 맞고, `JwtAuthenticationFilter`가 매 요청마다 조회하는 경로라 인메모리 조회 속도가 유리하다. 이 프로젝트에 Redis 의존성이 아직 없었지만 docker-compose에 서비스 하나 추가하는 비용은 크지 않아 채택했다. +- **무효화 방식**: `JwtProvider.generateToken`에 `jti`(UUID) claim을 추가하고, 로그아웃 시 그 `jti`를 Redis에 `TTL = 토큰 잔여 만료시간`으로 저장한다. `JwtAuthenticationFilter`는 매 요청마다 `jti`가 블랙리스트에 있는지 확인해 인증 여부를 결정한다. +- **Redis 장애 정책: fail-open.** 블랙리스트 조회가 실패하면(Redis 다운 등) `log.error` 남기고 정상 인증 흐름을 계속 진행한다. Redis를 인증 전체의 단일 장애점으로 만들지 않기 위함이며, 어차피 토큰은 1시간 내 자연 만료된다. +- **Redis 설정**: Spring Boot가 `spring-boot-starter-data-redis` + `spring.data.redis.host/port` 설정만으로 `StringRedisTemplate` 빈을 자동 구성해준다. MinIO처럼 별도 `RedisConfig`가 필요 없다 (MinIO는 Boot starter가 없어서 수동 설정한 것). +- **원본 토큰 접근**: 기존 `JwtAuthenticationFilter`는 인증된 `userId`만 `Authentication.details`에 저장하고 원본 토큰 문자열은 어디에도 남기지 않았다. 로그아웃 컨트롤러에서 토큰이 다시 필요해, filter의 private `resolveToken()` 로직을 `JwtProvider.resolveToken(HttpServletRequest)` 정적 메서드로 옮겨 filter/controller 양쪽에서 재사용한다. +- **에러 코드**: 신규 `ErrorCode` 없음. `/auth/logout`은 `SecurityConfig`의 `anyRequest().authenticated()`에 걸려 무효/만료 토큰은 필터 단계에서 이미 401로 막힌다. +- **프론트**: 기존 `logout()`(client-only 정리)은 `refresh()` 실패·`AUTH_EXPIRED_EVENT` 경로처럼 토큰이 이미 무효인 자동 정리 상황에 그대로 쓴다. 사용자가 명시적으로 로그아웃 버튼을 누르는 경로에만 백엔드 API를 먼저 호출하는 `signOut()`을 새로 추가했다. 자동 정리 경로에서까지 백엔드를 호출하면 이미 무효화된 토큰으로 불필요한 401만 유발한다. + +## API 명세 + +```http +POST /auth/logout +Authorization: Bearer {token} +``` + +현재 사용 중인 액세스 토큰을 무효화한다. 이후 같은 토큰으로 요청하면 만료 전이라도 인증되지 않는다. + +**응답**: `204 No Content` (본문 없음) + +**에러 케이스** + +| 상황 | 응답 | +| --- | --- | +| `Authorization` 헤더 없음 / 토큰이 유효하지 않음(서명 위조·만료) | `401 UNAUTHORIZED` — `JwtAuthenticationFilter` 단계에서 인증되지 않아 `SecurityConfig`가 거부 | +| 이미 로그아웃(블랙리스트 등록)된 토큰으로 재요청 | `401 UNAUTHORIZED` — 위와 동일하게 필터에서 걸러짐 | +| Redis 장애 | 로그아웃 요청 자체는 예외 없이 처리되지 않고 실패할 수 있으나(블랙리스트 등록 실패), 인증 필터의 블랙리스트 *조회*는 fail-open이라 다른 모든 API의 인증에는 영향 없음 | + +## 변경 파일 + +- `backend/build.gradle` — `spring-boot-starter-data-redis` 추가 +- `docker-compose.yml`, `.env.example` — `redis` 서비스/환경변수 추가 +- `backend/src/main/resources/application.yml` — `spring.data.redis.host/port/password` +- `JwtProvider.java` — `jti` claim 추가, `resolveToken(HttpServletRequest)` 정적 메서드 추가 +- 신규 `TokenBlacklistService.java` (`domain/auth/jwt`) — Redis 기반 블랙리스트 등록/조회 +- `JwtAuthenticationFilter.java` — 블랙리스트 검증(fail-open) 추가 +- `SecurityConfig.java` — `TokenBlacklistService` 주입 +- `AuthCommandService.java` — `logout(String token)` 추가 +- `AuthController.java` — `POST /auth/logout` 추가 +- `frontend/app/components/AuthProvider.tsx` — `signOut()` 추가 +- `frontend/app/features/AccountPage.tsx` — 로그아웃 버튼을 `signOut()`에 연결 + +## 테스트 + +- `AuthCommandServiceTest` — 로그아웃 정상/무효 토큰 케이스 +- 신규 `TokenBlacklistServiceTest` — Redis 등록/조회 단위 테스트 +- 신규 `JwtAuthenticationFilterTest` — 정상 인증, 블랙리스트 토큰 거부, Redis 장애 시 fail-open 3케이스 +- 신규 `AuthLogoutIntegrationTest`(`@Tag("integration")`) — 실제 Postgres+Redis로 로그인→로그아웃→블랙리스트 등록 확인 +- `./backend/gradlew -p backend test` 전체 통과 확인 From 66c3224f2cdce2a40adeb40fea17d27bbc51d043 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sat, 15 Aug 2026 14:22:34 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20PR=20#187=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0(CodeRabbit)=20=EB=B0=98=EC=98=81=20-=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20=EB=B3=B4=EC=95=88/?= =?UTF-8?q?=EC=95=88=EC=A0=95=EC=84=B1=20=EC=9D=B4=EC=8A=88=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - jti 없는(레거시) 토큰으로 로그아웃 시 auth:blacklist:null 공유 키가 생겨 다른 모든 레거시 토큰까지 함께 차단되던 문제를 null 가드로 수정 - 블랙리스트 TTL 계산이 소수 초를 버려 만료 직전 실제보다 짧게 등록되던 문제를 밀리초 기준 올림 처리로 수정 - 인증 요청마다 동기 조회하는 Redis에 커맨드 타임아웃을 추가해, 응답 없을 때 fail-open이 지연 없이 동작하도록 함 - 인증 없는 Redis가 외부 인터페이스에 노출되지 않도록 ollama와 동일하게 loopback(127.0.0.1)에만 바인딩 - 프론트 로그아웃 요청이 응답 없이 걸려도 세션 정리가 끝나도록 AbortController 타임아웃 추가 --- .env.example | 1 + .../domain/auth/service/command/AuthCommandService.java | 8 ++++++-- backend/src/main/resources/application.yml | 2 ++ docker-compose.yml | 2 +- frontend/app/components/AuthProvider.tsx | 7 +++++-- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 9ffba54..8d5e0f8 100644 --- a/.env.example +++ b/.env.example @@ -37,3 +37,4 @@ MINIO_BUCKET=docgrid # REDIS_HOST=localhost # REDIS_PORT=6379 # REDIS_PASSWORD= +# REDIS_TIMEOUT=1s diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandService.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandService.java index 30ac43a..7902471 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandService.java @@ -110,9 +110,13 @@ public void logout(String token) { } String jti = claims.get("jti", String.class); - long remainingSeconds = Duration.between(Instant.now(), claims.getExpiration().toInstant()).getSeconds(); + if (jti == null) { + return; + } - if (remainingSeconds > 0) { + long remainingMillis = Duration.between(Instant.now(), claims.getExpiration().toInstant()).toMillis(); + if (remainingMillis > 0) { + long remainingSeconds = (remainingMillis + 999) / 1000; tokenBlacklistService.blacklist(jti, remainingSeconds); } } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index cf8e3c3..adeff49 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -18,6 +18,8 @@ spring: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} + # 인증 요청마다 동기 조회하므로, Redis가 응답하지 않을 때 fail-open이 빠르게 동작하도록 짧게 제한한다. + timeout: ${REDIS_TIMEOUT:1s} ai: mcp: server: diff --git a/docker-compose.yml b/docker-compose.yml index 8609e4d..b3726a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -78,7 +78,7 @@ services: image: redis:7-alpine container_name: docgrid-redis ports: - - "${REDIS_PORT:-6379}:6379" + - "127.0.0.1:${REDIS_PORT:-6379}:6379" volumes: - redis-data:/data healthcheck: diff --git a/frontend/app/components/AuthProvider.tsx b/frontend/app/components/AuthProvider.tsx index 20e5e04..63adc91 100644 --- a/frontend/app/components/AuthProvider.tsx +++ b/frontend/app/components/AuthProvider.tsx @@ -25,11 +25,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }, []); const signOut = useCallback(async () => { + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), 5000); try { - await apiRequest("/auth/logout", { method: "POST" }); + await apiRequest("/auth/logout", { method: "POST", signal: controller.signal }); } catch { - // 백엔드 로그아웃이 실패해도 클라이언트 세션은 항상 정리한다. + // 백엔드 로그아웃이 실패하거나 시간 초과돼도 클라이언트 세션은 항상 정리한다. } finally { + window.clearTimeout(timeoutId); logout(); } }, [logout]); From 2260f4b15660b42d79fe34dcf5148943f97e45c5 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sat, 15 Aug 2026 14:22:39 +0900 Subject: [PATCH 8/8] =?UTF-8?q?test:=20jti=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=EC=9C=BC=EB=A1=9C=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83=ED=95=98=EB=8A=94=20=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit이 지적한 레거시 토큰 블랙리스트 키 충돌 방지 로직에 대한 회귀 테스트. --- .../service/command/AuthCommandServiceTest.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandServiceTest.java index 68cdfb8..2d022d0 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandServiceTest.java @@ -238,4 +238,17 @@ void logout_doesNothing_whenTokenInvalid() { then(tokenBlacklistService).shouldHaveNoInteractions(); } + + @Test + @DisplayName("jti가 없는 토큰으로 로그아웃하면 블랙리스트에 등록하지 않는다") + void logout_doesNothing_whenJtiMissing() { + String token = "legacy-token-without-jti"; + Claims claims = mock(Claims.class); + given(jwtProvider.getClaimsIfValid(token)).willReturn(claims); + given(claims.get("jti", String.class)).willReturn(null); + + authCommandService.logout(token); + + then(tokenBlacklistService).shouldHaveNoInteractions(); + } }