PREVIEW
이전 프로젝트에서 Spring Security를 전담하지 않았고, 사용자의 권한이 필요한지 확인하거나 로그인한 사용자의 정보를 가져올 때 @AuthenticationPrincipal 어노테이션을 사용.
이 방식은 프로젝트 진행 자체에는 문제가 없었지만, 사용자의 권한만 확인하면 되는 상황에서도 사용자의 정보를 전체적으로 불러오는 문제가 있다.
@AuthenticationPrincipal 특성상 로그인하지 않은 사용자에 대해 에러를 반환하지 않고 사용자의 정보를 가져오고, 로그인하지 않았을 경우 null로 주입이 된다.
또한 해당 정보를 기반으로 DB에 접근하여 ID가 존재하는지 조회하고, 존재할 경우 권한이 있는지 확인을 하게된다.
이렇게되면 로그인하지 않아도 서비스 계층과 데이터베이스까지 접근하고, 상황에 적절하지 않은 방식인거 같다고 생각했지만 시큐리티에 대해 잘 알지 못해 끝까지 찝찝함과 아쉬움이 남아 이번 프로젝트에서는 Spring MVC Interceptor 기반의 HandlerInterceptor를 사용하여 사용자의 권한을 체크하기로 했다.
HandlerInterceptor 적용하기
먼저 가장 마음에 들지 않았던 엔드포인트에 접근하자마자 권한을 검사하는 것이 아닌 메서드 실행 후 검증하는 것부터 해결하기 위해 커스텀 어노테이션을 생성.
@Retention(RUNTIME)
@Target(METHOD)
public @interface AuthorizationRequired {
Role[] value();
HttpStatus status() default HttpStatus.UNAUTHORIZED;
}
그리고 이 어노테이션을 사용하기 위해 aop를 사용하지 않고 Spring MVC Interceptor의 HandlerInterceptor의 구현체를 생성.
public class AuthorizationInterceptor implements HandlerInterceptor {
/**
* 메서드가 실행되기 전에 권한이 있는지 확인하고
* 권한이 있을 경우 메서드 싫랭
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
AuthorizationRequired annotation = getAnnotation(handler);
if (annotation == null) {
return true;
}
Collection<? extends GrantedAuthority> possibleAuthority = roleToAuthority(annotation.value());
if (!hasAuthority(possibleAuthority)) {
throw new AuthorizationException(ErrorCode.USER_NOT_LOGIN);
}
return true;
}
/**
* 접근 가능한 권한이 하나라도 존재하는지 확인
*/
private boolean hasAuthority(Collection<? extends GrantedAuthority> possibleAuthority) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return authentication != null && authentication.getAuthorities()
.stream().anyMatch(possibleAuthority::contains);
}
/**
* 메서드에 AuthorizationRequired 어노테이션이 달려있는지 확인
*/
private AuthorizationRequired getAnnotation(Object handler) {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
return handlerMethod.getMethodAnnotation(AuthorizationRequired.class);
}
return null;
}
/**
* 권한이 있는지 확인
*/
private Collection<? extends GrantedAuthority> roleToAuthority(Role[] required) {
return Arrays.stream(required)
.map(Role::getValue)
.map(SimpleGrantedAuthority::new)
.toList();
}
}
그리고 Spring MVC Interceptor가 동작할 수 있게 위 코드 파일을 빈등록을 한다.
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authorizationInterceptor())
.excludePathPatterns("/swagger-ui/**");
}
@Bean
AuthorizationInterceptor authorizationInterceptor() {
return new AuthorizationInterceptor();
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE","PATCH", "OPTIONS", "HEAD")
.allowedHeaders("*")
.exposedHeaders("Set-Cookie")
.allowCredentials(true);
}
}
이렇게 설정을 해주고 처음에 만든 어노테이션을 적용해주면된다.
@PatchMapping("/me")
@AuthorizationRequired(value = {Role.USER, Role.ADMIN}, status = OK)
public ResponseEntity<Void> updateProfile(@RequestBody UpdateProfile updateProfile) {
userService.updateImage(updateProfile);
return ResponseEntity.ok().build();
}
이렇게 사용하게되면 권한 확인만 필요할 경우 @AuthenticationPrincipal을 붙여서 유저객체 자체를 받지않을 수 있고 또한 컨트롤러 메서드 수행 전에 작용하기 때문에 불필요한 쿼리문이 나가지 않게된다.
마무리
Interceptor를 구현하면서 생각보다 시간이 오래 걸렸다.
코드를 보면 HandlerMethod handlerMethod = (HandlerMethod) handler; 이렇게 타입 캐스팅해주는 부분에서 CORS Preflight에러가 떳고 또한 handler 타입이 HandlerMethod 타입이 아니라고 한다.
먼저 CORS Prefligth 에러는 테스트할 때 요청을 DELETE와 GET으로 요청을 했는데 GET은 되고 DELETE에서 막히게 된다.
먼저 허용하는 요청인 .allowedMethods("GET", "POST", "PUT", "DELETE","PATCH", "OPTIONS", "HEAD")를 보면
대부분의 HTTP 요청을 허용하는 것을 알 수 있지만 처음에는 GET, POST, PUT, DELETE만 허용을 했다.
결론부터 말하면 OPTIONS가 없었기 때문에 에러가 떳다.
CORS에는 Preflight Request라는 규칙이 있는데
DELETE, PUT 또는 특정 조건을 만족하는 HTTP 요청을 브라우저가 보내기 전에, 서버가 요청을 허용하는지 확인하기 위해 자동으로 OPTIONS 요청을 먼저 보내는데 OPTIONS를 허용하지 않아 발생한 문제였다.
그리고 handler 타입이 HandlerMethod 타입이 아니라는 에러는 간단하게 해결할 수 있었다
HandlerMethod handlerMethod = (HandlerMethod) handler;이 메서드를 보면 무조건 타입 캐스팅을 하려고 하는데
자바에서 지원하는 instanceof를 사용하여 타입이 맞을 경우에만 타입캐스팅을 하게 만들어 주어 해결할 수 있었다.
이렇게 Interceptor를 구현하면서 aop와의 차이점이 뭔지 생각하게 되었고 이전 프로젝트에서 겪었던 찝찝함을 해소했다.
'개발' 카테고리의 다른 글
@JoinColumn의 name 속성과 referencedColumnName 속성의 기본 값 (2) | 2024.11.22 |
---|---|
ansible 기본 실행 옵션 (1) | 2024.10.04 |
네이버 클라우드 Jenkins를 활용한 배포 자동화 (1) | 2024.07.25 |
공통 검증 메서드 모듈화 및 메서드 분리 (0) | 2024.07.25 |
Spring Boot validation @NotBlank (0) | 2024.07.18 |