'USER'와 'ADMIN' 간 api 접근 인가 부분을 구현중에 있어서 Security 필터 체인에서 URI를 일일이 입력해서 허용할 API와 막아버릴 API를 구분하는 작업은 번거롭다고 생각했다.
// 권한 규칙 생성
http.authorizeHttpRequests(authorize -> authorize
.requestMatchers(AUTH_WHITELIST).permitAll() // @PreAuthorization을 사용하기 때문에 모든 경로에 대한 인증처리는 여기서 안함
.anyRequest().permitAll()
);
스프링 6점대부터는 람다식을 활용해서 체인 필터를 구성하는 방향을 지향하라는 Spring 공식문서를 읽었다. 더해서 이전까지는 .requestMatchers 함수 인자로 인증이 필요없는 URI들을 일일이 넣어주어야하는 불편함이 있었고, 작성하면서도 헷갈리던 부분이 많았다.
약간의 러닝타임이 길어질지 몰라도, 필터체인에서 URI를 구분하는 것이 아닌 Controller 계층에서 사용자 인증을 하기로 했다. @PreAuthorization 어노테이션을 활용해서 등급별 api 접근 제한을 두었고 테스트에 성공했다.
@GetMapping("/test1")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponseWrapper> test1(
){
return memberService.test1();
}
@GetMapping("/test2")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<ApiResponseWrapper> test2(){
return memberService.test2();
}
admin 관리자가 활용할 api들과 user가 활용할 api는 구분지어 계층화시켜두었기 때문에 헷갈릴 일도 없고 가시적으로 보이는 부분이다보니 코드를 작성할 때 있어서 효율적이라 생각했다.
Postman을 활용해 api 테스트를 진행하면서 한가지 문제 상황에 직면했다. 권한 문제와 같이 Security 단에서 Exception이 발생한다면 @ExceptionHandler가 예외처리를 하지 못하고 서블릿컨테이너가 먼저 예외처리를 해버리고, 어떤 예외처리는 동일한 응답메시지가 반환되었다.
{
"timestamp": "2024-02-21T05:43:48.787+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/api/v1/member/test1"
}
만약 요청이 인증되지 않는다거나 토큰이 만료되었거나 등 Controller 계층이 아닌 Security 영역에서 처리해버리는 예외는 어김없이 내부서버 에러 500번만 반환하고, 응답메세지 자체를 @ExceptionHandler로 커스텀할 수가 없었다.
언제나 에러 메시지는 500번 상태코드와 함께 동일한 포맷이었다. @ControllerAdvice 클래스 내부에 다음과 같이 메소드를 정의해두어도 소용이 없었다.
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponseWrapper> AccessDeniedExceptionHandler(AccessDeniedException e){
System.out.println("***********************************************************");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponseWrapper.fail("권한 없다!",e));
}
컨트롤러 계층이 아닌 Security 예외처리 과정에 접근하고자 필터체인 내부 과정에 변화를 주었다.
Security 필터에 의해서 발생하는 예외는 2가지가 있다. 인증에 대해서는 AuthenticationException이 발생하고,
인가에 대해서는 AccessDeniedException이 발생한다.
기존 코드 작성방식 다음과 같이 Security가 제공하는 기본 핸들러를 작성해두었다.
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable();
....
// 예외 핸들링 메서드
http.exceptionHandling( (exceptionHandling) -> exceptionHandling
.authenticationEntryPoint()
.accessDeniedHandler());
}
}
두 개의 내장 핸들러를 수정하기로 했다. 먼저 인증 핸들러부터 코드를 작성했다.
@Component
@Slf4j(topic = "UNAUTHORIZATION_EXCEPTION_HANDLER")
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private ObjectMapper objectMapper;
private HandlerExceptionResolver resolver;
@Autowired
public CustomAuthenticationEntryPoint(ObjectMapper objectMapper,
@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.objectMapper = objectMapper;
this.resolver = resolver;
}
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authenticationException) throws ServletException, IOException{
log.error("NOT Authenticated Request", authenticationException);
ErrorResponseDto errorResponseDto = new ErrorResponseDto(HttpStatus.UNAUTHORIZED.value(), "Authentication failed! :(", LocalDateTime.now());
String responseBody = objectMapper.writeValueAsString(errorResponseDto);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setCharacterEncoding("UTF-8");
response.getWriter().write(responseBody);
resolver.resolveException(request,response,null,authenticationException);
}
}
매번 동일한 포맷의 에러메시지가 오던 이유는 HttpServletResponse response가 리턴했기 때문이었다.
AuthenticationEntryPoint 인터페이스를 구현해서 HandlerExcpetionResolver를 bean에 주입시켰다. 알고보니 동일한 타입의 빈이 이미 존재했었고, 다음과 같은 에러를 직면했다.
Parameter 1 of constructor in fotcamp.finhub.common.security.CustomAuthenticationEntryPoint required a single bean, but 2 were found:
- errorAttributes: defined by method 'errorAttributes' in class path resource [org/springframework/boot/autoconfigure/web/servlet/error/ErrorMvcAutoConfiguration.class]
- handlerExceptionResolver: defined by method 'handlerExceptionResolver' in class path resource [org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration$EnableWebMvcConfiguration.class]
동일한 타입의 빈이 여러 개 있을 때 어떤 빈을 주입해야할지 스프링은 결정하지 못하기 때문에 @Qualifier 어노테이션을 활용해서 주입할 빈을 지정해주었다.
동일하게 인가 핸들러 인터페이스도 구현해서 코드를 작성했다.
response.flush()가 아닌 resolver가 예외처리를 하도록 코드를 작성했고,
@Component
@Slf4j(topic = "FORBIDDEN_EXCEPTION_HANDLER")
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
public CustomAccessDeniedHandler(ObjectMapper objectMapper, @Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.objectMapper = objectMapper;
this.resolver = resolver;
}
private final ObjectMapper objectMapper;
private final HandlerExceptionResolver resolver;
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws ServletException, IOException{
log.error("No Authorities", accessDeniedException);
ErrorResponseDto errorResponseDto =
new ErrorResponseDto(HttpStatus.FORBIDDEN.value(),"권한이 없습니다.", LocalDateTime.now());
String responseBody = objectMapper.writeValueAsString(errorResponseDto);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setCharacterEncoding("UTF-8");
response.getWriter().write(responseBody);
resolver.resolveException(request,response,null,accessDeniedException);
}
}
매번 동일하게 500번 에러코드만 오던 응답 메시지가
지금까지 공부했던 Spring Security의 전체 과정 설명과 코드는 조만간 총정리해서 글을 작성해보려고 한다.
Security 버전이 올라가면서 deprecated되는 어노테이션이나 메소드들이 자꾸 생겨 공식문서를 검색하는데 시간이 많이 소요가 된다. 매번 최신 버전의 Security를 고집하는건 안좋은 습관인 것 같다.
수정한 전체코드 첨부
filterChain 코드
@Configuration
@EnableWebSecurity
@AllArgsConstructor
@EnableMethodSecurity(securedEnabled = true)
public class SecurityConfig {
private final CustomUserDetailService customUserDetailService;
private final JwtUtil jwtUtil;
private final CustomAccessDeniedHandler customAccessDeniedHandler;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
private static final String[] AUTH_WHITELIST = {
"/api/v1/**"
};
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
// CSRF, CORS
http.csrf( (csrf) -> csrf.disable()); // CSRF 토큰 사용 X -> disable 설정
http.cors(Customizer.withDefaults()); // 다른 도메인의 웹 페이지에서 리소스에 접근할 수 있도록 허용
// 세션 관리 상태 없음으로 구성, SpringSecurity가 세션 생성
http.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(
SessionCreationPolicy.STATELESS));
// FormLogin, BasicHttp 비활성화
http.formLogin((form)->form.disable());
http.httpBasic(AbstractHttpConfigurer::disable);
http.exceptionHandling( (exceptionHandling) -> exceptionHandling
.authenticationEntryPoint(customAuthenticationEntryPoint)
.accessDeniedHandler(customAccessDeniedHandler));
// JwtAuthFilter를 UsernamePasswordAuthenticationFilter 앞에 추가
http.addFilterBefore(new JwtAuthFilter(customUserDetailService, jwtUtil), UsernamePasswordAuthenticationFilter.class);
// 권한 규칙 생성
http.authorizeHttpRequests(authorize -> authorize
.requestMatchers(AUTH_WHITELIST).permitAll() // @PreAuthorization을 사용하기 때문에 모든 경로에 대한 인증처리는 여기서 안함
.anyRequest().permitAll()
);
return http.build();
}
}
@Component
@Slf4j(topic = "FORBIDDEN_EXCEPTION_HANDLER")
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
public CustomAccessDeniedHandler(ObjectMapper objectMapper, @Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.objectMapper = objectMapper;
this.resolver = resolver;
}
private final ObjectMapper objectMapper;
private final HandlerExceptionResolver resolver;
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws ServletException, IOException{
log.error("No Authorities", accessDeniedException);
ErrorResponseDto errorResponseDto =
new ErrorResponseDto(HttpStatus.FORBIDDEN.value(),"권한이 없습니다.", LocalDateTime.now());
String responseBody = objectMapper.writeValueAsString(errorResponseDto);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setCharacterEncoding("UTF-8");
response.getWriter().write(responseBody);
resolver.resolveException(request,response,null,accessDeniedException);
}
}
@Component
@Slf4j(topic = "UNAUTHORIZATION_EXCEPTION_HANDLER")
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private ObjectMapper objectMapper;
private HandlerExceptionResolver resolver;
@Autowired
public CustomAuthenticationEntryPoint(ObjectMapper objectMapper,
@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.objectMapper = objectMapper;
this.resolver = resolver;
}
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authenticationException) throws ServletException, IOException{
log.error("NOT Authenticated Request", authenticationException);
ErrorResponseDto errorResponseDto = new ErrorResponseDto(HttpStatus.UNAUTHORIZED.value(), "Authentication failed! :(", LocalDateTime.now());
String responseBody = objectMapper.writeValueAsString(errorResponseDto);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setCharacterEncoding("UTF-8");
response.getWriter().write(responseBody);
resolver.resolveException(request,response,null,authenticationException);
}
}
'Backend' 카테고리의 다른 글
| [Spring Security] 시큐리티 프로세스 총정리 (3) | 2024.02.27 |
|---|---|
| [Spring Security] FilterChain, ExceptionHandler 커스터마이징2 (0) | 2024.02.23 |
| [Springboot] 한국수출입은행 환율 api 활용하여 환율정보 가져오기 (1) | 2024.02.08 |
| [JPA] 순환참조 에러 해결 (2) | 2024.02.02 |
| [Springboot] 금융 웹서비스 개발 프로젝트 - ChatGPT API 활용해서 대화해보기 (1) | 2024.01.27 |