diff --git a/.env.example b/.env.example index cdddbe2..8d5e0f8 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,10 @@ 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= +# REDIS_TIMEOUT=1s 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/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/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)); + } +} 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..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 @@ -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,22 @@ 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); + if (jti == null) { + return; + } + + 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/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(); } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 8313075..adeff49 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -13,6 +13,13 @@ 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:} + # 인증 요청마다 동기 조회하므로, Redis가 응답하지 않을 때 fail-open이 빠르게 동작하도록 짧게 제한한다. + timeout: ${REDIS_TIMEOUT:1s} ai: mcp: server: 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..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 @@ -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,45 @@ 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(); + } + + @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(); + } } 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; diff --git a/docker-compose.yml b/docker-compose.yml index d3a73b3..b3726a8 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: + - "127.0.0.1:${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: 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` 전체 통과 확인 diff --git a/frontend/app/components/AuthProvider.tsx b/frontend/app/components/AuthProvider.tsx index ea207ea..63adc91 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,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setUser(null); }, []); + const signOut = useCallback(async () => { + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), 5000); + try { + await apiRequest("/auth/logout", { method: "POST", signal: controller.signal }); + } catch { + // 백엔드 로그아웃이 실패하거나 시간 초과돼도 클라이언트 세션은 항상 정리한다. + } finally { + window.clearTimeout(timeoutId); + logout(); + } + }, [logout]); + const refresh = useCallback(async () => { const token = window.sessionStorage.getItem(ACCESS_TOKEN_KEY); if (!token) { @@ -66,7 +80,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" ? "문서 운영 권한을 가집니다." : "일반 문서 검색과 컬렉션 기능을 사용할 수 있습니다."}
)}
; }