[JWT를 활용한 일반 로그인 API 구현] 과정을 기록해보려고 한다. 프로젝트 개발 환경은 아래와 같다.
Java: 17
JDK: 17.0.6
IDE: IntelliJ IDEA 2024.1
Framework: Spring Boot 3.3.1
Spring Security의 구조(Filter)와 인증(Authentication) 구조에 대한 설명은 아래 글을 참고하자.
JWT 토큰 인증을 지원하는 Custum Security Filter 생성
Spring Security에선 원한다면 Custum Filter를 만들어 SecurityFilterChain에 추가할 수 있다. JWT와 함께 들어온 요청을 처리하는 Custum Security Filter를 만들어 보자.
JwtAuthenticationFilter
JwtAuthenticationFilter는 JWT와 함께 들어온 요청을 처리하는 Custum Security Filter이며, OncePerRequestFilter를 상속받는다. OncePerRequestFilter를 상속받으면 하나의 HttpServletRequest에 대해 한 번만 실행되는 Filter로 만들 수 있다. 즉, 여러 개의 Filter가 동작하면서 이미 지나온 Filter가 중복으로 호출되는 경우를 막을 수 있다.
- 예를 들어 1번부터 10번까지의 Filter가 존재하는 경우를 생각해보자. 3번 Filter에서 아래 again() API를 호출하면, HttpServletRequest가 1번 Filter로 이동하게 된다. 그러면 이미 지난 1번 Filter와 2번 Filter가 또 동작해야 하기 때문에 자원 낭비가 발생하게 된다.
- again() { // 1번 Filter로 Forwarding하는 메서드 }
- OncePerRequestFilter는 이 Filter 상속받은 Filter가 실행됐다는 걸 HttpServletRequest의 속성에 저장한다.
- 실행된 Filter의 이름과 boolean 값(TRUE)을 저장하고, forwarding이나 dispatch 등으로 Filter를 실행해야 할 일이 생기면 해당 값을 확인한다. 이미 실행됐다면(boolean 값이 TRUE) 다음 Filter를 실행한다.
- 여기서 OncePerRequestFilter는 Spring Security가 지원하는 Filter가 아니라 Spring Web에서 지원하는 Filter다.
- 코드로 살펴보면 아래와 같다.
public abstract class OncePerRequestFilter extends GenericFilterBean {
public static final String ALREADY_FILTERED_SUFFIX = ".FILTERED";
public OncePerRequestFilter() {
}
public final void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (request instanceof HttpServletRequest httpRequest) {
if (response instanceof HttpServletResponse httpResponse) {
String alreadyFilteredAttributeName = this.getAlreadyFilteredAttributeName();
boolean hasAlreadyFilteredAttribute = request.getAttribute(alreadyFilteredAttributeName) != null;
if (!this.skipDispatch(httpRequest) && !this.shouldNotFilter(httpRequest)) {
if (hasAlreadyFilteredAttribute) {
if (DispatcherType.ERROR.equals(request.getDispatcherType())) {
this.doFilterNestedErrorDispatch(httpRequest, httpResponse, filterChain);
return;
}
filterChain.doFilter(request, response);
} else {
request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
try {
this.doFilterInternal(httpRequest, httpResponse, filterChain);
} finally {
request.removeAttribute(alreadyFilteredAttributeName);
}
}
} else {
filterChain.doFilter(request, response);
}
return;
}
}
throw new ServletException("OncePerRequestFilter only supports HTTP requests");
}
}
동작 방식은 아래와 같다.
- HttpServletRequest에서 토큰을 추출한다.
- 추출한 토큰에 값이 담겨 있고, 검증에 실패하지 않았다면 인증이 완료된 것이므로, 토큰의 claim에 담긴 정보로 Authentication 객체를 생성하고 SecurityContextHolder에 설정한다.
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
// 요청과 들어온 토큰을 뽑아내 유효한 토큰인지 확인하고 인증을 하는 Filter
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = jwtUtil.resolveToken(request);
if (token != null && jwtUtil.validateToken(token)) {
Authentication authentication = jwtUtil.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
JwtAuthenticationFilter의 예외를 처리해 줄 핸들러 Filter 생성 & 응답 양식 통일
Spring Security의 Filter 구조를 살펴보면, AccessDeniedException(403 FORBIDDEN; 접근 거부)과 AuthenticationException(401 UNAUTHORIZED; 인증 정보 없음)은 ExceptionTranslationFilter를 통해 처리된다. 흐름은 아래와 같다.
1. ExceptionTranslationFilter가 요청을 넘긴 다음 Filter에서 AuthenticationException이나 AccessDeniedException이 발생한다.
2. ExceptionTranslationFilter에서 캐치하고 AuthenticationEntryPoint나 AccessDeniedHandler에 보내 처리한다.
이렇게 Security Filter를 지나면서 발생한 에러는 Security Filter 위치에서 발생하고 걸러지기 때문에 Servlet까지 전달될 수 없다. 따라서 Controller 단에서는 JwtAuthenticationFilter에서 발생한 토큰 관련한 예외(JwtException) 처리를 해줄 수 없다는 뜻이다.
그래서 JwtAuthenticationFilter에서 발생한 JwtException을 처리하고, 해당 에러를 통일된 응답으로 만들어 HttpServletResponse에 담아 보여주는 Filter를 따로 구현해야 한다. 그 후 JwtAuthenticationFilter 바로 앞에 여기서 만든 Filter를 추가하면 된다.
JwtExceptionHandler
이전 글에서 토큰을 검증하는 메서드를 구현했었다. 토큰을 파싱하는 과정에서 토큰에 문제가 있다면 JwtException을 발생시킨다.
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtUtil implements InitializingBean {
...
public boolean validateToken(String token) {
try {
Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
throw new JwtExceptionHandler(ErrorStatus.WRONG_TYPE_SIGNATURE.getMessage(), e);
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
throw new JwtExceptionHandler(ErrorStatus.TOKEN_EXPIRED.getMessage(), e);
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
throw new JwtExceptionHandler(ErrorStatus.WRONG_TYPE_TOKEN.getMessage(), e);
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
throw new JwtExceptionHandler(ErrorStatus.NOT_VALID_TOKEN.getMessage(), e);
}
}
...
}
JwtExceptionHandler는 JwtException을 처리하는 핸들러로, 발생한 JwtException의 에러 메시지와 원인을 넘겨줘야 한다.
public class JwtExceptionHandler extends JwtException {
public JwtExceptionHandler(String message) {
super(message);
}
public JwtExceptionHandler(String message, Throwable cause) {
super(message, cause);
}
}
JwtExceptionHandlerFilter
JwtExceptionHandlerFilter는 JwtAuthenticationFilter의 예외를 처리하는 Filter 역할을 한다.
- JwtAuthenticationFilter와 동일하게 OncePerRequestFilter를 상속받는다.
- JwtAuthenticationFilter의 바로 앞에 존재하며, 동작 방식은 다음과 같다.
- filterChain.doFilter(request, response);를 통해 다음 Filter인 JwtAuthenticationFilter에 요청을 넘긴다.
- JwtAuthenticationFilter에서 JwtExceptionHandler가
- 동작하면, 해당하는 JwtException을 통일된 응답으로 만들고 HttpServletResponse에 담는다.
- 동작하지 않으면, 아무것도 하지 않고 넘어간다.
@Component
public class JwtExceptionHandlerFilter extends OncePerRequestFilter {
// JwtAuthenticationFilter의 예외 처리를 해주는 Filter
// 해당 Filter의 앞에 존재하며, 해당 Filter에서 발생하는 에러 메시지를 컨트롤러에 전달할 수 있게 해줌
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch(JwtExceptionHandler ex) {
setErrorResponse(HttpStatus.UNAUTHORIZED, request, response, ex);
}
}
public void setErrorResponse(HttpStatus status, HttpServletRequest req,
HttpServletResponse res, Throwable ex) throws IOException {
ApiResponse<Object> apiResponse =
ApiResponse.onFailure(HttpStatus.UNAUTHORIZED.name(), "COMMON401", ex.getMessage());
String responseBody = new ObjectMapper().writeValueAsString(apiResponse);
res.setStatus(status.value());
res.setContentType("application/json");
res.setCharacterEncoding("UTF-8");
res.getWriter().write(responseBody);
}
}
AuthenticationEntryPoint & AccessDeniedHandler & 응답 양식 통일
위에서 살펴본 ExceptionTranslationFilter의 흐름을 다시 살펴보자.
1. ExceptionTranslationFilter가 요청을 넘긴 다음 Filter에서 AuthenticationException이나 AccessDeniedException이 발생한다.
2. ExceptionTranslationFilter에서 캐치하고 AuthenticationEntryPoint나 AccessDeniedHandler에 보내 처리한다.
즉, Security Exception(AuthenticationException || AccessDeniedException)이 발생하면 AuthenticationEntryPoint나 AccessDeniedHandler에 정의해 둔 코드에 따라 우리에게 에러 메시지가 전달된다.
해당 에러가 발생하면 통일된 응답으로 HttpServletResponse에 담아 보여줄 수 있도록 AuthenticationEntryPoint와 AccessDeniedHandler의 구현체를 각각 만들어 보자.
JwtAuthenticationEntryPoint
AuthenticationEntryPoint의 구현체로, 클라이언트로부터 자격 증명(ex. JWT)을 요청하는 역할을 한다. AuthenticationException이 발생하면 통일된 응답(401 UNAUTHORIZED)을 만들어 HttpServletResponse에 담아 보여준다.
@Slf4j
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
// 요청(Entry) 시 액세스 토큰이 필요한데 토큰이 요청과 함께 들어오지 않은 경우 에러 메시지를 전달
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
log.error("Not Authenticated Request", authException);
ApiResponse<Object> apiResponse =
ApiResponse.onFailure(HttpStatus.UNAUTHORIZED.name(), "COMMON401", "인증이 필요합니다.");
String responseBody = new ObjectMapper().writeValueAsString(apiResponse);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setCharacterEncoding("UTF-8");
response.getWriter().write(responseBody);
}
}
JwtAccessDeniedHandler
AccessDeniedHandler의 구현체로, 접근이 거부됐음을 알리는 역할을 한다. AccessDeniedException이 발생하면 통일된 응답(403 FORBIDDEN)을 만들어 HttpServletResponse에 담아 보여준다.
@Slf4j
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
// 권한이 없거나 접근할 수 없는 곳에 요청을 한 경우 에러 메시지를 전달
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
log.error("No Authorities", accessDeniedException);
ApiResponse<Object> apiResponse =
ApiResponse.onFailure(HttpStatus.FORBIDDEN.name(), "COMMON403", "금지된 요청입니다.");
String responseBody = new ObjectMapper().writeValueAsString(apiResponse);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setCharacterEncoding("UTF-8");
response.getWriter().write(responseBody);
}
}
SecurityFilterChain 빈으로 등록하기
Security와 관련된 설정을 하기 위해 SecurityConfig를 만들고, 지금까지 만든 Filter와 핸들러를 SecurityFilterChain 빈에 추가해 보자.
SecurityConfig
@Configuration을 사용해 SecurityConfig를 빈으로 등록하고, @EnableWebSecurity를 사용해 SecurityFilterChain을 빈으로 등록한다. 이전에 MemberService에 서 썼던 BCryptPasswordEncoder도 빈으로 등록한다. 여기서 가장 중요한 건 filterChain 메서드다.
http 속성 | 설명 | Filter |
cors | CORS 비활성화 | CorsFilter |
csrf | CSRF 공격에 대한 보안 비활성화 | CsrfFilter |
formLogin | form login 방식 비활성화 | UsernamePasswordAuthenticationFilter |
httpBasic | http basic 인증 방식 비활성화 | |
sessionManagement | 서버를 무상태로 관리하기 위해 session 관리 비활성화 | SessionManagementFilter |
exceptionHandling | Exception을 처리하는 핸들러를 따로 구현했을 경우, 여기에서 설정할 수 있음 | ExceptionTranslationFilter |
authorizeHttpRequests | 인가를 진행하기 전, HttpRequest 경로에 따라 인증 완료의 필요 여부를 결정 | AuthorizationFilter |
addFilterBefore(A, B) | B Filter 앞에 A Filter를 추가 |
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
// 예외 처리, 필터 추가 등 보안에 필요한 설정을 처리하는 Configuration
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final JwtExceptionHandlerFilter jwtExceptionHandlerFilter;
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
private static final String[] AUTH_WHITELIST = {
"/v2/api-docs", "/v3/api-docs/**",
"/configuration/ui", "/swagger-resources/**",
"/configuration/security", "/swagger-ui.html",
"/webjars/**", "/file/**", "/image/**",
"/swagger/**", "/swagger-ui/**", "/h2/**"
};
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
http
.cors(cors -> cors.configurationSource(new CorsConfigurationSource() {
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Collections.singletonList("*"));
configuration.setAllowedMethods(Collections.singletonList("*"));
configuration.setAllowCredentials(true);
configuration.setAllowedHeaders(Collections.singletonList("*"));
configuration.setMaxAge(3600L);
configuration.setExposedHeaders(Collections.singletonList("Authorization"));
return configuration;
}
}))
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling((exception) -> exception
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler))
.authorizeHttpRequests((request) -> request
.requestMatchers("/member/signup/**").permitAll()
.requestMatchers("/member/login").permitAll()
.requestMatchers("/health").permitAll() // health check
.requestMatchers(AUTH_WHITELIST).permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtExceptionHandlerFilter, JwtAuthenticationFilter.class);
return http.build();
}
}
참고자료