설명
가치택시 Spring Security로 인증 인가를 구현하는 중..... 필터 단에서 일어나는 인증 예외를 CustomAuthenticationFilter로 처리하고 있었다.
이때, 불효자 코드로 인해 엣지 케이스가 발생해버리고 마는데....
그것은 바로 허용한 경로 permitAll()에 요청할 때, 만료된 토큰을 가지고 있으면 요청이 거부되는 케이스를 발견했다!
기본적으로 Jwt 관련 발급, 검증, 추출 그리고 비지니스 로직은 다음 그림처럼 사용되도록 설계했다.
그리고 JwtExtractor 코드는 다음과 같다.
@Component
public class JwtExtractor {
private static final String ID_CLAIM = "id";
private static final String EMAIL_CLAIM = "email";
private static final String ROLE_CLAIM = "role";
private final Key key;
public JwtExtractor(@Value("${gachtaxi.auth.jwt.key}") String secretKey) {
this.key = Keys.hmacShaKeyFor(secretKey.getBytes()); // 키 변환
}
// 생략
public Boolean isExpired(String token) {
Claims claims = parseClaims(token);
return claims.getExpiration().before(new Date());
}
private String getClaimFromToken(String token, String claimName) {
Claims claims = parseClaims(token);
return claims.get(claimName, String.class);
}
private Long getIdFromToken(String token, String claimName) {
Claims claims = parseClaims(token);
return claims.get(claimName, Long.class);
}
private Claims parseClaims(String token) {
try{
JwtParser parser = Jwts.parserBuilder()
.setSigningKey(key)
.build();
return parser.parseClaimsJws(token).getBody();
}catch (JwtException e){
throw new TokenInvalidException();
}
}
}
JwtAuthenticationFilter 코드 일부
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtExtractor jwtExtractor;
private final static String JWT_ERROR = "jwtError";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
Optional<String> token = jwtExtractor.extractJwtToken(request);
if (token.isEmpty()) {
request.setAttribute(JWT_ERROR, JWT_TOKEN_NOT_FOUND);
filterChain.doFilter(request, response);
return;
}
String accessToken = token.get();
if(jwtExtractor.isExpired(accessToken)){
request.setAttribute(JWT_ERROR, JWT_TOKEN_EXPIRED);
filterChain.doFilter(request, response);
return;
}
// 인증 객체 저장 로직 생략
}
}
예외 로직
기본적으로 내가 짠 예외 처리 로직은 예외 상황이 발생하면 request에 Attribte를 심어주고
마지막에 CustomAuthenticationEntryPoint에서 값을 꺼내 예외 처리를 한다.
여기서 문제는 허용된 요청에 만료된 토큰을 가지고 요청하는 경우이다!
permitAll은 필터를 완전 무시하는 게 아니라 예외가 발생하더라도 exceptionFilter까지 넘어가지 않기 때문에 예외가 발생하지 않고 컨트롤러 단으로 넘어가게 해준다.
하지만 아래 코드의 isExpired()에서 예외가 계속 던져지고 있었다.
if(jwtExtractor.isExpired(accessToken)){
request.setAttribute(JWT_ERROR, JWT_TOKEN_EXPIRED);
filterChain.doFilter(request, response);
return;
}
던져지는 예외는 exceptionFilter로 넘어가 예외가 발생한 상태로 필터단이 종료되어 해당 상황을 다시 클라이언트에게 넘겨주고 클라이언트는 반가운 401에러를 맞이한다.
3. Jwt 의존성 문제
다음은 필요한 코드만 잘라놓은거다.
public Boolean isExpired(String token) {
Claims claims = parseClaims(token);
return claims.getExpiration().before(new Date());
}
private Claims parseClaims(String token) {
try{
JwtParser parser = Jwts.parserBuilder()
.setSigningKey(key)
.build();
return parser.parseClaimsJws(token).getBody();
}catch (JwtException e){
throw new TokenInvalidException();
}
}
isExpired에서 parseClaims를 호출하고
parseClaims에서 parse.parseCliamsJws <--- 이부분에서 토큰 검증이 이루어져 그릇된 토큰이면 예외가 생겨나게 된다!
나는 이걸 try catch로 잡아서 다시 예외를 던져주고 이 예외는 exceptionFilter로 넘어가게된다.
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
대부분 위 jackson 이 라이브러리를 사용하면 나처럼 jwtProvider 혹은 jwtExtractor를 구현할 수 밖에 없는데 이는 토큰에서 Claims를 추출할 때마다 검증이 이루어지는 비효율적인 구현이 되어버린다.
따라서 위 사태를 해결하려면 검증 메서드를 따로 구현하고 JwtAuthenticationFilter에서 해당 검증메서드를 사용해 request에 attribute를 심어주면 된다.
public boolean validateJwtToken(String token) {
try {
JwtParser parser = Jwts.parserBuilder()
.setSigningKey(key)
.build();
parser.parseClaimsJws(token).getBody();
return true;
}catch (JwtException e){
return false;
}
}
따라서 나는 위와 같은 검증만 수행하는 메서드를 따로 만들어서 true false를 반환하게 했고 이를 JwtAuthenticationFilter에 추가해 엣지케이스를 해결했다.
수정된 JwtAuthenticationFilter
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtExtractor jwtExtractor;
private final static String JWT_ERROR = "jwtError";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
Optional<String> token = jwtExtractor.extractJwtToken(request);
if (token.isEmpty()) {
request.setAttribute(JWT_ERROR, JWT_TOKEN_NOT_FOUND);
filterChain.doFilter(request, response);
return;
}
String accessToken = token.get();
if (!jwtExtractor.validateJwtToken(accessToken)) {
request.setAttribute(JWT_ERROR, JWT_TOKEN_INVALID);
filterChain.doFilter(request, response);
return;
}
if (jwtExtractor.isExpired(accessToken)) {
request.setAttribute(JWT_ERROR, JWT_TOKEN_EXPIRED);
filterChain.doFilter(request, response);
return;
}
saveAuthentcation(accessToken);
filterChain.doFilter(request, response);
}
private void saveAuthentcation(String token) {
Long id = jwtExtractor.getId(token);
String email = jwtExtractor.getEmail(token);
String role = jwtExtractor.getRole(token);
UserDetails userDetails = JwtUserDetails.of(id, email, role);
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
이렇게하면 만료된 토큰이 들어올 경우 validateJwtToken 메서드가 false를 반환해 request에 예외 attribute을 심기만 하지 예외를 던지지 않기 때문에 허용된 경로인 경우 그냥 넘어가게 된다!
다음에는 이렇게..?
다음에는 jackson 의존성 말고 다음 의존성을 사용해서 JwtProvier과 JwtExtractor를 구현해봐야겠다!
implementation 'com.auth0:java-jwt:4.2.1'
이 의존성을 사용하면 뭔가 더 직관적이라서 코드 짜기 훨씬 수월할 거 같다!
'프로젝트에서 일어난 일' 카테고리의 다른 글
졸프: 라즈베리파이와 MSA를 곁들인 (0) | 2025.04.10 |
---|---|
Jwt토큰 인가 검증에서 일어난 간단한 사건! (0) | 2025.02.04 |
N+1 싹둑(Slice)해버리기 (0) | 2025.02.01 |
카카오, 구글 통합 로그인 中 (의사소통의 중요성) (0) | 2025.01.16 |
AccessToken을 상쾌하게 만드는 RefreshToken (0) | 2025.01.14 |