Search

[스프링] 인터셉터에서 인가(Authorization) 처리 공통화하기

Last update: @7/1/2023

인증(Authentication)과 인가(Authorization)

인증(Authentication)은 로그인을 뜻함
인가(Authorization)는 허가를 뜻함
어떤 서비스를 로그인만 해도 이용할 수 있도록 하는 것은 인증을 통해 인가를 하는 것이고,
로그인을 했더라도 어드민만 손댈 수 있다면 인증 후 권한에 따라 인가를 하는 것이며,
엑세스 토큰만 제출하면 서비스를 이용할 수 있다면 인증 없이 인가를 하는 것이라 볼 수 있음

인터셉터 인가(Authorization) 처리 공통화

각 컨트롤러마다 특정 자원(주로 DB 데이터)에 대해 유저가 권한을 가지고 있는지 일일이 확인하는 일은 심히 번거로움
이를 해결하기 위해 스프링 인터셉터에서 아래와 같은 방법으로 공통된 인가 처리를 구현함
인가가 필요한 자원에 접근하기 위해서는 해당 자원의 id가 파라미터 키로 넘어가는 점에서 착안함
학습(learning)이라면 learningId, 문장(sentence)은 sentenceId 등이 파라미터 키가 됨
URI 쿼리 파라미터, POST body 쿼리 파라미터, PathVariable에 해당 자원의 id가 포함되어있을 경우 인가 검증을 진행하도록 인터셉터를 추가함

AuthorizationService 클래스 추가

먼저 AuthorizationService 클래스를 만들어 스프링 빈으로 등록함
@Component @RequiredArgsConstructor @Transactional(readOnly = true) @Slf4j public class AuthorizationService { private final LearningRepository learningRepository; private final SentenceRepository sentenceRepository; private final CollectionSentenceRepository collectionSentenceRepository; private final CollectionLearningRepository collectionLearningRepository; public static final String[] RESOURCE_PARAM_KEYS = new String[]{"learningId", "sentenceId", "collectionSentenceId", "collectionLearningId"}; public void validate(String paramKey, String paramValue, Authentication authentication) { validate(paramKey, new String[]{paramValue}, authentication); } public void validate(String paramKey, String[] paramValues, Authentication authentication) { List<Long> ids = Arrays.stream(paramValues) .filter(StringUtils::hasText) .map(Long::valueOf) .toList(); if (ids.size() == 0) { return; } try { paramKey = paramKey.substring(0, 1).toUpperCase() + paramKey.substring(1); Method method = this.getClass().getMethod("validate" + paramKey, List.class, Long.class); boolean isValid = (boolean) method.invoke(this, ids, authentication.getMemberId()); if (!isValid) { throw new UnauthorizedException("허가되지 않은 접근입니다."); } } catch (InvocationTargetException | NoSuchMethodException | IllegalAccessException e) { throw new IllegalStateException(e); } } public boolean validateLearningId(List<Long> ids, Long memberId) { return learningRepository.findUnauthorizedOne(ids, memberId).isEmpty(); } public boolean validateSentenceId(List<Long> ids, Long memberId) { return sentenceRepository.findUnauthorizedOne(ids, memberId).isEmpty(); } public boolean validateCollectionSentenceId(List<Long> ids, Long memberId) { return collectionSentenceRepository.findUnauthorizedOne(ids, memberId).isEmpty(); } public boolean validateCollectionLearningId(List<Long> ids, Long memberId) { return collectionLearningRepository.findUnauthorizedOne(ids, memberId).isEmpty(); } }
Java
복사
RESOURCE_PARAM_KEYS는 HTTP 요청에 포함될 경우 인가 검증을 진행할 리소스 파라미터 키(id) 목록임
먼저 파라미터 키 하나에 여러 값이 들어올 수 있고, 빈 문자열도 들어올 수 있기 때문에 필터링을 거친 후 Long 타입으로 변환함
List<Long> ids = Arrays.stream(paramValues) .filter(StringUtils::hasText) .map(Long::valueOf) .toList();
Java
복사
파라미터값의 첫 문자를 대문자로 바꾼 후 리플렉션으로 검증 메서드를 호출함
try { paramKey = paramKey.substring(0, 1).toUpperCase() + paramKey.substring(1); Method method = this.getClass().getMethod("validate" + paramKey, List.class, Long.class); boolean isValid = (boolean) method.invoke(this, ids, authentication.getMemberId()); if (!isValid) { throw new UnauthorizedException("허가되지 않은 접근입니다."); } } catch (InvocationTargetException | NoSuchMethodException | IllegalAccessException e) { throw new IllegalStateException(e); }
Java
복사
리소스의 id값이 learningId라고 하면 validateLearningId 메서드를 호출하게 됨
검증 메서드에서는 요청된 리소스 id값들 중 유저에게 권한이 없는 것이 하나라도 있는지 확인함
public boolean validateLearningId(List<Long> ids, Long memberId) { return learningRepository.findUnauthorizedOne(ids, memberId).isEmpty(); }
Java
복사
아래는 QueryDSL JPA 코드. in절과 not equal을 통해 권한 없는 한 개를 찾음
@Override public Optional<Object> findUnauthorizedOne(List<Long> ids, Long memberId) { return Optional.ofNullable( queryFactory. selectFrom(sentence) .where( sentence.id.in(ids), memberIdNe(memberId) ) .limit(1) .fetchOne() ); }
Java
복사

AuthorizationInterceptor 클래스 추가

이제 요청을 통해 넘어오는 파라미터 이름을 조사해서 RESOURCE_PARAM_KEYS에 있을 경우 검증을 진행함
@RequiredArgsConstructor public class AuthorizationInterceptor implements HandlerInterceptor { private final AuthorizationService authorizationService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Authentication authentication = (Authentication) request.getAttribute("authentication"); Map<String, String[]> parameterMap = request.getParameterMap(); for (String paramKey : AuthorizationService.RESOURCE_PARAM_KEYS) { String[] resourceIds = parameterMap.get(paramKey); if (resourceIds != null && resourceIds.length > 0) { authorizationService.validate(paramKey, resourceIds, authentication); } } Map<String, String> pathVariables = (Map<String, String>) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); if (pathVariables != null) { for (String paramKey : AuthorizationService.RESOURCE_PARAM_KEYS) { String resourceId = pathVariables.get(paramKey); if (StringUtils.hasText(resourceId)) { authorizationService.validate(paramKey, resourceId, authentication); } } } return true; } }
Java
복사
authentication attribute는 인증 인터셉터로부터 넘겨받은 인증 정보
request.getParameterMap()을 통해 URI와 POST body의 parameter를 받아와 조사함
request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE)를 통해 path variable을 받아와 조사함

인터셉터 등록

WebConfig에서 인터셉터를 등록함
@Configuration @RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { private final AuthorizationService authorizationService; private final List<String> RESOURCES_URL = new ArrayList<>(Arrays.asList( "/images/**", "/js/**", "/css/**", "/*.ico", "/error/**", "/health/**" )); @Override public void addInterceptors(InterceptorRegistry registry) { ... registry.addInterceptor(new AuthorizationInterceptor(authorizationService)) .order(3) .addPathPatterns("/**") .excludePathPatterns(RESOURCES_URL); }
Java
복사