diff --git a/.github/workflows/CI-CD_Pipeline.yml b/.github/workflows/CI-CD_Pipeline.yml index 73e2ffe2..f0635c5a 100644 --- a/.github/workflows/CI-CD_Pipeline.yml +++ b/.github/workflows/CI-CD_Pipeline.yml @@ -243,6 +243,7 @@ jobs: PROD_DATASOURCE_DRIVER=com.mysql.cj.jdbc.Driver PROD_DATASOURCE_USERNAME=root PROD_DATASOURCE_PASSWORD=${{ secrets.DB_PASSWORD }} + PROD_JPA_HIBERNATE_DDL_AUTO=update PROD_REDIS_HOST=redis_1 PROD_REDIS_PORT=6379 diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java index 3a80590c..dbecee3b 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java @@ -70,22 +70,22 @@ public void logout(String loginId, HttpServletResponse response) { public MemberResponse refreshToken(String refreshToken, HttpServletResponse response) { // Redis에서 리프레시 토큰으로 사용자 찾기 - String username = tokenProvider.findUsernameByRefreshToken(refreshToken); - if (username == null) { + String loginId = tokenProvider.findUsernameByRefreshToken(refreshToken); + if (loginId == null) { throw new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다."); } // 리프레시 토큰 유효성 검증 - if (!tokenProvider.validateRefreshToken(username, refreshToken)) { + if (!tokenProvider.validateRefreshToken(loginId, refreshToken)) { throw new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다."); } // 회원 정보 조회 - Member member = memberRepository.findByLoginId(username) + Member member = memberRepository.findByLoginId(loginId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); // RTR(Refresh Token Rotation) 패턴: 기존 리프레시 토큰 삭제 - tokenProvider.deleteRefreshToken(username); + tokenProvider.deleteRefreshToken(loginId); // 새로운 액세스 토큰과 리프레시 토큰 생성 String newAccessToken = tokenProvider.generateAccessToken(member); diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java b/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java index 2b87cc61..400fcbd2 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java @@ -10,7 +10,7 @@ public class CookieUtil { private static final String ACCESS_TOKEN_NAME = "accessToken"; private static final String REFRESH_TOKEN_NAME = "refreshToken"; - private static final int ACCESS_TOKEN_EXPIRE_TIME = 30 * 60; // 30분 + private static final int ACCESS_TOKEN_EXPIRE_TIME = 5 * 60; // 5분 private static final int REFRESH_TOKEN_EXPIRE_TIME = 7 * 24 * 60 * 60; // 7일 public void setTokenCookies(HttpServletResponse response, String accessToken, String refreshToken) { @@ -54,7 +54,6 @@ public String getAccessTokenFromCookies(HttpServletRequest request) { return getTokenFromCookies(request, ACCESS_TOKEN_NAME); } - @SuppressWarnings("unused") public String getRefreshTokenFromCookies(HttpServletRequest request) { return getTokenFromCookies(request, REFRESH_TOKEN_NAME); } diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java b/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java index 779f81c3..0c23d0fe 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java @@ -1,5 +1,7 @@ package com.ai.lawyer.global.jwt; +import com.ai.lawyer.domain.member.entity.Member; +import com.ai.lawyer.domain.member.repositories.MemberRepository; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -23,17 +25,35 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final TokenProvider tokenProvider; private final CookieUtil cookieUtil; + private final MemberRepository memberRepository; @Override protected void doFilterInternal(@Nullable HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable FilterChain filterChain) throws ServletException, IOException { - if (request != null) { - String accessToken = cookieUtil.getAccessTokenFromCookies(request); + if (request != null && response != null) { + // 1. Authorization 헤더에서 Bearer 토큰 추출 시도 (우선순위 1) + String accessToken = extractTokenFromAuthorizationHeader(request); + boolean fromHeader = accessToken != null; + + // 2. Authorization 헤더에 없으면 쿠키에서 토큰 추출 (우선순위 2) + if (accessToken == null) { + accessToken = cookieUtil.getAccessTokenFromCookies(request); + } // JWT 액세스 토큰 검증 및 인증 처리 - if (accessToken != null && tokenProvider.validateToken(accessToken)) { - setAuthentication(accessToken); + if (accessToken != null) { + TokenProvider.TokenValidationResult validationResult = tokenProvider.validateTokenWithResult(accessToken); + + if (validationResult == TokenProvider.TokenValidationResult.VALID) { + // 유효한 토큰인 경우 인증 처리 + setAuthentication(accessToken); + } else if (validationResult == TokenProvider.TokenValidationResult.EXPIRED && !fromHeader) { + // 만료된 토큰이고 쿠키에서 왔을 경우에만 자동 갱신 시도 + // (Authorization 헤더 토큰은 클라이언트가 직접 관리해야 함) + tryAutoRefreshToken(request, response, accessToken); + } + // INVALID인 경우 아무 처리 하지 않음 (인증되지 않은 상태로 진행) } } @@ -42,6 +62,19 @@ protected void doFilterInternal(@Nullable HttpServletRequest request, @Nullable } } + /** + * Authorization 헤더에서 Bearer 토큰을 추출합니다. + * @param request HTTP 요청 + * @return Bearer 토큰 값 또는 null + */ + private String extractTokenFromAuthorizationHeader(HttpServletRequest request) { + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7); // "Bearer " 제거 + } + return null; + } + /** * JWT 토큰에서 사용자 정보를 추출하여 Spring Security 인증 객체를 설정합니다. * @param token JWT 액세스 토큰 @@ -70,6 +103,61 @@ private void setAuthentication(String token) { } } + /** + * 만료된 액세스 토큰으로 자동 갱신을 시도합니다. + * @param request HTTP 요청 + * @param response HTTP 응답 + * @param expiredAccessToken 만료된 액세스 토큰 + */ + private void tryAutoRefreshToken(HttpServletRequest request, HttpServletResponse response, String expiredAccessToken) { + try { + // 1. 만료된 토큰에서 loginId 추출 + String loginId = tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken); + if (loginId == null) { + log.warn("만료된 토큰에서 loginId 추출 실패"); + return; + } + + // 2. 쿠키에서 리프레시 토큰 추출 + String refreshToken = cookieUtil.getRefreshTokenFromCookies(request); + if (refreshToken == null) { + log.info("리프레시 토큰이 없어 자동 갱신 불가: {}", loginId); + return; + } + + // 3. 리프레시 토큰 유효성 검증 + if (!tokenProvider.validateRefreshToken(loginId, refreshToken)) { + log.info("유효하지 않은 리프레시 토큰으로 자동 갱신 불가: {}", loginId); + return; + } + + // 4. 회원 정보 조회 + Member member = memberRepository.findByLoginId(loginId).orElse(null); + if (member == null) { + log.warn("존재하지 않는 회원으로 자동 갱신 불가: {}", loginId); + return; + } + + // 5. RTR(Refresh Token Rotation) 패턴: 기존 리프레시 토큰 삭제 + tokenProvider.deleteRefreshToken(loginId); + + // 6. 새로운 액세스 토큰과 리프레시 토큰 생성 + String newAccessToken = tokenProvider.generateAccessToken(member); + String newRefreshToken = tokenProvider.generateRefreshToken(member); + + // 7. 새로운 토큰들을 쿠키에 설정 + cookieUtil.setTokenCookies(response, newAccessToken, newRefreshToken); + + // 8. 새로운 액세스 토큰으로 인증 설정 + setAuthentication(newAccessToken); + + log.info("액세스 토큰 자동 갱신 성공: {}", loginId); + + } catch (Exception e) { + log.warn("액세스 토큰 자동 갱신 실패: {}", e.getMessage()); + } + } + /** * JWT 인증이 필요하지 않은 경로들을 필터링에서 제외합니다. * @param request HTTP 요청 diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java b/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java index 1753a769..d2a41406 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java @@ -54,25 +54,31 @@ public String generateRefreshToken(Member member) { return refreshToken; } - public boolean validateToken(String token) { + /** + * 토큰의 상태를 확인합니다. + * @param token JWT 토큰 + * @return TokenValidationResult (유효, 만료, 오류) + */ + public TokenValidationResult validateTokenWithResult(String token) { try { Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token); - return true; - } catch (MalformedJwtException e) { - log.warn("잘못된 JWT 토큰: {}", e.getMessage()); + return TokenValidationResult.VALID; } catch (ExpiredJwtException e) { log.warn("만료된 JWT 토큰: {}", e.getMessage()); - } catch (UnsupportedJwtException e) { - log.warn("지원되지 않는 JWT 토큰: {}", e.getMessage()); - } catch (IllegalArgumentException e) { - log.warn("JWT 토큰이 잘못되었습니다: {}", e.getMessage()); - } catch (SecurityException e) { - log.warn("JWT 서명이 잘못되었습니다: {}", e.getMessage()); + return TokenValidationResult.EXPIRED; + } catch (MalformedJwtException | UnsupportedJwtException | IllegalArgumentException | SecurityException e) { + log.warn("유효하지 않은 JWT 토큰: {}", e.getMessage()); + return TokenValidationResult.INVALID; } - return false; + } + + public enum TokenValidationResult { + VALID, // 유효한 토큰 + EXPIRED, // 만료된 토큰 + INVALID // 잘못된 토큰 } public Long getMemberIdFromToken(String token) { @@ -117,6 +123,28 @@ public String getLoginIdFromToken(String token) { } } + /** + * 만료된 토큰에서도 loginId를 추출합니다. + * @param token JWT 토큰 (만료되어도 괜찮음) + * @return loginId 또는 null + */ + public String getLoginIdFromExpiredToken(String token) { + try { + Claims claims = Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + return claims.get("loginid", String.class); + } catch (ExpiredJwtException e) { + // 만료된 토큰이지만 claim은 추출 가능 + return e.getClaims().get("loginid", String.class); + } catch (Exception e) { + log.warn("만료된 토큰에서 로그인 ID 추출 실패: {}", e.getMessage()); + return null; + } + } + public boolean validateRefreshToken(String loginId, String refreshToken) { String redisKey = REFRESH_TOKEN_PREFIX + loginId; String storedToken = (String) redisTemplate.opsForValue().get(redisKey); diff --git a/backend/src/test/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilterTest.java b/backend/src/test/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilterTest.java new file mode 100644 index 00000000..f7b67ea4 --- /dev/null +++ b/backend/src/test/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilterTest.java @@ -0,0 +1,179 @@ +package com.ai.lawyer.global.jwt; + +import com.ai.lawyer.domain.member.entity.Member; +import com.ai.lawyer.domain.member.repositories.MemberRepository; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("JwtAuthenticationFilter 테스트") +class JwtAuthenticationFilterTest { + + @Mock + private TokenProvider tokenProvider; + + @Mock + private CookieUtil cookieUtil; + + @Mock + private MemberRepository memberRepository; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private FilterChain filterChain; + + @InjectMocks + private JwtAuthenticationFilter jwtAuthenticationFilter; + + private Member testMember; + private String validAccessToken; + private String expiredAccessToken; + private String refreshToken; + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + + testMember = Member.builder() + .loginId("test@example.com") + .password("encodedPassword") + .name("Test User") + .age(25) + .gender(Member.Gender.MALE) + .role(Member.Role.USER) + .build(); + + validAccessToken = "validAccessToken"; + expiredAccessToken = "expiredAccessToken"; + refreshToken = "refreshToken"; + } + + @Test + @DisplayName("유효한 Authorization 헤더 토큰으로 인증 성공") + void doFilterInternal_ValidHeaderToken_Success() throws Exception { + // given + given(request.getHeader("Authorization")).willReturn("Bearer " + validAccessToken); + given(tokenProvider.validateTokenWithResult(validAccessToken)) + .willReturn(TokenProvider.TokenValidationResult.VALID); + given(tokenProvider.getMemberIdFromToken(validAccessToken)).willReturn(1L); + given(tokenProvider.getRoleFromToken(validAccessToken)).willReturn("USER"); + + // when + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // then + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); + assertThat(SecurityContextHolder.getContext().getAuthentication().getPrincipal()).isEqualTo(1L); + verify(filterChain).doFilter(request, response); + } + + @Test + @DisplayName("만료된 쿠키 토큰으로 자동 리프레시 성공") + void doFilterInternal_ExpiredCookieToken_AutoRefreshSuccess() throws Exception { + // given + String newAccessToken = "newAccessToken"; + String newRefreshToken = "newRefreshToken"; + + given(request.getHeader("Authorization")).willReturn(null); + given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(expiredAccessToken); + given(tokenProvider.validateTokenWithResult(expiredAccessToken)) + .willReturn(TokenProvider.TokenValidationResult.EXPIRED); + + // 자동 리프레시 관련 + given(tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken)).willReturn("test@example.com"); + given(cookieUtil.getRefreshTokenFromCookies(request)).willReturn(refreshToken); + given(tokenProvider.validateRefreshToken("test@example.com", refreshToken)).willReturn(true); + given(memberRepository.findByLoginId("test@example.com")).willReturn(Optional.of(testMember)); + given(tokenProvider.generateAccessToken(testMember)).willReturn(newAccessToken); + given(tokenProvider.generateRefreshToken(testMember)).willReturn(newRefreshToken); + + // 새 토큰으로 인증 설정 + given(tokenProvider.getMemberIdFromToken(newAccessToken)).willReturn(1L); + given(tokenProvider.getRoleFromToken(newAccessToken)).willReturn("USER"); + + // when + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // then + verify(tokenProvider).deleteRefreshToken("test@example.com"); + verify(cookieUtil).setTokenCookies(response, newAccessToken, newRefreshToken); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); + verify(filterChain).doFilter(request, response); + } + + @Test + @DisplayName("만료된 Authorization 헤더 토큰은 자동 리프레시하지 않음") + void doFilterInternal_ExpiredHeaderToken_NoAutoRefresh() throws Exception { + // given + given(request.getHeader("Authorization")).willReturn("Bearer " + expiredAccessToken); + given(tokenProvider.validateTokenWithResult(expiredAccessToken)) + .willReturn(TokenProvider.TokenValidationResult.EXPIRED); + + // when + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // then + verify(tokenProvider, never()).getLoginIdFromExpiredToken(anyString()); + verify(cookieUtil, never()).getRefreshTokenFromCookies(request); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(filterChain).doFilter(request, response); + } + + @Test + @DisplayName("리프레시 토큰이 없으면 자동 갱신 실패") + void doFilterInternal_NoRefreshToken_AutoRefreshFail() throws Exception { + // given + given(request.getHeader("Authorization")).willReturn(null); + given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(expiredAccessToken); + given(tokenProvider.validateTokenWithResult(expiredAccessToken)) + .willReturn(TokenProvider.TokenValidationResult.EXPIRED); + given(tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken)).willReturn("test@example.com"); + given(cookieUtil.getRefreshTokenFromCookies(request)).willReturn(null); + + // when + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // then + verify(tokenProvider, never()).validateRefreshToken(anyString(), anyString()); + verify(cookieUtil, never()).setTokenCookies(any(), anyString(), anyString()); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(filterChain).doFilter(request, response); + } + + @Test + @DisplayName("잘못된 토큰으로 인증 실패") + void doFilterInternal_InvalidToken_AuthFail() throws Exception { + // given + String invalidToken = "invalidToken"; + given(request.getHeader("Authorization")).willReturn("Bearer " + invalidToken); + given(tokenProvider.validateTokenWithResult(invalidToken)) + .willReturn(TokenProvider.TokenValidationResult.INVALID); + + // when + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // then + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(filterChain).doFilter(request, response); + } +} \ No newline at end of file