서론
해당 포스팅은 P-프로젝트를 진행하면서 진행했었던, Spring Security에서 사용되는 JWT 인증/인가 에 대해 작성해보겠다
Spring Security는 애플리케이션에서 인증/인가 에 대한 설정을 편리하게 도와주는 역할을 해준다
위 사진들을 보면 알 수 있듯이, Filter는 클라이언트의 요청이 서버에 도달하기 전에 실행되어 JWT 토큰의 유효성을 검사하고, 해당 토큰에 기반한 사용자 인증을 수행한다 따라서 Filter 가 하나의 영역에서 인증 인가를 수행하는 " 단위 " 라고 봐주면 될거같다
1. 사용자 토큰 검증을 통한 인증/인가 를 수행하는 JWT Filter 를 만들어서 유효성 검사(토큰이 유효하지 않거나 존재하지 않는 경우, 예외를 발생)
2. 인증 성공시, 검증된 토큰을 기반으로 사용자의 정보와 권한을 담은 Authentication 객체를 생성하여 SecurityContext에 저장
JWT 인증/인가는 여러 방법이 있겠지만, 크게는 위 단계로 이루어진다고 생각해주면 된다. 세부적으로 어떻게 구현했는지는 아래 부분에서 자세히 다루어보겠다
본문
JwtFilter
JWT 인증/인가에서 JwtFilter 플로우는 아래 내용으로 구현했다
1. 먼저 아래의 getToken 메서드를 호출해서, HttpServletRequest 객체의 헤더에서 Authorization 값을 가져온 JWT 토큰을 추출해준 뒤에 존재하지 않으면 null을 반환
String token = getToken(request);
log.debug("Extracted Token: {}", token);
2. Authorization 값이 존재하면 Bearer 이후의 순수 토큰 문자열을 반환해주어서 JwtProvider의 validateToken 메서드를 통해 토큰의 유효성을 검증
if (token != null) {
jwtProvider.validateToken(token);
}
3. 검증에 성공한 토큰을 기반으로 Authentication 객체(사용자 정보 + 권한을 포함)를 생성
Authentication authentication = jwtProvider.getAuthentication(token);
4. Spring Security에서 제공하는 SecurityContextHolder로 인증된 사용자 처리
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Authentication set in SecurityContext: {}", authentication);
5. JwtException catch로 나머지 예외 처리
} catch (JwtException e) {
log.info("error token: {}", e.getMessage());
request.setAttribute("jwtException", INVALID_TOKEN.getCode());
}
전체 코드의 내용은 아래와 같다
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String token = getToken(request);
log.debug("Extracted Token: {}", token);
try {
if (token != null) {
jwtProvider.validateToken(token);
Authentication authentication = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Authentication set in SecurityContext: {}", authentication);
}
} catch (JwtException e) {
log.info("error token: {}", e.getMessage());
request.setAttribute("jwtException", INVALID_TOKEN.getCode());
}
filterChain.doFilter(request, response);
}
// request의 헤더에 Authorization에서 토큰을 추출하고 Bearer 뒷 부분 JWT 토큰만 분리하여 반환
private String getToken(HttpServletRequest request) {
String token = request.getHeader("Authorization");
if (StringUtils.hasText(token) && token.startsWith("Bearer ")) {
// "Bearer "를 뺀 부분만 반환
return token.substring(7);
}
return null;
}
JwtProvider
Jwt provider에서는 토큰 생성, 토큰 파싱, 토큰 검증 등등의 기능들을 구현했다
AccessToken + RefreshToken 토큰 생성 메서드
public String generateAccessToken(String email) {
final Date now = new Date();
final Date expiration = new Date(now.getTime() + accessTokenExpirationTime);
return Jwts.builder()
.setSubject(email)
.setIssuedAt(now)
.setExpiration(expiration)
.signWith(key)
.compact();
}
public String generateRefreshToken() {
return UUID.randomUUID().toString();
}
parseToken으로 JWT 토큰을 파싱하여 Claims, 토큰의 payload 부분 반환
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
validateToken 메서드로 토큰의 만료여부, JWT 표준에 맞는 형식인지, 구조가 올바르지 않은지, 토큰 변조여부, 파싱할 수 없는값 등등의 여부를 검증
public void validateToken(String token) {
try {
parseToken(token);
} catch (ExpiredJwtException e) {
log.warn("JwtProvider: Expired token");
throw e;
} catch (UnsupportedJwtException e) {
log.warn("JwtProvider: Unsupported token");
throw e;
} catch (MalformedJwtException e) {
log.warn("JwtProvider: Malformed token");
throw e;
} catch (SignatureException e) {
log.warn("JwtProvider: Signature exception");
throw e;
} catch (IllegalArgumentException e) {
log.warn("JwtProvider: IllegalArgumentException");
throw e;
}
}
결론
SecurityConfig에서 적용
SecurityConfig에서 SecurityFilterChain을 통해서 filterChain을 구성하여, 모든 요청에서 jwtFilter 를 적용해서 JWT 토큰의 유효성을 검증할 수 있도록 설정해주었다
아래는 SecurityConfig 코드의 일부인데 해당 내용을 참고해주면 더 좋을거같다
.addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exceptioHandling ->
exceptioHandling
.authenticationEntryPoint(customAuthenticationEntryPoint))
.build();
}
@Bean
public JwtFilter jwtFilter() {
return new JwtFilter(jwtProvider); // JwtFilter를 Bean으로 등록하고 JwtProvider를 주입
}
아래는 내가 작성한 API 명세서 일부의 사진이다
느낀점
3학년 1학기 Leets에서 처음 JWT 관련 개념을 접하고, JWT 인증/인가를 적용한 로그인을 했었을때 너무 막막했었던 기억이 있다
당시에는 JWT 자체가 너무 생소했고, SecurityConfig 설정이나 Filter 적용 과정에서 어려움을 겪으면서 해결하지 못한 부분도 많았다 😂
얼마 전에 위 사진에 나온 PR 코드를 다시 확인했는데, 그때 작성했던 코드들을 보며 지금과 비교해보니 정말 많이 성장하였다는 것을 느꼈다. 그동안의 시행착오가 큰 경험이 되었고, 앞으로도 계속 성장해나갈 수 있을 것이라는 확신도 들었다 🔥
참고한 자료