[TIL] Spring Security RoleHierarchy

김건우·2024년 9월 2일

[TIL]

목록 보기
18/25

kumoh-talk 프로젝트를 진행하면서 권한에 대해 고민할 부분이 생겼었다.

이처럼 처음 사용자 추가 정보를 작성하거나, 세미나 신청폼을 작성한다면 -> 스터디/프로젝트 글을 작성하거나, 신청가능하며 세미나 요약 글 작성이 가능하게 되는 구조를 가지고 있다.

처음에는 각자 메서드에서 추가 정보가 존재하는지 체크해서 구현했었는데, 세미나 신청폼 작성도 사용자 추가 정보가 작성되야 하며, 각 부분이 계층적으로 이뤄짐을 발견했다.

Jwt로 인증/인가를 처리하고 있었기 때문에 권한이 변경됨에 따라 DB 업데이트 및 변경된 권한으로 Jwt를 재발급 해줘야 하는 처리가 추가로 필요했다.

우선 권한을 생성했다.

public enum Role {
    ROLE_GUEST, // 정보가 존재하지 않는 첫 로그인한 사용자
    ROLE_USER, // 일반 사용자 (댓글 작성 및 뉴스레터 구독 같은 자잘한 활동 가능)
    ROLE_ACTIVE_USER, // 추가정보를 입력한 사용자 (세미나 신청, 스터디/프로젝트 글 작성 및 신청 가능)
    ROLE_SEMINAR_WRITER, // 세미나 신청을 한 번 이상한 사용자 (세미나 글 작성 가능)
    ROLE_ADMIN // 관리용 계정
}

이런식으로 5개의 권한을 만들어줄 수 있었다.

나는 권한을 enum 타입으로 저장하는 편인데, DB에 다른 테이블로 빼는 장점을 잘 모르겠어서 enum으로 사용중이다. 이유로는 매번 join 연산으로 권한을 가져와야 하며, 추가나 변경에 자유롭다고 해도 권한을 변경할 정도라면 프로젝트 자체를 다시 새로 시작해야하는게 맞다고 보기 때문이다.

여튼 enum 타입을 사용하기에 Spring Security RoleHierarchy 를 적용하기 매우 쉽다.

기존 SecurityConfig.java 파일에서 다음과 같은 정보를 추가해주면 된다.

    @Bean
    public RoleHierarchy roleHierarchy() {
        RoleHierarchyImpl roleHierarchyImpl = new RoleHierarchyImpl();
        roleHierarchyImpl.setHierarchy("ROLE_ADMIN > ROLE_SEMINAR_WRITER > ROLE_ACTIVE_USER > ROLE_USER");
        return roleHierarchyImpl;
    }

    @Bean
    public MethodSecurityExpressionHandler expressionHandler(RoleHierarchy roleHierarchy) {
        DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
        expressionHandler.setRoleHierarchy(roleHierarchy);
        return expressionHandler;
    }

roleHierarchy 메서드에서는 역할 간의 상하 관계를 설정한다. ROLE_ADMIN 이 가장 높은 권한을 가지고, ROLE_USER 이 가장 낮은 권한을 가진다. ROLE_GUEST는 기획상의 이유로 계층을 적용하지 않고, 별개로 사용하도록 설정했다.

MethodSecurityExpressionHandler 를 통해 메서드 시큐리티에서도 roleHierarchy 를 적용할 수 있게 설정했다. 이는 우리 프로젝트에서는 다음과 같이 메서드 시큐리티인 @PreAuthorize 를 통해 권한 검사를 진행하기 때문이다.

    @PreAuthorize("isAuthenticated() and hasRole('ROLE_GUEST')")
    @GetMapping("/check-nickname")
    public ResponseEntity<ResponseBody<Void>> checkNicknameDuplicate(
            @Param("nickname") @Pattern(regexp = NICKNAME_REGEXP, message = "닉네임 정규식을 맞춰주세요.") String nickname) {
        userService.checkNicknameDuplicate(nickname);
        return ResponseEntity.ok(createSuccessResponse());
    }

SecurityConfig에서 권한을 검사하지 않고, 메서드 단으로 바꾼 이유는 팀 협업을 진행하면서 충돌을 최소화하고, 각 api를 확인했을 때 권한을 한눈에 알아볼 수 있기 때문이다.

Config에서 검사한다면 명세서처럼 한눈에 알아볼 수 있다는 장점도 있지만, 협업 시 충돌을 최소화하는게 목적이라 생각해 다음과 같이 적용하고 있다.

마지막으로 활용한 부분을 간단하게 설명하고 마무리 짓도록 하겠다.

    @AssignUserId
    @PreAuthorize("isAuthenticated() and hasRole('ROLE_ACTIVE_USER')")
    @PostMapping
    public ResponseEntity<ResponseBody<TokenResponse>> applyForSeminar(Long userId,
                                                                       @RequestBody @Valid SeminarApplicationRequest request) {
        return seminarApplicationService.applyForSeminar(userId, request)
                .map(token -> ResponseEntity.ok(createSuccessResponse(token))) // 200 OK
                .orElseGet(() -> ResponseEntity.noContent().build()); // 204 No Content
    }
    ----------------------------------------------------------------------------------------------

    @Transactional
    public Optional<TokenResponse> applyForSeminar(Long userId, @Valid SeminarApplicationRequest request) {
        User user = userService.validateUser(userId);
        boolean isFirstApplication = user.getSeminarApplications().isEmpty();

        if (isFirstApplication) { // 사용자 역할 업데이트 (첫 생성)
            user.updateUserRoleToSeminarWriter();
        }

        user.addSeminarApplications(SeminarApplication.from(request, user));

        return isFirstApplication
                ? Optional.of(jwtHandler.createTokens(JwtUserClaim.create(user))) // 첫 생성 시 토큰 반환
                : Optional.empty(); // 첫 생성이 아닐 경우 빈 Optional 반환
    }

이처럼 세미나 신청폼의 첫 생성시에는 권한 정보를 업데이트하고, 사용자에 해당 신청폼을 추가해줌으로써 Cascade.PERSIST 옵션으로 자동으로 DB에 저장되게 된다.

이후, 첫 생성시라면 토큰을 발급해서 반환하고, 첫 생성이 아닐경우 빈 Optional로 반환한다. null로 해도 되지만, 예쁘지 않을 뿐더러 Optional을 잘 활용하려 노력하고 있다.

그리고 controller 단에서 토큰 정보가 존재한다면 200 상태코드와 함께 내보내고 있고, 토큰 정보가 없다면 정상 작동 되었다는 의미로 204 상태 코드와 함께 빈 body를 반환한다.

여기서 ROLE_ACTIVE_USER 권한으로 설정해놨는데, 이러면 자동으로 그 위에 있는 권한들은 사용할 수 있고, 아래에 있는 권한은 사용할 수 없게 된다.


간단하게 Spring Security에서 제공해주는 RoleHierarchy를 적용해봤는데 이렇게 각 역할마다 계층이 확실하다면 적용시키는 것이 확실히 편한 것 같다.

만약 권한 정보를 테이블로 따로 구성한다면 조금 더 복잡해지겠지만 말이다..

여튼 만약 권한 정보를 테이블로 따로 구성하는 것에 장점을 알고 적용하게 되는 시점이 온다면 그때 다시 계층 권한 적용을 정리해보겠다!

profile
공부 정리용

1개의 댓글

comment-user-thumbnail
2024년 9월 4일

잘봤습니다!! 좋은글이네요!

답글 달기