Post

사용자 권한제한 기능 추가

사용자 접근제한 기능을 개발 과정을 소개합니다. 세션과 Spring AOP를 활용하였습니다.

기능 소개

현재 운영하고 있는 서비스에서 관리자(Admin)의 권한이 필요한 기능은 아이템 등록 이라는 기능 뿐이다. 등록할 수 있는 아이템의 수가 제한되어 있고, 모 게임의 컨텐츠를 기준으로 하고 있기 때문에 무분별하게 아이템 정보를 등록해서는 안되기 때문이다.

따라서 관리자정보 또한 JWT토큰을 통해 식별하는 것이 아니라 서버에서 관리자의 상태정보를 저장하는 세션기능을 이용하고 있다. 아직은 서비스에 회원가입을 통한 일반회원(Member)을 추가할 계획이 없기 때문이다. 만약 미래에 일반회원(Member)이 추가된다면 많은 회원의 정보를 세션에서 기억하려면 메모리에 무리가 갈 것이기 때문에 jwt 토큰방식을 채택할 계획이다. 일단은 관리자뿐이기에 세션 기능을 채택하고 있다.

앞에서도 말했듯이 현재 서비스에는 아이템 등록기능만이 관리자 식별을 요구하고 있다. 하지만 앞으로 신고된 댓글 관리, 아이템 정보 업데이트 등과 같이 관리기능이 추가될 것이다.

현재 관리자를 식별하는 기능은 다음과 같이 이루어진다.

  1. 관리자 권한을 필요로 하는 컨트롤러 메서드를 호출한다.
  2. 컨트롤러 메서드에서는 @SessionAttribute 애노테이션을 이용해서 사용자 정보를 가져온다.
  3. 가져온 사용자의 권한이 ADMIN 이하이거나, 세션에 멤버 데이터가 없으면 예외를 던진다.
  4. 권한이 ADMIN 이상일 경우 비즈니스 로직을 실행한다.

위 방법은 관리자 권한을 컨트로럴 메서드에서 검증하기 때문에 다음 문제들이 존재한다.

  • 컨트롤러가 사용자의 권한을 검증하는 책임을 가진다.
  • 검증로직 코드가 중복된다.

위 문제는 필터나 인터셉터를 활용해서도 해결할 수 있다. 하지만 필터나 인터셉터를 사용하는 방법은 컨트롤러의 메서드만 보고는 어떤 권한이 있어야 메서드를 사용할 수 있는지 한눈에 파악하기 힘들다. 필터와 인터셉터가 어떤 URI를 대상으로 하는지 하나하나 코드를 뜯어보어야한다.

컨트롤러의 메서드에 @MasterOnly, @AdminOnly, @MemberOnly 처럼 애노테이션이 달려있다면 컨트롤러 메서드만 보고도 어떤 권한을 가진 사용자에게 허용되는 기능인지 한눈에 파악이 가능하다.

이렇게 애플리케이션 전반적으로 유저권한에 따라 접근과 동작을 제한하는 기능이 필요하기 때문에 권한을 검증하는 기능을 Spring AOP를 사용하여 구현하기로 결정하였다. 글에서는 Admin 권한을 가진 사용자에게 허용된 기능을 어떻게 필터링 하는지만 살펴보자. 다른 권한한을 식별하는 내용은 지금부터 설명하는 방법의 반복일 뿐이다. 위에서도 설명했듯이 사용자는 세션을 통해서 식별한다.

기능 구현

관리자(Admin)접근 메서드 애노테이션

@AdminOnly는 관리자(Admin)만 접근할 수 있는 메서드에 붙여지는 애노테이션입니다. Spring AOP를 적용하는 기준으로 사용될 예정이다.

1
2
3
4
@Target(METHOD)
@Retention(RUNTIME)
	public @interface AdminOnly {
}

관리자(Admin)식별 파라미터 애노테이션

접근하는 사용자가 관리자(Admin)일때 사용자 정보를 담아올 파라미터에 달리는 애노테이션 입니다..

1
2
3
4
@Target(PARAMETER)
@Retention(RUNTIME)
	public @interface AdminAuth {
}

사용자 정보를 담은 Accessor 클래스 정의

사용자의 ID(memberId) 그리고 사용자의 권한(authority)정보를 담은 클래스입니다. 권한 체크가 필요한 메서드에서 파라미터로 사용됩니다. Argument Resolver를 사용해서 컨트롤러 메서드의 파라미터로 바인딩됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Getter
public class Accessor {

    private final Long memberId;
    private final Authority authority;

    private Accessor(final Long memberId, final Authority authority) {
        this.memberId = memberId;
        this.authority = authority;
    }

    public static Accessor admin(final Long memberId) {
        return new Accessor(memberId, ADMIN);
    }
    
    //...

    public boolean isAdmin() {
        return authority.equals(ADMIN);
    }

}

권한정보를 담는 Authority는 권한이름이 담긴 enum입니다.

  • GUEST
  • MEMBER
  • ADMIN
  • MASTER

네가지 권한을 나타내고 있습니다.

1
2
3
4
5
6
public enum Authority {
    GUEST,
    MEMBER,
    ADMIN,
    MASTER
}

Accessor를 넘겨주는 ArgumentResolver 정의

컨트롤러의 메서드 파라미터로 Accessor를 전달받을 수 있도록 ArgumentResolver를 정의해줍니다. 컨트롤러에서는 @AdminAuth 애노테이션과 함께 전달받을 수 있습니다.

1
2
3
4
5
@GetMapping
@AdminOnly
public String adminOnly(@AdminAuth final Accessor accessor) {
    return "ok";
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Component
@Slf4j
public class AdminArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(AdminAuth.class);
    }

    @Override
    public Accessor resolveArgument(
        MethodParameter parameter,
        ModelAndViewContainer mavContainer,
        NativeWebRequest webRequest,
        WebDataBinderFactory binderFactory
    ) throws Exception {
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        HttpSession session = request.getSession(false);

        if (session == null) {
            return null;
        }

        LoginMemberSecurityDto memberDto = (LoginMemberSecurityDto) session.getAttribute(LOGIN_MEMBER);
        if (memberDto.getAuthority().equals(Authority.ADMIN.name())) {
            return null;
        }

        return Accessor.admin(memberDto.getMemberId());
    }
}
  • ArgumentResolver가 적용되는 대상은 파라미터에 @AdminAuth 애노테이션이 달렸을 때
  • HttpServletRequest 를 통해 세션을 가져온다.
  • 세션이 없거나 세션을 통해 가져온 멤버정보에서 권한이 Admin이 아니라면 null을 반환한다.
    • null이 아니라 상황에 따라 맞는 Accessor를 반환해도 된다.
  • 세션이 존재하고, 세션에 저장되어있는 사용자정보의 권한이 ADMIN이라면 ADMIN권한을 가지는 Accessor 인스턴스를 반환한다.

ArgumentResolver 등록

WebMvcConfigurer 인터페이스를 상속받아 설정클래스를 만들고 ArgumentResolver를 등록해줍니다.

1
2
3
4
5
6
7
8
9
10
11
@RequiredArgsConstructor
@Configuration
public class ArgumentResolverConfig implements WebMvcConfigurer {

    private final AdminArgumentResolver adminArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(adminArgumentResolver);
    }
}

검증 AOP로직 정의

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
@Aspect
public class AdminChecker {

    @Before("@annotation(com.example.securityaop.auth.AdminOnly)")
    public void check(JoinPoint joinPoint) {
        Arrays.stream(joinPoint.getArgs())
            .filter(Accessor.class::isInstance)
            .map(Accessor.class::cast)
            .filter(Accessor::isAdmin)
            .findFirst()
            .orElseThrow(() -> new AdminException(INVALID_ADMIN_AUTHORITY));
    }
}
  • @Before("@annotation(com.example.securityaop.auth.AdminOnly)")
    • @AdminOnly 애노테이션이 달린 타겟(메서드)의 로직이 실행되기 전에 AOP로직을 적용한다.
  • Arrays.stream(joinPoint.getArgs())
    • 메서드의 인수(파라미터, args)정보를 가져온다.
    • 파라미터에는 ArgumentResolver를 통해 전달받은 Accessor도 포함되어 있다.
  • filter(Accessor.class::isInstance)
    • 파라미터중 Accessor 타입인 인수를 필터링한다.
  • map(Accessor.class::cast)
    • 필터링된 인수를 Accessor 타입으로 타입캐스팅한다.
  • filter(Accessor::isAdmin)
    • 권한이 ADMIN인 Accessor 인스턴스를 필터링한다.
This post is licensed under CC BY 4.0 by the author.