이 글과 이어진다.
SecurityConfig
AuthToken
, AuthTokenProvider
, AuthTokenFilter
, ...)를 정의했으므로, 이를 사용하기 위한 Spring security Configuration을 작성한다.AuthTokenProvier
, AuthTokenFilter
를 Spring bean으로 등록한다.AuthTokenFilter
라는 Custom filter를 정의했을 뿐, 실제로 요청이 이 filter를 거치는 게 아니다.SecurityConfig
에서 작성한다.AuthTokenFilter
예외 처리SecurityConfig
AuthTokenFilter
, AuthTokenProvider
등 필요한 요소를 Java Bean으로 등록한다.AuthTokenFilter
를 등록한다.import java.util.Arrays;
import java.util.List;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.CorsUtils;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import com.example.demo.user.auth.entrypoint.JwtAuthenticationEntryPoint;
import com.example.demo.user.auth.filter.AuthTokenFilter;
import com.example.demo.user.auth.handler.JwtAccessDeniedHandler;
import com.example.demo.user.auth.token.AuthTokenProvider;
import lombok.RequiredArgsConstructor;
@Configuration
@EnableWebSecurity //EntryPoint, AccessDeniedHandler를 사용하기 위함
@RequiredArgsConstructor
public class SecurityConfig {
private final AuthTokenProvider tokenProvider;
// 인증 토큰 없이 접근 가능한 URL
private final String[] PERMIT_ALL = new String[] {
"/api/user/login",
"/api/user/join",
//Swagger 3를 사용하는 경우 아래의 주석을 해제 한다.
// "/swagger-ui", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", "/configuration/**"
};
@Bean
public AuthTokenFilter authTokenFilter() {
return new AuthTokenFilter(tokenProvider, redisService);
}
@Bean
// 인증 단계에서 예외 발생 시 여기로 간다.
public JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint() {
return new JwtAuthenticationEntryPoint();
}
@Bean
// 원래는 인가 관련 예외(AccessDeniedException) 발생 시 여기로 간다.
// '원래는'이라고 한 이유는 실제로 이 handler가 실행된 걸 못 봤기 때문....
// 이게 유효하려면 요청 마다 권한 설정을 해줘야 한다.
public JwtAccessDeniedHandler jwtAccessDeniedHandler() {
return new JwtAccessDeniedHandler();
}
@Bean
// Spring security에서 제공하는 form login 사용 시
// 이 Bean에 의해 패스워드가 인코딩되지만
// 현재는 해당사항이 없다(이전 글 참조).
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(10); // default rounds: 10
}
@Bean
// 핵심적인 부분.
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// Stateless하므로 CSRF 방어가 불필요하다.
http.csrf(AbstractHttpConfigurer::disable);
// Sessrion 정책을 stateless로 설정한다 ≡ Session을 비활성화한다(사용하지 않는다).
http.sessionManagement(
(session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
// UsernamePasswordAuthenticationFilter 앞에 AuthTokenFilter를 등록한다.
// 즉 어떤 요청에 대해 AuthTokenFilter를 거친 후 UsernamePasswordAuthenticationFilter를 거치게 된다.
http.addFilterBefore(authTokenFilter(), UsernamePasswordAuthenticationFilter.class);
// 요청 인가에 관한 옵션 설정
http
.authorizeHttpRequests(
authorizationManagerRequestMatcherRegistry ->
authorizationManagerRequestMatcherRegistry
.requestMatchers(PERMIT_ALL).permitAll() // PERMIT_ALL의 패턴 해당하는 URL에 대한 요청은 허용한다.
// Swagger 3 접근 허용이 안된다면 다음을 추가한다.
// .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.anyRequest().authenticated() // 그 외의 요청은 모두 인증을 요구한다.
)
.exceptionHandling(
configurer -> configurer
.authenticationEntryPoint(jwtAuthenticationEntryPoint()) // 인증 관련 예외 발생 시 여기로 이동된다.
.accessDeniedHandler(jwtAccessDeniedHandler())
);
return http.build();
}
}
http.authorizeHttpRequests()
사용 시 주의사항일단 순서가 유의미하다는 게 가장 중요하다. 방화벽 정책처럼 먼저 설정한 게 우선이다.
http
.authorizeHttpRequests(
authorizationManagerRequestMatcherRegistry ->
authorizationManagerRequestMatcherRegistry
.requestMatchers(PERMIT_ALL).permitAll()
.anyRequest().authenticated()
);
이 경우 정책은 다음과 같다.
PERMIT_ALL
허용즉 PERMIT_ALL
에 대해서는 인증을 요구하지 않는 게 우선되므로, 뒤에서 모든 요청에 대해 인증을 요구하면 PERMIT_ALL
을 제외한 요청에 대해서만 인증을 요구하게 된다.
http
.authorizeHttpRequests(
authorizationManagerRequestMatcherRegistry ->
authorizationManagerRequestMatcherRegistry
.anyRequest().authenticated()
.requestMatchers(PERMIT_ALL).permitAll()
);
이 경우 정책은 다음과 같다.
PERMIT_ALL
허용즉 모든 요청에 대해 인증 요구 가 우선되므로 뒤에서 PERMIT_ALL
에 대해 허용해봤자 적용되지 않는다.
JwtAuthenticationEntryPoint
import java.io.IOException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException
) throws IOException {
// 인증 실패 시 처리
}
}
JwtAccessDeniedHandler
import java.io.IOException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException
) throws IOException {
// 인가 실패 시 처리
}
}
두 경우 모두 적절한 처리 내용을 넣으면 된다.
내 경우 적절한 에러를 응답으로 보내주기만 했다. 필요한 절차는 상황에 맞게 추가하거나 제거하면 된다.
이것으로 SecurityConfig
작성이 끝났다.
다음은 AuthTokenFilter
에서 예외 처리를 추가할 차례다.
AuthTokenFilter
...
public class AuthTokenFilter extends OncePerRequestFilter {
private final AuthTokenProvider tokenProvider;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String token = request.getHeader("Authorization"); //헤더에서 토큰 정보를 얻는다.
try {
// claim을 얻어냄으로써 다음을 검증한다.
// 1. 우리가 발급한 토큰이 맞는가?
AuthToken authToken = new AuthToken(token);
if (tokenProvider.validate(authToken)) {
//토큰
Authentication authentication = tokenProvider.getAuthentication(authToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
} catch (ExpiredJwtException je) {
//예외 처리
}
}
}
예외는 tokenProvider.validate(authToken)
에서 발생한다.
필터에서 토큰과 관련해 예외를 발생시키는 경우는 토큰이 없는 경우가 아니라 토큰이 만료된 경우다.
따라서 validate도 다음과 같이 수정한다.
AuthTokenProviderImpl
...
@Override
public boolean validate(AuthToken token) throws ExpiredJwtException {
// token의 claim을 얻는 과정에서 예외 발생 ≡ token이 유효하지 않다
Claims claims = token.getClaims(this.key);
return claims != null;
}
...
원래는 JwtException
을 throw
했지만, 이 경우 토큰이 필요없는 요청에서도 예외가 발생한다(token == null
이니까).
따라서 토큰이 만료된 경우 발생하는 예외인 ExpiredJwtException
만 throw
한다.
(JwtException
은 JWT 관련 여러 예외의 super class다.)
그런데 여기서 throw
되는 예외는 token.getClaims()
에서 발생하는 예외고, token.getClaims()
는 JwtException
을 throw
하고 있다. 따라서 AuthToken
클래스에서 getClaims()
도 수정할 필요가 있다.
AuthToken
...
public Claims getClaims(SecretKey key) throws ExpiredJwtException {
Claims claims = null;
try {
claims = Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (ExpiredJwtException e) {
throw e;
} catch (JwtException ignored) {
}
return claims;
}
...
이 경우 ExpiredJwtException
을 제외한 다른 JWT 예외는 모두 무시되며(ignored
), ExpiredJwtException
만 throw
하게 된다.
이렇게 하는 이유는 요청에 토큰이 없는 경우 발생하는 JWT 예외를 무시하기 위함이다.
이제 이에 맞춰 AuthTokenFilter
를 다시 수정해보자.
AuthTokenFilter
...
public class AuthTokenFilter extends OncePerRequestFilter {
private final AuthTokenProvider tokenProvider;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String token = request.getHeader("Authorization"); //헤더에서 토큰 정보를 얻는다.
try {
// claim을 얻어냄으로써 다음을 검증한다.
// 1. 우리가 발급한 토큰이 맞는가?
AuthToken authToken = new AuthToken(token);
if (tokenProvider.validate(authToken)) {
//토큰
Authentication authentication = tokenProvider.getAuthentication(authToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
} catch (ExpiredJwtException je) {
HttpStatus unauthorized = HttpStatus.UNAUTHORIZED;
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
ResponseEntity<ErrorResponse> responseBody = new ResponseEntity<>(
new ErrorResponse(
unauthorized.value(),
"인증 정보가 만료되었습니다. 다시 로그인해주세요."
),
unauthorized
);
ServletOutputStream outputStream = response.getOutputStream();
outputStream.write(new Gson().toJson(responseBody).getBytes(StandardCharsets.UTF_8));
}
}
ErrorResponse
는 프로젝트에서 따로 선언한 클래스다.Gson
은 구글에서 제공하는 JSON parsing 라이브러리로, Jackson
에 비해 사용법이 매우 간단해서 개인적으로 선호한다. 의존성 추가만 하면 끝이다.SecurityConfig
AuthToken
, AuthTokenProvider
, AuthTokenFilter
, ...)를 정의했으므로, 이를 사용하기 위한 Spring security Configuration을 작성한다.AuthTokenProvier
, AuthTokenFilter
를 Spring bean으로 등록한다.AuthTokenFilter
라는 Custom filter를 정의했을 뿐, 실제로 요청이 이 filter를 거치는 게 아니다.SecurityConfig
에서 작성한다.AuthTokenFilter
예외 처리모두 했다.
JWT는 변조에 강하지만 탈취에는 약하다.
다음과 같은 시나리오를 생각해보자.
- 사용자 U가 로그인에 성공해 토큰을 발급받았다.
- 공격자 A가 U의 토큰을 탈취했다.
- 사용자 U가 로그아웃했다.
만약 로그아웃에서의 절차가 쿠키 삭제 뿐이라면 A는 U의 토큰으로 서비스를 사용할 수 있게 된다(stateless이므로 A가 보내든 B가 보내든 토큰에만 의존적으로 인증한다).
즉 서버에서는 로그아웃 시 해당 사용자의 토큰을 파기된 토큰 으로 간주하고, 주어진 토큰이 파기된 토큰 인지 식별할 수 있어야 한다.
이를 위해 Redis를 사용하며, 다음과 같이 쓰인다.
- 사용자 로그아웃 시 해당 토큰을 Redis에 저장한다(만료기간은 토큰의 그것과 동일하게 설정한다).
AuthTokenFilter
에서는 요청으로 들어온 토큰이 Redis에 있는지 체크한다.- 요청된 토큰이 Redis에 있다면 파기된 토큰으로 간주하고 예외를 발생시킨다.