Application/Spring Framework

스프링 인터셉터와 어노테이션으로 인증 및 권한 관리하기

반응형

Spring Interceptor를 통한 권한 관리

사이드 프로젝트를 진행하면서 좀 더 효율적으로 인증 및 권한 관리를 할 수 없을까 고민하면서 해 본 방법을 정리한 글입니다.


기존의 상황


먼저 사이드 프로젝트의 요구사항을 보면, 비회원 유저와 회원 유저(Member)로 구분되며, (인증 체크 필요)
여러 유저가 속해 있을 수 있는 그룹(Organization)이라는 개념이 존재하며, 그룹 내에 관리자(ADMIN)와 일반(USER) 멤버 두 가지 형태로 존재하며 각기 다른 권한 범위를 가지고 있습니다. (권한 체크 필요)

요약하면 현재 저희 프로젝트에서 현재까지 필요한 인증 및 권한 작업은

  • 로그인하지 않아도 되는 API (인증이 필요 없는 API)
  • 로그인이 필요한 API (인증이 필요한 API)
  • 특정 그룹에 속해 있어야 하는 API (관리자 or 일반 멤버로서) (인증 + 권한이 필요한 API)
  • 특정 그룹에 관리자로 속해 있어야 하는 API (인증 + 권한이 필요한 API)

다음과 같이 3가지 종류의 권한 작업이 필요했습니다.  (또한 차후 더욱 확장될 수 있는 상황입니다.)


기존의 문제점

지금까지 권한 작업은 다음과 같이 이루어지고 있었습니다. 예를 들어 현재 유저가 그룹의 관리자인지 확인하는 로직은 서비스 계층에서 처리하고 있었으며 다음과 같이 중복되는 로직이 발생하였습니다.

XService.java

    @Transactional
    public void 권한 작업이 필요한 메소드1(String subDomain, ManageOrganizationMemberRequest request, Long memberId) {
        Organization organization = OrganizationServiceUtils.findOrganizationBySubDomain(organizationRepository, subDomain);
        organization.validateAdminMember(memberId); // 해당 유저가 그룹의 관리자인지 확인.
        ...
    }

    @Transactional
    public void 권한 작업이 필요한 메소드2(String subDomain, ManageOrganizationMemberRequest request, Long memberId) {
        Organization organization = OrganizationServiceUtils.findOrganizationBySubDomain(organizationRepository, subDomain);
        organization.validateAdminMember(memberId); // 해당 유저가 그룹의 관리자인지 확인.
       ...
    }

그룹의 관리자 인지 확인하는 다음과 같은 로직이 매번 들어가고 있었습니다. (권한 로직에 대한 중복!)

organization.validateAdminMember(memberId);

이 뿐만 아니라 그룹에 속해있는 유저인지 확인하는 로직도 마찬가지로... 중복되는 상황이었습니다.

 

이렇게 매번 중복되는 코드를  다음과 같이 컨트롤러에서 어노테이션을 통해서 권한 체크를 처리하고 싶었습니다.

    @Auth(role = ORGANIZATION_ADMIN) // 그룹의 관리자 권한이 필요함을 나타냄
    @PutMapping("/api/v1/organization/join/approve/{subDomain}")
    public ApiResponse<String> approveOrganizationMember(@PathVariable String subDomain, @Valid @RequestBody ManageOrganizationMemberRequest request) {
        organizationAdminService.approveOrganizationMember(subDomain, request);
        return ApiResponse.OK;
    }

 

Spring Interceptor를 선택한 이유


그래서 제가 생각한 방법은 세가지 방법을 생각하였습니다.

1. Filter를 이용한 방법

2. Spring AOP를 사용한 방법.

3. Spring interceptor를 사용한 방법.

1. Filter를 이용한 방법

1. Filter를 이용한 방법은 Spring Context가 아닌, Web Application에 등록돼서 스프링 IoC 컨테이너가 관리하는 빈에 접근하지 못하는 이유로 선택하지 않았습니다.


2. Spring AOP를 사용한 방법.

2. Spring AOP vs 3. Spring Interceptor 중에서는 AOP 보다 좀 더 Web Request 및 Response 처리에 특화된 인터셉터를 사용해서 구현하게 되었습니다. (좀 더 자세한 내용은 아래 참고)


Spring AOP vs Spring Interceptor

스프링 인터셉터DispatcherServlet이 컨트롤러를 호출하기 전, 후로 끼어들어서 Spring Context 내부에서 Controller에 관한 Request와 Response를 처리합니다. 
(또한 파라미터도, HttpServletRequest, HttpServletResponse를 사용해서 Controller로 넘어가는 Request, Response 데이터를 좀 더 처리하기 용이하다고 판단하였습니다.)


반면, Spring AOP는 주로 트랜잭션, 로깅 등 좀 더 비즈니스단의 메서드에서 조금 더 세밀하게 조정하고 싶을 때 사용한다고 합니다.

cf) 토비의 스프링에서...

컨트롤러의 호출 과정에 적용되는 부가 기능은 핸들러 인터셉터를 사용하는 편이 낫다. 스프링 MVC의 컨트롤러는 타입이 하나로 정해져 있지 않고, 실행 메소드 또한 제각각이기 때문에 적용할 메소드를 선별하는 포인트컷 작성도 쉽지 않다. 게다가 파라미터나 리턴 값 또한 일정치 않다. 이러한 이유러 컨트롤러에 AOP를 적용하려면 꽤나 많은 수고가 필요하다. 반대로 스프링 MVC는 모든 종류의 컨트롤러에게 동일한 핸들러 인터셉터를 적용할 수 있게 해준다. 따라서 컨트롤러에 공통적으로 적용할 부가기능이라면 핸들러 인터셉터를 이용하는 편이 낫다.

 

cf) 참고로 Spring AOP를 이용해서 요청 및 및 요청 처리 시간을 로깅하는 방법은 아래를 참고해주세요!

 

Spring AOP를 이용한 Request 요청 및 요청 처리시간 로깅

진행하는 사이드 프로젝트에서 클라이언트에서 서버로 HTTP Request 요청을 로깅하고 얼마나(ms) 걸리는지 확인하고 싶었습니다. 그래서 생각한 방법은 - Spring Filter를 이용 - Spring AOP를 이용 두 가지

willseungh0.tistory.com

 

샘플 코드


 

Auth.java (@Auth)

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Auth {

    Role role() default Role.USER;

    enum Role {
        USER, // 로그인이 필요한 사용자
        ORGANIZATION_MEMBER, // 로그인 뿐만 아니라 그룹에 속해있는 사용자 (일반 멤버 혹은 관리자로써)
        ORGANIZATION_ADMIN // 그룹의 관리자로 속해있는 사용자
    }

}

 

해당 Role을 통해 여러 권한을 설정할 수 있으며, 다음과 같이 권한 작업이 필요한 컨트롤러 메소드에 다음과 같이 어노테이션을 붙여줄 수 있습니다.

@Auth(role = ORGANIZATION_ADMIN) // 그룹의 관리자인지 체크
 @Auth(role = ORGANIZATION_MEMBER) // 그룹에 속해있는지 체크
 @Auth(role = USER) // 로그인한 유저인지 체크

 

AuthInterceptor.java

@RequiredArgsConstructor
@Component
public class AuthInterceptor extends HandlerInterceptorAdapter {

    private final LoginUserComponent loginUserComponent;
    private final OrganizationComponent organizationComponent;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }
        HandlerMethod handlerMethod = (HandlerMethod) handler;

        Auth auth = handlerMethod.getMethodAnnotation(Auth.class);
        if (auth == null) {
            return true;
        }
        Long memberId = loginUserComponent.getMemberId(request);
        request.setAttribute("memberId", memberId);

        // 그룹의 관리자인지 확인
        if (auth.role().compareTo(ORGANIZATION_ADMIN) == 0) {
            organizationComponent.validateOrganizationAdmin(request, memberId);
        }

        // 그룹에 속해있는 멤버인지 확인 (관리자 or 일반 멤버)
        if (auth.role().compareTo(ORGANIZATION_MEMBER) == 0) {
            organizationComponent.validateOrganizationMember(request, memberId);
        }
        return true;
    }

}

그 다음에 HandlerInterceptorAdapter를 상속받는 클래스를 구현했습니다.

 

먼저 HandlerInterceptorAdaptor에서는 네 가지 메소드를 오버라이딩할 수 있습니다.

  • preHandle: 클라이언트의 요청을 컨트롤러에 전달하기 전에 호출
  • postHandle: 클라이언트의 요청을 처리한 후에 View에 응답을 전달하기 전에 호출
  • afterCompletion: 클라이언트의 요청을 처리한 후에 View에 응답을 전달한 후에 호출
  • afterConcurrentHandlingStarted: 비동기 요청 시 호출되는 메소드로, 비동기 요청 시 postHandle, afterCompletion은 호출되지 않음.

권한 작업은 컨트롤러로 전달하기 전에 호출하는 것이 효율적이라고 생각해서, preHandle을 오버라이딩 해서 구현하였습니다.

 

설명 1. @Auth 어노테이션이 없는 컨트롤러 (따로 인증 체크가 필요 없는 API)

        Auth auth = handlerMethod.getMethodAnnotation(Auth.class);
        if (auth == null) { // Auth 어노테이션이 없으면 따로 권한 체크가 필요 없으므로 true 반환
            return true;
        }
 

컨트롤러에 @Auth 어노테이션이 따로 존재하지 않으면 권한 체크가 필요 없으므로 true를 반환합니다.

 

2. @Auth 어노테이션이 존재하고, role = User인 컨트롤러 (인증 체크가 필요하며, 따로 권한 체크는 필요 없는 API)

Auth 어노테이션이 존재하는 경우, 로그인 여부를 체크하는 로직입니다.

(정확히는 로그인했을 경우 세션에서 memberId를 가져와서 다시 Request에 memberId를 보내줍니다..)

      Long memberId = loginUserComponent.getMemberId(request); // Auth 어노테이션이 있으므로 로그인 여부를 체크
      request.setAttribute("memberId", memberId);


LoginUserComponent

@RequiredArgsConstructor
@Component
public class LoginUserComponent {

    private final static String BEARER_TOKEN = "Bearer ";

    private final SessionRepository<? extends Session> sessionRepository;

    public Long getMemberId(HttpServletRequest request) {
        return getMemberSession(request).getMemberId();
    }

    private MemberSession getMemberSession(HttpServletRequest request) {
        String header = request.getHeader(HttpHeaders.AUTHORIZATION);
        Session session = extractSessionFromHeader(header);
        return session.getAttribute(SessionConstants.AUTH_SESSION);
    }

    private Session extractSessionFromHeader(String header) {
        if (header == null) {
            throw new UnAuthorizedException("세션이 없습니다");
        }
        if (!header.startsWith(BEARER_TOKEN)) {
            throw new UnAuthorizedException(String.format("잘못된 세션입니다 (%s)", header));
        }
        Session session = sessionRepository.getSession(header.split(BEARER_TOKEN)[1]);
        if (session == null) {
            throw new UnAuthorizedException(String.format("잘못된 세션입니다 (%s)", header));
        }
        return session;
    }

}

 

간단하게 Spring HttpSession을 이용해서 로그인을 구현하고 있었는데, 차후 로그인 방식이 변경된다면 이 부분만 그에 맞춰 변경해 주면 됩니다.

코드는 서버로 HTTP 요청 시 Authorization Header의 Bearer 토큰에 있는 SessionId를 가져와서 로그인 여부를 확인하는 로직입니다.

 

3. @Auth 어노테이션이 존재하고, role = ORGANIZATION_ADMIN인 컨트롤러
(인증 체크 및 
그룹의 관리자인지 권한 체크가 필요한 API)

@Auth(role = ORGANIZATION_ADMIN)
        // 그룹의 관리자인지 확인
        if (auth.role().compareTo(ORGANIZATION_ADMIN) == 0) {
            organizationComponent.validateOrganizationAdmin(request, memberId);
        }

organization.validateOrganizationAdmin(....) 를 통해서 그룹의 관리자 여부를 체크해줍니다.

 

해당 메소드는 다음과 같습니다.

@RequiredArgsConstructor
@Component
public class OrganizationComponent {

    private final OrganizationRepository organizationRepository;

    public void validateOrganizationAdmin(HttpServletRequest request, Long memberId) {
        Organization organization = getOrganization(request, memberId);
        organization.validateAdminMember(memberId);
    }

    public void validateOrganizationMember(HttpServletRequest request, Long memberId) {
        // 아래에서 설명 
    }

    private Organization getOrganization(HttpServletRequest request, Long memberId) {
        Object path = request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
        final Map<String, String> pathVariables = (Map<String, String>) path;
        String subDomain = pathVariables.get("subDomain");
		
        return OrganizationServiceUtils.findOrganizationBySubDomain(organizationRepository, subDomain);
    }

}

다음과 같이 HTTP 요청 시 PathVariables로 넘어오는 subDomain (그룹의 고유키)를 가져와서 해당 Organization을 찾아온 후 해당 Organization의 관리자인지 확인하는 로직입니다.


4. @Auth 어노테이션이 존재하고, role = ORGANIZATION_MEMBER인 컨트롤러
(인증 체크 및 그룹에 관리자든 일반 회원이든 상관없이 그룹에 속해있는지 확인하는 권한 체크 필요한 API)

@Auth(role = ORGANIZATION_MEMBER)
// 그룹에 속해있는 멤버인지 확인 (관리자 or 일반 멤버)
if (auth.role().compareTo(ORGANIZATION_MEMBER) == 0) {
    organizationComponent.validateOrganizationMember(request, memberId);
}
@RequiredArgsConstructor
@Component
public class OrganizationComponent {

    private final OrganizationRepository organizationRepository;

    public void validateOrganizationAdmin(HttpServletRequest request, Long memberId) {
        // 위에서 설명함
    }

    public void validateOrganizationMember(HttpServletRequest request, Long memberId) {
        Organization organization = getOrganization(request, memberId);
        organization.validateIsMemberInOrganization(memberId);
    }

    private Organization getOrganization(HttpServletRequest request, Long memberId) {
        Object path = request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
        final Map<String, String> pathVariables = (Map<String, String>) path;
        String subDomain = pathVariables.get("subDomain");
        
        return OrganizationServiceUtils.findOrganizationBySubDomain(organizationRepository, subDomain);
    }

}

 



인터셉터 등록


그리고 다음과 같이 AuthInterceptor를 등록해줍니다.

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final AuthInterceptor authInterceptor;
   
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authInterceptor);
    }

}

 

소스코드


전체 소스 코드는 아래에서 참고해주세요~~

 

steamed-potatoes/potato-backend

https://pmarket.space/swagger-ui.html. Contribute to steamed-potatoes/potato-backend development by creating an account on GitHub.

github.com

 

감사합니다.

반응형