인터셉터가 개발자가 사용하기 더 편하고 제공하는 기능이 더 많으며 인터셉터를 사용하면 Spring AOP
를 사용해서 JWT
인증을 어노테이션으로 관심사 분리를 할 수 있기 때문에 인터셉터를 활용하여 JWT
인증을 하는게 좋다고 생각할 수 있다. 하지만 여기에는 함정이 있다.
먼저 Filter
와 Inteceptor
의 차이점에 대해 알아보자.
서블릿 사양의 일부로, 서블릿 컨테이너에서 실행된다.
모든 요청에 대해 동작하며, Spring Context
외부의 요청과 응답도 처리할 수 있다. 이는 Spring Security
가 Spring MVC
밖에서도 작동할 수 있게 하는 핵심적인 이유이다.
Spring Security
는 자체 필터 체인을 통해 인증과 인가 과정을 관리하며, 사용자 정의 필터를 이 체인에 쉽게 추가할 수 있다.
필터는 요청이 DispatcherServlet
에 도달하기 전에 실행된다. 따라서 인증과 같이 모든 요청에 대해 처리해야 하는 로직을 구현하기에 적합하다.
Spring MVC
의 일부로, DispatcherServlet
이 컨트롤러를 호출하기 전, 후 그리고 완료 후에 추가 작업을 수행할 수 있도록 한다.
Spring Context
내부에서만 작동하며, 주로 컨트롤러의 실행을 가로채는 데 사용된다.
인증보다는 요청의 사전 처리, 로깅, 트랜잭션 관리 등에 더 적합하다.
사용자 정의 인터셉터를 구성하기 쉽고, 요청과 응답에 대한 높은 수준의 제어를 제공한다.
Filter
와 Interceptor
그리고 DispatcherServlet
에 대해 더 알고 싶다면 이 링크의 글을 읽어보자
Spring Security
의 아키텍처 : Spring Security
는 광범위한 보안 요구 사항을 처리하기 위해 설계된 복잡한 필터 체인을 가지고 있다. 이 체인은 인증과 인가 과정을 관리하며, JWT 인증 로직을 필터로 구현하면 이 체인에 자연스럽게 통합된다.
요청 처리의 초기 단계에서 인증 : 인증은 보통 요청 처리의 가장 초기 단계에서 이루어져야 한다. 필터는 Spring MVC
의 DispatcherServlet
에 요청이 도달하기 전에 실행되므로, 모든 요청에 대해 인증 로직을 수행하기에 적합하다.
보안 컨텍스트 설정 : 인증 후, 사용자의 보안 컨텍스트를 설정해야 하는데, 이는 Spring Security
의 필터 체인을 통해 자연스럽게 처리된다. 인증 정보는 Spring Security
의 SecurityContextHolder
에 저장되며, 이 정보는 요청 처리 과정에서 다른 필터와 Spring MVC
컨트롤러에서 접근할 수 있다.
편의성 및 통합성 : Spring Security
는 JWT 인증을 포함한 다양한 보안 매커니즘을 필터를 통해 제공하고 있으며, 이를 사용하면 별도의 인증 처리 로직을 구현할 필요 없이 효율적으로 보안 요구사항을 충족할 수 있다.
Interceptor
는 매우 유용하고 강력한 도구이지만, 인증과 같은 보안 관련 로직을 처리하기에는 몇 가지 제약 있다.
Spring MVC
에 한정된 실행 : Interceptor
는 Spring MVC
의 컨텍스트 내에서만 실행되므로, Spring MVC
를 통하지 않는 요청(예: 정적 리소스 접근, 다른 종류의 서블릿 요청 등)은 처리할 수없다. 반면, 필터는 서블릿 컨테이너 수준에서 작동하므로 모든 요청에 대한 인증 로직을 적용할 수 있다.
보안 처리에 필요한 초기 단계 실행의 어려움 : 보안 처리는 요청 처리의 매우 초기 단계에서 이루어져야 한다. Interceptor
는 DispatcherServlet
에 의해 관리되므로, 요청이 Spring MVC
에 도달하기 전에 이미 필요한 보안 처리가 완료되어야 하는 경우가 많다. 필터는 이러한 요구 사항을 자연스럽게 충족시킬 수 있다.
Spring Security
와의 통합 : Spring Security
는 보안 관련 처리를 위한 복잡한 필터 체인을 제공한다. 이 체인 내에서 인증 및 인가 과정을 관리하며, 사용자 정의 필터를 쉽게 추가할 수 있다. Interceptor
를 사용하는 경우, Spring Security
의 기존 구조와 별도로 동작하기 때문에, 보안 처리를 위한 일관된 방식을 유지하기 어렵다.
관련된 의존성을 추가한다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
}
application.properties
설정 파일을 통해 설정 값들을 중앙 집중화하여 관리할 수 있으며, 프로파일에 따라 다른 설정을 적용할 수 있다.
# JWT
jwt.secret=Popcorn
jwt.expiration=86400000
OncePerRequestFilter
를 상속 받아서 구현한다.
OncePerRequestFilter
를 상속 받는 이유
OncePerRequestFilter
는Spring Security
의 필터 중 하나로, 하나의 요청에 대해 한 번만 실행되도록 보장하는 필터이다. 이는 서블릿 컨테이너에 의해 요청이 여러 번 처리될 수 있는 상황(요청이 여러번 처리 되는 이유)에서 특히 유용하다. 예를 들어, 요청이 디스패치되어 여러 필터를 거치는 경우(예: 에러 페이지로의 리디렉션, 요청의 내부 포워딩 등) 하나의 요청에 대해 해당 필터가 여러 번 실행되는 것을 방지한다.
@Slf4j
@Component
@Profile({"local", "server"})
@RequiredArgsConstructor
public class JwtRequestFilter extends OncePerRequestFilter {
public static final String TOKEN_PREFIX = "Bearer ";
public static final String HEADER_STRING = "Authorization";
private static final List<String> EXCLUDE_URL = List.of(
"/api/user/external/oauth/token",
"/favicon.ico",
"/swagger/**",
"/swagger-resources/**",
"/swagger-ui/**", "/webjars/**", "/swagger-ui.html",
"/v3/api-docs/**"
);
@Value("${jwt.secret}")
public static String SECRET;
@Value("${jwt.expiration-time}")
public static int EXPIRATION_TIME;
@Override
protected void doFilterInternal(HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {
try {
final String jwtHeader = request.getHeader(HEADER_STRING);
log.info("JWT Filter 진입");
if (pathMatchesExcludePattern(request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
if (jwtHeader == null || jwtHeader.isEmpty()) {
request.setAttribute("exception", JwtErrorCode.NOTFOUND_TOKEN);
throw new JwtFilterException(JwtErrorCode.NOTFOUND_TOKEN);
}
if (!jwtHeader.startsWith(TOKEN_PREFIX)) {
request.setAttribute("exception", JwtErrorCode.UNSUPPORTED_TOKEN);
throw new JwtFilterException(JwtErrorCode.UNSUPPORTED_TOKEN);
}
String token = jwtHeader.replace(TOKEN_PREFIX, "");
request.setAttribute("userCode", JWT.require(Algorithm.HMAC512(SECRET)).build().verify(token).getClaim("id").asLong());
} catch (TokenExpiredException e) {
request.setAttribute("exception", JwtErrorCode.EXPIRED_TOKEN);
throw new JwtFilterException(JwtErrorCode.EXPIRED_TOKEN);
} catch (JWTVerificationException e) {
request.setAttribute("exception", JwtErrorCode.WRONG_TYPE_TOKEN);
throw new JwtFilterException(JwtErrorCode.WRONG_TYPE_TOKEN);
} catch (JwtFilterException e) {
filterChain.doFilter(request, response);
return;
}
filterChain.doFilter(request, response);
}
private boolean pathMatchesExcludePattern(String requestURI) {
AntPathMatcher pathMatcher = new AntPathMatcher();
for (String excludeUrl : EXCLUDE_URL) {
if (pathMatcher.match(excludeUrl, requestURI)) {
return true;
}
}
return false;
}
}
jwt
속성 정의public static final String TOKEN_PREFIX = "Bearer ";
public static final String HEADER_STRING = "Authorization";
private static final List<String> EXCLUDE_URL = List.of(
"/api/user/external/oauth/token",
"/favicon.ico",
"/swagger/**",
"/swagger-resources/**",
"/swagger-ui/**", "/webjars/**", "/swagger-ui.html",
"/v3/api-docs/**"
);
@Value("${jwt.secret}")
public static String SECRET;
@Value("${jwt.expiration-time}")
public static int EXPIRATION_TIME;
application.properties
에 정의한 속성을 불러오고, 앞으로 사용할 상수들과 인증을 제외할 URL List
들을 정의한다.
doFilter
구현doFilterinternal
을 오버라이딩해서 필터의 로직을 구현하면 된다.
주석의 설명을 보자.
예외 처리에 대한 설명은 밑에 다시 설명하겠다. 이번에는 그냥 예외를 request
에 담아서 보내는구나 정도로 이해하자.
@Override
protected void doFilterInternal(HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {
try {
final String jwtHeader = request.getHeader(HEADER_STRING);
log.info("JWT Filter 진입");
if (pathMatchesExcludePattern(request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
if (jwtHeader == null || jwtHeader.isEmpty()) {
request.setAttribute("exception", JwtErrorCode.NOTFOUND_TOKEN);
throw new JwtFilterException(JwtErrorCode.NOTFOUND_TOKEN);
}
if (!jwtHeader.startsWith(TOKEN_PREFIX)) {
request.setAttribute("exception", JwtErrorCode.UNSUPPORTED_TOKEN);
throw new JwtFilterException(JwtErrorCode.UNSUPPORTED_TOKEN);
}
String token = jwtHeader.replace(TOKEN_PREFIX, "");
request.setAttribute("userCode", JWT.require(Algorithm.HMAC512(SECRET)).build().verify(token).getClaim("id").asLong());
} catch (TokenExpiredException e) {
request.setAttribute("exception", JwtErrorCode.EXPIRED_TOKEN);
throw new JwtFilterException(JwtErrorCode.EXPIRED_TOKEN);
} catch (JWTVerificationException e) {
request.setAttribute("exception", JwtErrorCode.WRONG_TYPE_TOKEN);
throw new JwtFilterException(JwtErrorCode.WRONG_TYPE_TOKEN);
} catch (JwtFilterException e) {
filterChain.doFilter(request, response);
return;
}
filterChain.doFilter(request, response);
}
// 인증 제외 URI 인지 확인하는 로직
private boolean pathMatchesExcludePattern(String requestURI) {
AntPathMatcher pathMatcher = new AntPathMatcher();
for (String excludeUrl : EXCLUDE_URL) {
if (pathMatcher.match(excludeUrl, requestURI)) {
return true;
}
}
return false;
}
이제 구현한 JWT 필터를 서블릿 컨테이너에게 사용하라고 알려주는 설정 작업을 해야 한다. Spring Security
에게 JwtRequestFilter
를 사용하라고 알려주자.
원래는
SecurityConfig.java
에서WebSecurityConfigurerAdapter
을 상속받아 인가 기능을 구현하였다. 하지만Spring Security 6.0
버전 기준으로WebSecurityConfigurerAdapter
가deprecated
되면서 더는 사용할 수 없다.SecurityFilterChain
을Bean
으로 등록하여 사용하자.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
public static final String FRONT_URL = "http://localhost:3000";
private final CorsFilter corsFilter;
private final JwtRequestFilter jwtRequestFilter;
@Bean
public BCryptPasswordEncoder encodePwd() {
return new BCryptPasswordEncoder();
}
/**
* SecurityFilterChain
* <p>
* WebSecurityConfigurerAdapter 가 deprecated 되면서 SecurityFilterChain 을 Bean 으로 등록하여 사용
* </p>
*
* @param http HttpSecurity
* @return SecurityFilterChain
* @throws Exception 예외
*/
@Bean
@Order(SecurityProperties.BASIC_AUTH_ORDER)
protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// http 시큐리티 빌더
return http
.httpBasic() // JWT token을 사용하므로 basic 인증 disable
.disable()
.csrf() // csrf 비활성화
.disable()
.sessionManagement() // session 을 사용하지 않음
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests()
.requestMatchers(FRONT_URL + "/main/**").permitAll()
.anyRequest()
.authenticated()
.and()
.exceptionHandling() // 예외 처리
.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.and()
.formLogin() // form login disable
.disable()
.addFilter(corsFilter) // @CrossOrigin(인증X), 시큐리티 필터에 등록 인증(O)
.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class) // JWT 필터 등록
.build();
}
}
코드가 매우 긴데 중요한 부분만 따로 보자.
@EnableWebSecurity
: Spring Security
설정을 활성화하기 위한 어노테이션이다. 웹 보안 관련 기능을 활성화하는데 사용된다.
.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class)
: JWT 필터를 UsernamePasswordAuthenticationFilter
뒤에 추가 한다.
UsernamePasswordAuthenticationFilter
UsernamePasswordAuthenticationFilter
는Spring Security
에서 제공하는 필터 중 하나로, 사용자의 이름과 비밀번호를 사용하여 인증을 수행한다. 이 필터는 일반적으로 폼 기반 로그인을 처리할 때 사용된다. 사용자가 로그인 폼을 통해 자신의 이름과 비밀번호를 제출하면, 이 필터가 해당 정보를 받아 인증 과정을 수행한다.
JwtRequestFilter
와 필터 순서JWT 기반 인증 방식에서는 폼 로그인을 사용하지 않으므로,
UsernamePasswordAuthenticationFilter
가 인증을 처리하기 전에 JWT 토큰을 먼저 검증하는 것이 중요하다. 그래서addFilterBefore
를 사용하여Jwt
필터가UsernamePasswordAuthenticationFilter
보다 먼저 실행되게 순서를 지정해 주었다.
@Override
protected void doFilterInternal(HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {
try {
final String jwtHeader = request.getHeader(HEADER_STRING);
log.info("JWT Filter 진입");
if (pathMatchesExcludePattern(request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
if (jwtHeader == null || jwtHeader.isEmpty()) {
request.setAttribute("exception", JwtErrorCode.NOTFOUND_TOKEN);
throw new JwtFilterException(JwtErrorCode.NOTFOUND_TOKEN);
}
if (!jwtHeader.startsWith(TOKEN_PREFIX)) {
request.setAttribute("exception", JwtErrorCode.UNSUPPORTED_TOKEN);
throw new JwtFilterException(JwtErrorCode.UNSUPPORTED_TOKEN);
}
String token = jwtHeader.replace(TOKEN_PREFIX, "");
request.setAttribute("userCode", JWT.require(Algorithm.HMAC512(SECRET)).build().verify(token).getClaim("id").asLong());
} catch (TokenExpiredException e) {
request.setAttribute("exception", JwtErrorCode.EXPIRED_TOKEN);
throw new JwtFilterException(JwtErrorCode.EXPIRED_TOKEN);
} catch (JWTVerificationException e) {
request.setAttribute("exception", JwtErrorCode.WRONG_TYPE_TOKEN);
throw new JwtFilterException(JwtErrorCode.WRONG_TYPE_TOKEN);
} catch (JwtFilterException e) {
filterChain.doFilter(request, response);
return;
}
filterChain.doFilter(request, response);
}
doFiletInternal
을 오버라이딩한 코드에서 예외가 발생하면 request.setAttribute("exception", JwtErrorCode.NOTFOUND_TOKEN);
이렇게 request
에 헤더를 exception
으로 값은 커스텀 ErrorCode
를 넘겨주는 걸 볼 수 있다. 또한 JwtFilterException
에러를 던지면 밑에서 JwtFilterException
에러를 잡아서 바로 다음 필터로 넘겨주고 return
한다.
왜 예외를 던져서 @RestControllerAdvice
에서 전역적으로 예외 처리를 하지 않는 것 일까?
@RestControllerAdvice
에 대한 정보는 이 링크의 글을 읽어보자
@ControllerAdvice
에서 처리할 수 없을까?일반적으로 예외는 클라이언트 측에서 서버로 보낸 요청을 애플리케이션의 컨트롤러가 받은 후 요청에 대한 비즈니스 로직을 처리하는 과정에서 발생한다. 즉, 일단 요청이 컨트롤러에 도달한 다음에 예외가 발생하는 것이다.
하지만 스프링 시큐리티는 요청이 컨트롤러에 도달하기 전에 필터 체인
에서 예외를 발생시킨다. 앞서 이야기했듯이 @ControllerAdvice
는 컨트롤러 계층에서 발생하는 예외를 처리하는데, 요청이 컨트롤러에 도달하기도 전에 이미 예외가 발생해서 요청이 컨트롤러에 도달하지도 못했기 때문에 @ControllerAdvice
에서 처리를 할 수가 없다.
AuthenticationEntryPoint
스프링 시큐리티에서는 사용자가 인증되지 않았거나 AuthenticationException
이 발생했을 때 AuthenticationEntryPoint
에서 예외 처리를 시도한다. 따라서 AuthenticationEntryPoint
의 구현체를 적절하게 이용하면 된다.
@Getter
public enum JwtErrorCode {
NOTFOUND_TOKEN(400, "Authorization이 없습니다."),
UNSUPPORTED_TOKEN(400, "Bearer로 시작하지 않습니다."),
EXPIRED_TOKEN(400, "토큰이 만료되었습니다."),
WRONG_TYPE_TOKEN(400, "유효하지 않은 토큰입니다.");
private int code;
private String message;
JwtErrorCode(int status, String message) {
this.code = status;
this.message = message;
}
}
먼저 에러 코드를 사용해서 에러를 관리하기 쉽게 Enum
으로 정의해둔다.
JwtRequestFilter
에서 JWT Token
검증 시 예외 발생 코드를 보자.
if (jwtHeader == null || jwtHeader.isEmpty()) {
request.setAttribute("exception", JwtErrorCode.NOTFOUND_TOKEN);
throw new JwtFilterException(JwtErrorCode.NOTFOUND_TOKEN);
}
예외가 발생하면 request
의 attribute
의 exception
에 JwtErrorCode
를 넣어주고 JwtFilterException
에러를 발생시킨다.
catch (JwtFilterException e) {
filterChain.doFilter(request, response);
return;
}
그럼 발생시킨 JwtFilterException
을 밑에서 잡아서 예외를 발생시키지 않고 request
에 JwtFilterException
가 담긴채 다음 필터로 넘기고 return
한다.
위에서
throw new JwtFilterException(JwtErrorCode.NOTFOUND_TOKEN);
를 지우고 바로 다음 필터로 넘기고return
해도 되지만 검증 로직이 많으므로 검증로직에 실패하면 에러를 던지고 이 에러를 처리하는 곳은 한 곳에서 관리하도록 에러를 던지고 받는 로직으로 구성하였다.
CustomAuthenticationEntryPoint
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
Object exceptionObj = request.getAttribute("exception");
String errorMessage = "인증 에러가 발생했습니다."; // 기본 에러 메시지
if (exceptionObj instanceof JwtErrorCode jwtErrorCode) {
setResponse(response, jwtErrorCode.getCode() + " : " + jwtErrorCode.getMessage());
} else {
setResponse(response, errorMessage);
}
}
private void setResponse(HttpServletResponse response, String errorMessage) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter()
.println("{\"errorMessage\": \"" + errorMessage + "\"}");
}
}
AuthenticationEntryPoint
구현한 CustomAuthenticationEntryPoint
이다.
여기서는 Object exceptionObj = request.getAttribute("exception");
를 통해 "exception"
에 들어가 있는 객체를 꺼내온다. 이 객체가 JwtErrorCode
의 자식이라면 클라이언트에게 보내는 응답을 커스텀한 JwtErrorCode
으로 구성하여 더 자세한 커스텀 에러 응답을 보내준다.
"Bearer 12313123..."
형식으로 된 JWT Token
을 복호화하고 암호화하는 서비스를 구현한다.
@Service
public class JwtService {
public Long getUserIdByJWT(HttpServletRequest request) {
String jwtHeader = request.getHeader(HEADER_STRING);
String token = jwtHeader.replace(TOKEN_PREFIX, "");
return JWT.require(Algorithm.HMAC512(SECRET)).build().verify(token).getClaim("id").asLong();
}
public String createJWTToken(String email, Long userId, String name) {
return JWT.create()
.withSubject(email)
.withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.withClaim("id", userId)
.withClaim("nickname", name)
.sign(Algorithm.HMAC512(SECRET));
}
}
public String createJWTToken(String email, Long userId, String name) {
return JWT.create()
.withSubject(email)
.withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.withClaim("id", userId)
.withClaim("nickname", name)
.sign(Algorithm.HMAC512(SECRET));
}
.withSubject(email)
: JWT의 주제를 설정한다. 사용자의 이메일 주소를 주제로 사용하였다.
.withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
: 토큰의 만료 시간을 설정한다.
.withClaim("id", userId) .withClaim("nickname", name)
: 토큰에 포함할 추가 정보를 설정한다. 사용자 ID 와 name
을 추가하였다.
.sign(Algorithm.HMAC512(SECRET));
: 토큰에 서명을 추가한다. 비밀키를 기반으로 HMAC512
알고리즘으로 서명한다. 서명은 토큰이 변경되지 않았음을 검증하는데 사용한다.
public Long getUserIdByJWT(HttpServletRequest request) {
String jwtHeader = request.getHeader(HEADER_STRING);
String token = jwtHeader.replace(TOKEN_PREFIX, "");
return JWT.require(Algorithm.HMAC512(SECRET)).build().verify(token).getClaim("id").asLong();
}
String jwtHeader = request.getHeader(HEADER_STRING);
: Authorization
헤더의 값인 Bearer 12313123..."
을 가져온다.
String token = jwtHeader.replace(TOKEN_PREFIX, "");
: Bearer 12313123..."
에서 앞의 Berer
부분을 없애주고 순수한 토큰 값만 대입한다.
JWT.require(Algorithm.HMAC512(SECRET)).build()
: 설정한 SECRET
값을 사용하여 HMAC512
알고리즘으로 서명된 JWT를 검증할 준비를 한다.
.verify(token).getClaim("id").asLong();
: 토큰 값을 검증하여 id
에 지정된 값을 Long
타입으로 가져온다.
public UserDto getCurrentUser(HttpServletRequest request) {
Long userIdByJWT = jwtService.getUserIdByJWT(request);
User user = userRepository.findById(userIdByJWT).orElseThrow(UserNotFoundException::new);
return UserDto.builder()
.profileImgUrl(user.getProfileImgUrl())
.nickname(user.getNickname())
.email(user.getEmail())
.provider(user.getProvider())
.userRole(user.getUserRole())
.description(user.getDescription())
.createTime(user.getCreateTime())
.build();
}
UserService
코드에서 jwtService
를 사용하는 코드이다.
jwtService
를 이용해서 request
에 담긴 JWT Token
값을 userId
값으로 복호화한다.