
Date#getTime()은 밀리초(ms) 기준이기 때문에, 분(minute) 단위를 그대로 더하면 만료 시간이 의도와 다르게 계산될 수 있다.
now.getTime() + expireMinutes (expireMinutes가 “분”이면 단위 불일치)
해결: ms로 변환해서 더하기 또는 아예 java.time 사용
Date now = new Date();
Instant instant = now.toInstant().plus(Duration.ofMinutes(props.accessToken().expireMinutes()));
Date exp = Date.from(instant);
-> 토큰 만료처럼 “시점 + 기간” 계산은 Instant + Duration이 가장 읽기 좋다.
JWT 인증 실패를 어디서 분기할지 고민했다.
io.jsonwebtoken.* 예외 타입을 EntryPoint가 알아야 해서 라이브러리 결합이 커짐
예외 종류가 늘수록 EntryPoint 분기 로직이 복잡해짐
Spring Security에서 “인증 실패”는 보통 AuthenticationException 계열로 흘러가야 한다.
그래서 JWT 관련 오류를 담는 커스텀 예외를 만들었다.
public class JwtAuthenticationException extends AuthenticationException {
private final ErrorCode errorCode;
public JwtAuthenticationException(Throwable cause, ErrorCode errorCode) {
super(errorCode.getMessage(), cause);
this.errorCode = errorCode;
}
public JwtAuthenticationException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}
cause는 필수는 아니지만,
디버깅/로그에서 원인 추적이 쉬워서 있는 케이스는 넣는 쪽 추천
응답에는 cause를 노출하지 않고, ErrorCode 기반으로만 응답하도록 설계
JWT 필터는 일반적으로 UsernamePasswordAuthenticationFilter보다 앞단에 놓인다.
그 경우 Spring의 ExceptionTranslationFilter가 자동으로 EntryPoint를 호출해주지 못하는 케이스가 생길 수 있다(필터 순서 문제).
그래서 JWT 필터에서 인증 예외가 발생하면 직접 EntryPoint를 호출하고 즉시 종료하도록 했다.
try {
String token = tokenExtractor.extract(request);
if (StringUtils.hasText(token)) {
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request,response);
} catch (AuthenticationException e) {
SecurityContextHolder.clearContext();
authenticationEntryPoint.commence(request, response, e);
return;
}
return;은 catch 뒤에 더 실행할 코드가 없으면 “필수”는 아니지만,
나중에 코드가 추가될 때 실수 방지용으로 남겨두는 게 안전하다.
아니다.
AuthenticationException은 Spring Security 계열
JwtException, IllegalArgumentException은 별개 런타임 예외
따라서 필터에서 catch (AuthenticationException)만 잡고 싶다면,
JwtException/IllegalArgumentException을 Parser/Provider에서 JwtAuthenticationException으로 변환해야 한다.public Claims extractClaims(String token) {
try {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (MalformedJwtException e) {
throw new JwtAuthenticationException(e, ErrorCode.MALFORMED_JWT);
} catch (ExpiredJwtException e) {
throw new JwtAuthenticationException(e, ErrorCode.EXPIRED_JWT);
} catch (UnsupportedJwtException e) {
throw new JwtAuthenticationException(e, ErrorCode.UNSUPPORTED_JWT);
} catch (SecurityException e) {
throw new JwtAuthenticationException(e, ErrorCode.INVALID_SIGNATURE_JWT);
} catch (IllegalArgumentException | JwtException e) {
throw new JwtAuthenticationException(e, ErrorCode.INVALID_JWT);
}
}
JwtException을 몰라도 된다.SecurityException은 JJWT에서 실제로 떨어지는 타입 import를 명확히 맞추는 게 좋다.
Claims claims = jwtTokenParser.extractClaims(accessToken);
String subject = claims.getSubject();
if (!StringUtils.hasText(subject)) {
throw new JwtAuthenticationException(ErrorCode.INVALID_JWT_SUBJECT);
}
Long userId;
try {
userId = Long.valueOf(subject);
} catch (NumberFormatException e) {
throw new JwtAuthenticationException(e, ErrorCode.INVALID_JWT_SUBJECT);
}
UserDetails userDetails = customUserDetailsServiceImpl.loadUserById(userId);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
Subject가 비어있거나 숫자가 아니면 ErrorCode로 명확히 분기
유저가 없으면 loadUserById()에서 JwtAuthenticationException으로 처리
JWT 경로에서는 loadUserById()가 필요해서 커스텀 서비스에 별도 메서드를 둠.
public UserDetails loadUserById(Long id) {
User foundUser = userRepository.findById(id)
.orElseThrow(() -> new JwtAuthenticationException(ErrorCode.USERNAME_NOT_FOUND));
return CustomUserDetails.fromJwt(foundUser);
}
jwt 관련이 아니기 때문에 일단은 저렇게 처리해둠
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User foundUser = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("유저가 존재하지 않습니다."));
return CustomUserDetails.fromLogin(foundUser);
}
if (hasText && startsWith("Bearer ")) return token;
return null;
이 경우:
보호된 엔드포인트: 인증이 없으니 결국 401/403로 떨어짐
permitAll 엔드포인트: 그냥 정상 통과
즉 “Authorization 헤더가 이상해도” 그냥 토큰 없는 요청처럼 취급한다는 정책이 된다.
엄격하게 하고 싶으면 “헤더가 있는데 Bearer 아니면 예외”로 바꿀 수도 있다.
이것도 크게 중요하지 않은 것 같아서 일단 이렇게 처리해둠
jwt.* 설정이 application-dev.yml에만 있으면,
테스트에서 test 프로파일로 실행될 때 jwt.*가 없어 null 바인딩이 된다.
공통값은 application.yml로
secret key는 프로파일별로(application-dev.yml, application-test.yml) 분리
application.yml (공통)
jwt:
issuer: DeliveryPlatform
access-token:
expire-minutes: 10
application-dev.yml
jwt:
secret:
key: ${JWT_SECRET_KEY}
application-test.yml (테스트 전용 더미키)
jwt:
secret:
key: "~~"
테스트에서 환경변수 없이도 실행 가능
키 길이/형식(Base64)을 만족하도록 준비
JWT 만료 계산을 Duration/Instant로 수정
JWT 라이브러리 예외를 Parser에서 ErrorCode 기반으로 JwtAuthenticationException으로 변환
필터는 AuthenticationException 하나만 잡고 EntryPoint 직접 호출
UserDetailsService는 JWT용(id 기반) / 로그인용(email 기반) 경로를 분리
테스트 환경에서 설정 누락으로 null 바인딩되는 문제를 공통/프로파일 분리 패턴으로 해결
테스트 전용 Base64 secret key 준비