안녕하세요. 이번 포스팅에서는 회원의 권한 관리라는 주제로 글을 작성하고자 합니다. Spring에서 제공하는 Interceptor와 Annotation을 활용하여 구현하였습니다. 부족한 부분은 언제나 댓글로 알려주시면 바로바로 반영하겠습니다😊
어플리케이션은 일반적으로 다양한 사용자들이 존재합니다. 예를 들어 일반 사용자와 관리자가 있다고 가정해 볼게요. 이 경우 관리자만이 할 수 있는 기능을 일반 사용자가 접근하게 된다면 어떻게 될까요? 굉장히 위험하겠죠. 이렇게 서로 다른 권한을 가진 사용자들을 분리하고, 권한에 적절한 API 호출만 가능하도록 하는 것을 권한 관리(Authorizaion)이라고 합니다.
우아한 테크 마켓은 처음 가입한 GUEST, 필수 정보를 입력한 USER, 그리고 관리자 이렇게 총 세개의 권한이 존재합니다. 위의 예시처럼 각기 다른 권한을 가진 사람이 접근할 수 있는 API는 제한적이여야 하기 때문에 권한과 관련된 기능을 구현해 보았습니다.
일반적으로 권한이나 인증과 관련된 부분은 비즈니스 로직이 아닌 Filter, Interceptor 등에서 처리됩니다. 저의 경우 회원이 존재하는지 찾아와야 하는 로직이 포함되기 때문에 Interceptor를 선택하였습니다.(스프링 환경이 동작한 이후에 빈을 사용할 수 있기 때문에)
구현 과정에 있어서 고민은 아래의 두 가지 경우 중 어떤 것이 적합할까? 였습니다. 서로 다른 장단점이 존재하지만 저는 어노테이션을 기반으로 전자를 선택하여 진행하였습니다.! 이유는 아래에 같이 명시해두었습니다. 혹시 더 좋은 아이디어나 생각이 있으시다면 알려주세요!!👨💻
구현 내용을 보여드리기에 앞서 전체적인 로직은 다음과 같이 수행됩니다. 코드를 보기 전에 확인하시는 게 이해하기 편할 것 같아서요
주석으로 간단하게 설명 달았습니다. 혹시 이해 안되시는 부분이나 별로인 부분이 있다면 얘기해주세요. 👉
Annotation
@Target(value = {ElementType.METHOD, ElementType.TYPE}) // 클래스와 메소드 모두 사용
@Retention(RetentionPolicy.RUNTIME)
public @interface Permission {
String target() default "NONE";
}
Controller와 Handler
@RequiredArgsConstructor
@Permission(target = "USER") // 모든 Handler는 기본적으로 USER가 사용할 수 있는 API
@RequestMapping("/api/members")
@RestController
public class MemberController {
private final MemberService memberService;
@Permission(target = "NONE") // 해당 Handler는 NONE(아무나)도 사용 가능
@PostMapping
public ResponseEntity<Void> create(@Valid @RequestBody MemberCreateRequest request) {
MemberResponse response = memberService.createMember(request);
return ResponseEntity.created(URI.create("/api/members/" + response.getId())).build();
}
}
BearerInterceptor
@RequiredArgsConstructor
@Component
public class BearerInterceptor implements HandlerInterceptor {
private final AuthenticationExtractor extractor;
private final JwtTokenProvider provider;
private final MemberService memberService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod)) { // 호출되는 메소드가 헨들러가 아니라면 검증할 필요가 없겠죠?
return true;
}
Role role = getProperRole((HandlerMethod)handler); // Handler에 달린 어노테이션을 읽어 역할을 가져옵니다.
if (role.isNone()) { // 역할이 NONE(아무나)라면 바로 통과합니다.
return true;
}
String bearer = extractor.extract(request); // 아니라면 Header로 넘어온 토큰을 해체합니다.
String kakaoId = provider.getSubject(bearer);
Member findMember = findMember(kakaoId); // 접속한 회원을 조회합니다.
request.setAttribute("member", findMember);
if (role.isGuest() || findMember.hasPermission(role)) {
return true; // 권한이 확인된 사용자는 통과합니다.
} // 참고로 로그인을 했다면 GUEST 기능은 모두 이용 가능해서 GUEST 도 추가되어 있습니다.
throw new AuthorizationException();
}
private Member findMember(String kakaoId) {
try {
return memberService.findByKakaoId(Long.parseLong(kakaoId));
} catch (NumberFormatException e) {
throw new NotFoundMemberException();
}
}
private Role getProperRole(HandlerMethod handler) {
Permission clazzAnnotation = handler.getBean().getClass().getAnnotation(Permission.class);
Permission methodAnnotation = handler.getMethod().getAnnotation(Permission.class);
Permission priority = AnnotationUtils.getPriority(clazzAnnotation, methodAnnotation);
return Role.valueOf(priority.target()); // 어노테이션을 분석해 적절한 권한을 반환합니다.
}
}
AnnotationUtils - 클래스와 메소드 어노테이션 중 우선순위를 가져옵니다.
public class AnnotationUtils {
public static Permission getPriority(Permission clazzAnnotation, Permission methodAnnotation) {
if (Objects.isNull(clazzAnnotation) && Objects.isNull(methodAnnotation)) {
throw new AssertionError("인가와 관련된 어노테이션을 추가해주세요.");
}
if (Objects.nonNull(methodAnnotation)) {
return methodAnnotation;
}
return clazzAnnotation;
}
}
실무에서 어떤 식으로 권한을 관리하는지 잘 몰라서 개인적인 방법으로 적용해 보았습니다. 권한과 관련된 로직은 비즈니스 로직과 분리되어야 된다고 생각하여 인터셉터에서 처리했습니다. 더 나은 방법이나, 다른 방법이 있다면 알려주세요!!
추가적인 생각들
악플이라도 하나! 밥먹고 읽으러 올게요~