jwt:
secret:
access: "base64로 인코딩된 암호 키, HS256를 사용할 것이기 때문에, 256비트(32바이트) 이상이 되어야 합니다. 영숫자 조합으로 아무렇게나 길게 써주세요!"
refresh: "base64로 인코딩된 암호 키, HS256를 사용할 것이기 때문에, 256비트(32바이트) 이상이 되어야 합니다. 영숫자 조합으로 아무렇게나 길게 써주세요!"
expiration:
access: 3600000 # 1시간 (원래는 더 짧게 가져가야하지만, API 테스트를 위해 약간 길게 설정했습니다!)
refresh: 1209600000 # 2주
- jwt.secret : 서버가 가지고 있는 개인 키로, 이 secret을 이용하여 JWT 생성 시 암호화를 진행합니다.
암호화 알고리즘으로 HS256을 사용할 것이기 때문에, 256비트(32바이트) 이상이 되어야 합니다.
따라서, 영숫자 조합으로 아무렇게나 길게 써주세요!
- jwt.expiration : Access Token / Refresh Token 의 만료 시간을 설정 파일에서 설정해 줍니다.
저는 Access Token의 만료 시간은 1시간, Refresh Token의 만료 시간은 2주로 설정하였습니다.
일반적으로 리프레시 토큰의 만료 기간은 2주로 많이 잡는다고 합니다.
Access Token은 일반적으로 짧은 만료 시간을 설정하지만, API 테스트를 위해 만료 시간을 다소 길게 조정했습니다.
아래 코드는 Refresh Token을 Redis에서 관리하는 코드입니다.
본격적인 코드를 확인하기 전에 참고해 주시면 감사하겠습니다!
// 서버에서 관리하는 리프레시 토큰을 관리하기 위한 로직
@Service
@Slf4j
public class RefreshTokenService {
private final RedisTemplate<String, String> redisTemplate;
public RefreshTokenService(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
// 리프레시 토큰 저장 (TTL 설정)
public void saveRefreshToken(Long userId, String refreshToken, long expirationTimeInMillis) {
ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
valueOperations.set(String.valueOf(userId), refreshToken, expirationTimeInMillis, TimeUnit.MILLISECONDS);
}
public String getRefreshToken(Long userId) {
ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
return valueOperations.get(String.valueOf(userId));
}
public void deleteRefreshToken(Long userId) {
log.info(String.valueOf(userId));
redisTemplate.delete(String.valueOf(userId));
}
public boolean hasValidRefreshToken(Long userId, String refreshToken) {
String storedToken = getRefreshToken(userId);
return storedToken != null && storedToken.equals(refreshToken);
}
}
1. JWT 관련 클래스 설정
JwtTokenProvider이 JWT 로직 관련 클래스입니다.
전체 코드를 먼저 보여드리고, 코드가 길기 때문에 부분적으로 코드를 발췌하여 설명드리겠습니다.
🎯JwtTokenProvider 전체 코드
@Component
@Slf4j
@RequiredArgsConstructor
public class JwtTokenProvider {
private final RefreshTokenService refreshTokenService;
@Value("${jwt.secret.access}")
private String accessTokenSecret;
@Value("${jwt.secret.refresh}")
private String refreshTokenSecret;
@Value("${jwt.expiration.access}")
private long accessTokenValidityInMilliseconds;
@Value("${jwt.expiration.refresh}")
private long refreshTokenValidityInMilliseconds;
// 액세스 토큰 생성
public String createAccessToken(Long userId) {
return createToken(userId, accessTokenValidityInMilliseconds, accessTokenSecret);
}
// 리프레시 토큰 생성
public String createRefreshToken(HttpServletResponse response, Long userId) {
String refreshToken = createToken(userId, refreshTokenValidityInMilliseconds, refreshTokenSecret);
storeRefreshToken(response, userId, refreshToken);
return refreshToken;
}
// 토큰 생성
private String createToken(Long userId, long validity, String secret) {
Claims claims = Jwts.claims().setSubject(String.valueOf(userId));
Date now = new Date();
Date expiry = new Date(now.getTime() + validity);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(SignatureAlgorithm.HS256, secret)
.compact();
}
// 리프레시 토큰 저장
public void storeRefreshToken(HttpServletResponse response, Long userId, String refreshToken) {
// Redis에 리프레시 토큰 저장
refreshTokenService.saveRefreshToken(userId, refreshToken, refreshTokenValidityInMilliseconds);
// 쿠키에 리프레시 토큰 저장
addRefreshTokenCookie(response, refreshToken);
}
// 리프레시 토큰 쿠키 생성 및 설정
private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) {
ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken)
.maxAge((int) (refreshTokenValidityInMilliseconds / 1000))
.sameSite("None")
.secure(true)
.path("/")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
// 리프레시 토큰 검증
public String validateRefreshToken(String refreshToken, Long userId) {
if (refreshTokenService.hasValidRefreshToken(userId, refreshToken)) {
return createAccessToken(userId);
}
throw new IllegalArgumentException("Invalid refresh token");
}
// 액세스 토큰 검증
public boolean validateAccessToken(String token) {
try {
log.info("액세스 토큰 검증을 시작합니다: " + token);
Jwts.parser().setSigningKey(accessTokenSecret).parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
log.error("Invalid JWT token: {}", e.getMessage());
return false;
}
}
// 토큰에서 사용자 ID 추출
public String getUserIdFromToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(accessTokenSecret)
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
public String getRole(String token) {
Claims claims = Jwts.parser()
.setSigningKey(accessTokenSecret)
.parseClaimsJws(token)
.getBody();
return claims.get("role", String.class);
}
public Long getUserIdFromRefreshToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(refreshTokenSecret)
.parseClaimsJws(token)
.getBody();
return Long.parseLong(claims.getSubject());
}
}
a. 프로퍼터 주입
@Value("${jwt.secret.access}")
private String accessTokenSecret;
@Value("${jwt.secret.refresh}")
private String refreshTokenSecret;
@Value("${jwt.expiration.access}")
private long accessTokenValidityInMilliseconds;
@Value("${jwt.expiration.refresh}")
private long refreshTokenValidityInMilliseconds;
@Value를 사용하여 각 필드들에 설정 파일인 application.yml의 프로퍼티들을 주입하도록 하겠습니다.
b. Access Token & Refresh Token 생성 메서드
// 토큰 생성
private String createToken(Long userId, long validity, String secret) {
Claims claims = Jwts.claims().setSubject(String.valueOf(userId));
Date now = new Date();
Date expiry = new Date(now.getTime() + validity);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(SignatureAlgorithm.HS256, secret)
.compact();
}
// 액세스 토큰 생성
public String createAccessToken(Long userId) {
return createToken(userId, accessTokenValidityInMilliseconds, accessTokenSecret);
}
// 리프레시 토큰 생성
public String createRefreshToken(HttpServletResponse response, Long userId) {
String refreshToken = createToken(userId, refreshTokenValidityInMilliseconds, refreshTokenSecret);
storeRefreshToken(response, userId, refreshToken);
return refreshToken;
}
b-1. createToken 메서드
createToken 메서드는 JWT 토큰을 생성하는 빌더를 사용하여 JWT를 생성합니다.
먼저 .setSubject()를 사용하여 JWT 토큰의 주제(Subject)에 userId를 담고, .setExpiration()으로 토큰의 만료 시간을 설정합니다.
그 후, .setClaims()를 사용하여 claims 객체를 통해 사용자 관련 정보인 userId를 Payload에 담고, 이를 바탕으로 토큰을 생성합니다.
.signWith() 메서드를 사용하여 서명(Signature)을 생성하는데, 여기서는 HS256 알고리즘과 서버의 비밀키(accessTokenSecret)를 사용하여 서명합니다.
최종적으로 .compact() 메서드를 호출하여 생성된 JWT 토큰을 반환합니다.
이 과정에서 JWT 토큰은 서버의 비밀 키로 암호화되어 생성됩니다.
b-2. createRefreshToken 메소드
createAccessToken은 위에서 설명한createToken메소드를 호출하여 액세스 토큰을 생성합니다. 반면, Refresh Token은 Redis에 저장하고 클라이언트에게는Cookie로 전송되기 때문에,HttpServletResponse를 매개변수로 받아야 합니다. 또한, 리프레시 토큰을 Redis에 저장하는 로직도 포함되어 있습니다.
c. Refresh Token 관련 메소드
public void storeRefreshToken(HttpServletResponse response, Long userId, String refreshToken) {
// Redis에 리프레시 토큰 저장
refreshTokenService.saveRefreshToken(userId, refreshToken, refreshTokenValidityInMilliseconds);
// 쿠키에 리프레시 토큰 저장
addRefreshTokenCookie(response, refreshToken);
}
// 리프레시 토큰 쿠키 생성 및 설정
private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) {
ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken)
.maxAge((int) (refreshTokenValidityInMilliseconds / 1000))
.sameSite("None")
.secure(true)
.path("/")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
c-1. storeRefreshToken 메소드
storeRefreshToken 메서드는 리프레시 토큰을 저장하는 두 가지 작업을 수행합니다.
첫 번째로,refreshTokenService.saveRefreshToken() 메소드를 호출하여 리프레시 토큰을 Redis에 저장합니다. 이 메서드는userId,refreshToken, 그리고 리프레시 토큰의 유효 기간(refreshTokenValidityInMilliseconds)을 인자로 받아, 해당 정보를 Redis에 저장하여 리프레시 토큰을 관리합니다.
두 번째로,addRefreshTokenCookie() 메소드를 호출하여 리프레시 토큰을쿠키에 저장합니다. 이 작업을 통해 리프레시 토큰은 클라이언트의 브라우저에 저장되어, 클라이언트가 서버와의 후속 요청에서 리프레시 토큰을 포함할 수 있게 됩니다.
따라서, 이 메서드는 리프레시 토큰을서버 측 Redis와클라이언트 측 쿠키에 모두 저장하여,서버에서 토큰을 안전하게 관리하고,클라이언트에서 토큰을 사용할 수 있게합니다.
c-2. addRefreshTokenCookie
addRefreshTokenCookie메소드는 클라이언트의 브라우저에 리프레시 토큰을쿠키로 저장하는 작업을 수행합니다.
.from을 통해 객체를 생성한 후 보안을 고려해 HTTPS 연결을 요구하고, 쿠키의 유효 기간 및 도메인 설정을 적용합니다.
마지막으로, HttpServletResponse 객체의 헤더에 SET_COOKIE를 추가하여 클라이언트에게 쿠키를 전송합니다. 이 쿠키는 브라우저에 저장되어 이후 요청에서 서버로 자동으로 전송됩니다.
만약 리프레시 토큰이 유효하지 않다면,IllegalArgumentException예외를 던져서 잘못된 리프레시 토큰에 대한 오류를 처리합니다.
d-2. Access Token 검증 메소드
validateAccessToken메서드는 주어진 액세스 토큰이 유효한지 검증하는 역할을 합니다.
Jwts.parser()를 통해 JWT 토큰을 파싱하기 위한 객체를 생성하며 setSigningKey를 통해 서명 키를 설정합니다. 이후 parseClaimsJws로 클레임을 검증하여 만약 토큰이 유효하면, parseClaimsJws가 정상적으로 실행되고, true를 반환하여 토큰이 유효함을 나타냅니다. 만약 JWT 토큰이 유효하지 않다면 fasle를 반환하여 토큰이 유효하지 않음을 알립니다.
e. Token에서 Id 추출
// 토큰에서 사용자 ID 추출
public String getUserIdFromToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(accessTokenSecret)
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
public Long getUserIdFromRefreshToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(refreshTokenSecret)
.parseClaimsJws(token)
.getBody();
return Long.parseLong(claims.getSubject());
}
Jwts.parser().setSigningKey(secreatKey).parseClaimsJws(token).getBody()를 호출하여, JWT 토큰을 파싱하고 토큰의 클레임을 추출합니다. 이후 claims.getSubject()로 사용자 ID를 얻습니다.
2. JWT 인증 필터 - JwtAuthenticationFilter
제가 Custom한 JWT 인증 필터는 OncePerRequestFilter를 상속받아서 구현합니다.
간략하게 필터를 요약하면, 클라이언트가 헤더에 JWT 토큰을 담아서 "/login" URL 이외의 요청을 보냈을 시, 해당 토큰들의 유효성을 검사하여 인증 처리/인증 실패/토큰 재발급 등을 수행하는 역할의 필터입니다.
인증 필터를 이해하기 위해서는, JWT 인증 로직을 이해해야 합니다. 따라서, 간략하게 JWT 인증 로직을 이해하고 가도록 하겠습니다.
💡 JWT 인증 로직 - Access Token 만료 전 / Acces Token 만료 후
인증 로직은 크게 Access Token 만료 전(정상 로직) / Access Token 만료 후로 나뉘게 됩니다.
해당 인증 로직을 그림과 함께 설명드리겠습니다.
- Access Token 만료 전 인증 과정
1. 클라이언트가 이메일/비밀번호를 담아 로그인 요청을 서버에 보냅니다. 2. 서버는 요청받은 이메일/비밀번호로 DB에서 유저를 찾고, 유저가 존재한다면 Access Token과 Refresh Token을 생성하여 Response에 담아 반환합니다. (이때, 생성한 Refresh Token은 DB에 저장해 둡니다. 3. 이후 클라이언트는 매 요청 시마다 Access Token을 담아 API를 요청합니다. 4. 서버에서는 요청받은 Access Token을 검증하여 인증 성공/인증 실패 처리를 합니다.
- Access Token 만료 후 인증 과정
1. 클라이언트에서 자체적으로 Access Token 만료를 판단 후, 서버에 Refresh Token만을 담아 요청합니다. 2. 서버에서 요청받은 Refresh Token이 DB에 저장된 Refresh Token과 일치하는지 판단 후, 일치하다면 AccessToken과 RefreshToken을 재발급하여 Response에 담아 보냅니다. 이때, 재발급한 RefreshToken으로 DB의 RefreshToken을 업데이트합니다. (RTR 방식) 3. 클라이언트는 서버로부터 재발급받은 Access Token을 요청에 담아 API 요청을 보냅니다. 4. 서버에서 요청받은 Access Token을 검증하여 인증 성공/인증 실패 처리를 합니다.
🤔 RTR 방식?
Refresh Token Rotation 방식의 약자로, Refresh Token을 한 번만 사용할 수 있게 만드는 방법입니다. Refresh Token을 사용하여 만료된 Access Token을 재발급받을 때, Refresh Token도 재발급하는 방법입니다.
이러한 방식이 나온 이유는, Refresh Token이 탈취된다면 Access Token을 계속 생성할 수 있기 때문입니다. Refrehs Token은 만료 기간이 길기 때문에 이러한 상황이 된다면 상당히 위험해집니다.
따라서, Refresh Token을 Access Token 재발급 시 같이 재발급하여, 만료 기간을 줄이는 방법입니다. 위의 Access Token 만료 후 인증 과정에서도 RTR 방식을 적용했기 때문에 Access Token을 재발급할 때 Refresh Token까지 재발급하여 DB에 업데이트해주는 것입니다..
🎯 JwtAuthenticationFilter 전체 코드
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String requestURI = request.getRequestURI();
log.info(requestURI);
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
log.info("옵션");
filterChain.doFilter(request, response);
return;
}
if (isRequest(requestURI)) {
log.info(requestURI + ": 액세스 토큰이 필요없는 작업입니다.");
// 쿠키 배열에서 리프레시 토큰을 찾기
if (request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
if ("refreshToken".equals(cookie.getName())) {
String refreshToken = cookie.getValue();
log.info("리프레시 토큰: " + refreshToken);
break;
}
}
} else {
log.info("쿠키가 없습니다.");
}
filterChain.doFilter(request, response);
return;
}
try {
log.info(requestURI + ": 액세스 토큰이 필요한 작업입니다.");
String token = resolveToken(request);
log.info("액세스 토큰: " + token);
// 토큰 유효성 검증
if (!jwtTokenProvider.validateAccessToken(token)) {
throw new SecurityTokenException(ErrorCode.INVALID_TOKEN);
}
String userId = jwtTokenProvider.getUserIdFromToken(token);
List<GrantedAuthority> authorities = new ArrayList<>();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(Long.valueOf(userId), null, authorities);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Authentication set in SecurityContext for user: {}", userId);
} catch (SecurityTokenException e) {
log.error("Cannot set user authentication: {}", e.getMessage());
throw new SecurityTokenException(ErrorCode.INTERNAL_SECURITY_ERROR);
} catch (Exception e) {
log.error("Internal server error during authentication processing: {}", e.getMessage());
throw new SecurityTokenException(ErrorCode.INTERNAL_SERVER_ERROR);
}
// 다음 필터로
filterChain.doFilter(request, response);
}
// Authorization 헤더에서 JWT 토큰을 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
log.info("액세스 토큰 추출 시작: " + bearerToken);
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
private boolean isRequest(String requestURI) {
return requestURI.startsWith("/swagger-resources") ||
requestURI.startsWith("/swagger-ui") ||
requestURI.startsWith("/v3/api-docs") ||
requestURI.startsWith("/api-docs") ||
requestURI.startsWith("/webjars") ||
requestURI.startsWith("/api/v1/auth/user/info") ||
requestURI.startsWith("/api/v1/auth/reissue");
}
}
a. OncePerRequestFilter의 doFilterInternal()
OncePerRequestFilter의 doFilterInternal()를 Override 한 것입니다.
이 메서드 안에 인증 처리/인증 실패 로직을 설정하여 필터 진입 시 인증 처리/인증 실패 등을 처리합니다.
요청의 메서드가 OPTIONS인 경우 특정 처리를 합니다. 먼저, 요청 URI를 request.getRequestURI()를 통해 얻은 뒤, 만약 HTTP 메서드가 OPTIONS라면, "옵션"이라는 메시지를 로그에 기록하고 이후 요청을 필터 체인의 다음 단계로 전달합니다.
OPTIONS 요청이 처리되면 필터 로직을 종료하고, 추가 처리를 하지 않도록 return 문으로 메서드를 종료합니다.
🤔 HTTP 메서드가 OPTIONS라면 왜 바로 종료하지?
OPTIONS 요청에 대해 바로 필터 로직을 종료하는 이유는 CORS 관련 처리를 위해서입니다. OPTIONS 메서드는 Preflight Request라고 불리며, 주로 클라이언트가 서버에 특정 조건으로 요청을 보내기 전에, 서버가 해당 요청을 허용하는지 확인하기 위해 사용됩니다.
isRequest(requestURI)메서드를 통해 Access Token 이 필요하지 않은 URI인 경우, 해당 URI에서 Refresh Token 찾는 작업을 수행합니다. 쿠키 배열에서"refreshToken"이라는 이름의 쿠키가 있으면 그 값을 추출하여 리프레시 토큰을 로그로 출력하고, 없으면 "쿠키가 없습니다."라는 메시지를 로그에 남깁니다. 그런 후 필터 체인을 계속 진행하여 요청을 처리합니다.
try {
log.info(requestURI + ": 액세스 토큰이 필요한 작업입니다.");
String token = resolveToken(request);
log.info("액세스 토큰: " + token);
// 토큰 유효성 검증
if (!jwtTokenProvider.validateAccessToken(token)) {
throw new SecurityTokenException(ErrorCode.INVALID_TOKEN);
}
String userId = jwtTokenProvider.getUserIdFromToken(token);
List<GrantedAuthority> authorities = new ArrayList<>();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(Long.valueOf(userId), null, authorities);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Authentication set in SecurityContext for user: {}", userId);
} catch (SecurityTokenException e) {
log.error("Cannot set user authentication: {}", e.getMessage());
throw new SecurityTokenException(ErrorCode.INTERNAL_SECURITY_ERROR);
} catch (Exception e) {
log.error("Internal server error during authentication processing: {}", e.getMessage());
throw new SecurityTokenException(ErrorCode.INTERNAL_SERVER_ERROR);
}
위 코드는 Access Token이 필요한 작업에 대해 Access Token을 추출하고, 그 유효성을 검사하는 과정입니다. 먼저 요청에서 Access Token을 추출하고, 해당 토큰의 유효성을jwtTokenProvider를 통해 검증합니다. 만약 토큰이 유효하지 않으면SecurityTokenException을 던져 오류를 처리합니다.
토큰이 유효한 경우, 토큰에서 사용자 ID를 추출하고, 이를 바탕으로GrantedAuthority객체를 생성하여 사용자의 인증 정보를 설정합니다. 이후UsernamePasswordAuthenticationToken을 생성하여SecurityContextHolder에 인증 정보를 저장합니다. 이 인증 정보는 이후의 보안 처리를 위해 사용됩니다.
이 과정에서 발생한 예외는 각각SecurityTokenException과Exception으로 구분하여 적절히 처리하며, 오류 메시지를 로그로 기록합니다.