인증이 필요한 요청이 들어왔을 때 JWT Access Token을 검증하는 과정을 설명한다.
public class JwtConstants {
// ...
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
// ...
}
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 1. 요청 헤더에서 토큰을 꺼낸다.
String accessToken = extractToken(request);
// 2. 토큰이 존재하지 않는 경우 다음 필터로 넘어간다.
if (accessToken == null) {
request.setAttribute("exception", SecurityExceptionCode.NOT_TOKEN);
filterChain.doFilter(request, response);
return;
}
// 3. 토큰이 유효하지 않은 경우 다음 필터로 넘어간다.
// 4. 정상 토큰이면 해당 토큰으로 Authentication을 가져와서 SecurityContext 에 저장한다.
try {
Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (JwtException e) {
request.setAttribute("exception", SecurityExceptionCode.INVALID_TOKEN);
filterChain.doFilter(request, response);
return;
} catch (UsernameNotFoundException e) {
request.setAttribute("exception", SecurityExceptionCode.USER_NOT_FOUND);
filterChain.doFilter(request, response);
return;
}
// 5. 다음 필터로 넘어간다.
filterChain.doFilter(request, response);
}
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader(JwtConstants.AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(JwtConstants.BEARER_PREFIX)) {
return bearerToken.substring(JwtConstants.BEARER_PREFIX.length());
}
return null;
}
}
💡 JwtFilter
- 인증이 필요한 요청이 들어왔을 경우 HTTP Request Header 에 담긴 JWT를 검증하기 위한 JwtFilter 클래스를 구현한다.
- JwtFilter 클래스는 OncePerRequestFilter 클래스를 확장한 클래스로, 모든 HTTP 요청에 대해 한 번씩만 실행되도록 보장한다.
- doFilterInternal 메서드에서 JWT를 검증한다.
- 유효한 경우 해당 토큰을 사용하여 사용자를 인증하고 SecurityContext에 저장한다.
- 유효하지 않은 경우 예외를 설정하고 필터 체인을 계속 진행한다.
- resolveToken 메서드에서는 HTTP Request Header에서 JWT를 추출한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(request -> request
.requestMatchers("/").permitAll()
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/products/**").permitAll()
.anyRequest().authenticated()
)
return http.build();
}
}
💡 AuthorizationFilter
- Spring Security에서 인증된 사용자의 권한을 확인하고, 특정 리소스에 대한 접근 권한을 결정하는 역할을 수행하는 필터이다.
- Spring Security 구성에서는 authorizeHttpRequests 메서드를 사용하여 이러한 권한을 정의하고, AuthorizationFilter가 이를 기반으로 요청을 처리한다.
- requestMatchers 메서드는 특정 URL 패턴에 대한 권한 설정을 지정하는 데 사용되며, 각 패턴에 대한 접근 규칙을 지정할 수 있다.
- permitAll()은 해당 URL 패턴에 대한 모든 사용자의 접근을 허용하는 권한 설정이다.
- 그외의 모든 요청은 인증된 사용자에게 허용한다.
@Component
public class JwtTokenProvider {
// ... (생략)
public boolean validateToken(String token) {
try {
parseClaims(token);
return true;
} catch (JwtException e) {
return false;
}
}
public Authentication getAuthentication(String accessToken) {
// 1. Access Token에서 claims을 파싱한다.
Claims claims = parseClaims(accessToken);
// 2. 파싱된 claims에 대한 검증을 수행한다.
if (claims.get(JwtConstants.AUTHORITIES_KEY) == null) {
throw new InvalidTokenException(SecurityExceptionCode.INVALID_TOKEN);
}
// 3. claims에서 authorities를 추출한다.
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(JwtConstants.AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// 4. claims에서 사용자 아이디를 추출한다,
Long userId = Long.parseLong(claims.getSubject());
// 5. claims에서 추출된 사용자 아이디를 사용하여 UserDetails를 생성한다.
// 6. 생성된 Authentication 객체를 반환한다.
UserDetails userDetails = customUserDetailsService.loadUserById(userId);
return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
}
private Claims parseClaims(String accessToken) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(accessToken)
.getBody();
}
// ... (생략)
}
⭐ validateToken Method
주어진 토큰이 유효한지 판단한다.
토큰이 유효하다는 것은 토큰이 변조되지 않았으며, 만료되지 않았으며, 올바른 서명을 가지고 있다는 것을 의미한다.
- Spring Security의 JWT 라이브러리인 io.jsonwebtoken에서 제공하는 메서드 체인 방식의 API를 사용하여 JWT를 검증하고 파싱하는 부분이다.
- 토큰이 유효하면 true 그렇지 않으면 false를 반환한다.
⭐ getAuthentication Method
주어진 토큰을 사용하여 Authentication 객체를 생성하여 반환한다.
- 토큰으로부터 클레임 정보를 추출하고, 클레임으로부터 sub 및 auth 정보를 추출한다.
- UsernamePasswordAuthenticationToken 클래스를 활용한다.
Spring Security 에서 Authentication 정보를 나타내는 클래스 중 하나이다.* Principal (주체) : UserDetails 인터페이스를 구현한 객체가 주체가 된다. 사용자의 식별 정보와 권한을 제공한다. * Credentials (자격 증명) : 주로 비밀번호를 나타낸다. 보안상의 이유로 주로 null로 설정된다. * Authorities (권한) : 사용자에게 부여된 역할이나 권한 목록을 나타낸다.

💡 JWT 검증 과정
- 디코딩
- 토큰은 BASE64 인코딩된 헤더, 페이로드, 서명의 세 부분으로 구성된다.
- 헤더와 페이로드를 BASE64 디코딩하여 원래 JSON 형식의 내용을 얻는다.
- 헤더 및 페이로드 확인
- 헤더에는 알고리즘 정보가 포함되어 있으며, 페이로드에는 클레임 정보가 들어 있다. 이 정보를 사용자가 기대하는 값과 비교하여 검증한다.
- 서명 검증
- 헤더에 지정된 알고리즘 정보를 확인한다.
- 해당 알고리즘을 사용하여 헤더와 페이로드를 합친 후, 서버의 비밀 키를 사용하여 서명을 생성한다.
- 생성된 서명과 제공된 서명을 비교하여 일치 여부를 확인한다.
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void configure(HttpSecurity httpSecurity) {
JwtFilter jwtFilter = new JwtFilter(jwtTokenProvider);
httpSecurity.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
}
}
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.apply(new JwtSecurityConfig(jwtTokenProvider));
return http.build();
}
}
💡 JwtSecurityConfig
- Spring Security의 SecurityConfigurerAdapter를 확장하여 특정한 설정을 제공하는 클래스이다. 이 클래스는 JWT를 사용한 인증을 구성하는 데 사용된다.
- 여기서 configure 메서드에서는 JwtFilter를 생성하고 이를 UsernamePasswordAuthenticationFilter 앞에 추가한다.
- JwtFilter는 주어진 JwtTokenProvider를 사용하여 JWT를 검증하고, 필요에 따라 사용자를 인증하는 역할을 수행한다.
- 이후 SecurityFilterChain에 JwtSecurityConfig를 적용하면 configure 메서드가 호출되어 추가적인 보안 구성이나 필터를 적용할 수 있다.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
ExceptionCode exception = (ExceptionCode) request.getAttribute("exception");
if (exception != null && exception.equals(SecurityExceptionCode.NOT_TOKEN)) {
exceptionHandler(response, SecurityExceptionCode.NOT_TOKEN);
return;
}
if (exception != null && exception.equals(SecurityExceptionCode.INVALID_TOKEN)) {
exceptionHandler(response, SecurityExceptionCode.INVALID_TOKEN);
return;
}
if (exception != null && exception.equals(SecurityExceptionCode.USER_NOT_FOUND)) {
exceptionHandler(response, SecurityExceptionCode.USER_NOT_FOUND);
return;
}
}
public void exceptionHandler(HttpServletResponse response, SecurityExceptionCode errorCode) throws IOException {
Map<String, Object> responseBody = new HashMap<>();
responseBody.put("status", errorCode.getStatus());
responseBody.put("code", errorCode.getCode());
responseBody.put("message", errorCode.getMsg());
String responseBodyJson = objectMapper.writeValueAsString(responseBody);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(401);
response.setCharacterEncoding("utf-8");
response.getWriter().write(responseBodyJson);
}
}
💡 JwtAuthenticationEntryPoint
- 인증되지 않은 사용자가 보호된 엔드포인트에 액세스하려고 할 때 (401 Unauthorized) 호출될 JwtAuthenticationEntryPoint를 커스텀하게 작성한다.
- 인증을 수행하는 JwtFilter에서 발생한 예외를 받아서 처리하고, JSON 형태의 API 응답으로 변환하는 역할을 수행한다.
- commence 메서드: AuthenticationEntryPoint 인터페이스의 메서드로, 인증 예외가 발생했을 때 호출된다.
- exceptionHandler 메서드: objectMapper를 사용하여 status, code, message를 담은 JSON을 생성하고, HttpServletResponse를 통해 클라이언트에게 전달한다.
- Spring Security는 최종적인 인증/인가 요청을 AuthorizationFilter에서 판단한다.
- 여기서 인증이 필요한 요청인데 인증이 되어 있지 않다거나, 필요한 권한이 존재하지 않는 요청을 AuthorizationDecision이라는 클래스로 판단한다.
- 이때 요건이 충족되지 않았다고 판단이 되면 예외를 던지고 이는 AuthorizationFilter의 바로 앞 필터인 ExceptionTranslationFilter에서 처리하게 된다.
- 이 때, 인증이 필요한 요청에 인증이 되지 않은 요청이라면 ExceptionTranslactionFilter는 AuthenticationEntryPoint의 메서드를 호출한다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.exceptionHandling()
.accessDeniedHandler(jwtAccessDeniedHandler)
.authenticationEntryPoint(jwtAuthenticationEntryPoint);
}
💡 exceptionHandling()
- 위에서 커스텀한 JwtAuthenticationEntryPoint를 SecurityFilterChain에 등록한다.
- 이렇게 설정하면 해당 예외가 발생했을 때 등록된 커스텀 핸들러들이 호출되어 적절한 응답을 생성하게 된다.