Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/CI-CD_Pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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인 경우 아무 처리 하지 않음 (인증되지 않은 상태로 진행)
}
}

Expand All @@ -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 액세스 토큰
Expand Down Expand Up @@ -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 요청
Expand Down
50 changes: 39 additions & 11 deletions backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
Loading