카테고리 없음

3. AOP 통한 권한 기반 인가 로직 구현

han1693516 2025. 8. 14. 11:01

이번 프로젝트에서는 사용자 권한이 MASTER, ADMIN, USER 이렇게 있는데, 각 API별로 인가 설정을 다르게 해 줘야 한다. (ex MASTER만 접근 가능 or MATSER/ADMIN만 접근 가능 or ...)

 

이번 프로젝트에선 AOP를 이용해 인가 로직을 구현해봤다.

 

사용을 위해 build.gradle에 다음을 추가하도록 하자.

implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.aspectj:aspectjweaver'

 

 

설명에 앞서, 사용 예시는 다음과 같다. @CheckRolePermission 을 통해 사용자의 권한을 가져와 ADMIN, 혹은 MASTER에 속할 경우에만 접근이 가능하도록 구성했다. 

@CheckRolePermission(requiredRoles = {ADMIN, MASTER})
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ApiResponse<FileUploadResponse>> uploadFile(
                                                                      @LoginUser User uploader,
                                                                      @ValidFile(isEmptyFileMessage = "파일이 비어 있습니다!",
                                                                                  maxSizeExceededMessage = "10MB를 넘는 파일을 첨부할 수 없습니다!",
                                                                                  invalidFileExtensionMessage = "지원하지 않는 파일 양식입니다!") @RequestPart(value = "file") MultipartFile file,
                                                                      @Valid @RequestPart(name = "fileInfo") FileMetaInfoUploadRequest fileMetaInfoUploadRequest) {

           return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.created(fileManagementFacade.uploadMediaFile(file, fileMetaInfoUploadRequest, uploader)));
}

 

 

사용한 어노테이션에는 크게 특별한 건 없고, 허용되는 권한을 받도록 구성했다.

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

    RoleType[] requiredRoles() default {RoleType.ADMIN};

}

 

 

Aspect 클래스는 다음과 같이 구성했다. @Before 를 통해 @CheckRolePermission 이 달린 method 실행되기 전에 실행되도록 했고, context 내에서 auth 내 Authorities를 가져와 가지고 있는지 확인하도록 했다. 

@Aspect
@Component
public class PermissionAspect {

    @Before("@annotation(com.dtalks.dtalks_be.global.aop.annotation.CheckRolePermission)")
    public void doRoleAuthorize(JoinPoint joinPoint) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (Objects.isNull(authentication)) {
            throw new UnauthorizedException("인증되지 않은 유저입니다!");
        }

        CheckRolePermission annotation = extractAnnotation(joinPoint, CheckRolePermission.class);

        List<String> requiredRoles = Arrays.stream(annotation.requiredRoles())
                .map(RoleType::getAuthority)
                .toList();

        List<String> loginUserRole = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .toList();

        boolean hasPermission = checkPermission(requiredRoles, loginUserRole);

        if (!hasPermission) {
            throw new RoleForbiddenException("접근할 수 있는 권한을 가지고 있지 않습니다!");
        }
    }

    private static boolean checkPermission(List<String> requiredRoles, List<String> loginUserRole) {
        return requiredRoles.stream()
                .anyMatch(loginUserRole::contains);
    }

    private static <T extends Annotation> T extractAnnotation(JoinPoint joinPoint, Class<T> annotationClass) {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        return method.getAnnotation(annotationClass);
    }
}

 

 

컨트롤러 단에서 관련해 테스트하기 위해 ControllerTestSupport 내 @Import 에 앞서 작성한 PermissionAspect.class와 AopAutoConfiguratino.class 를 추가했다.

@ActiveProfiles("test")
@WebMvcTest(controllers = {
        UserController.class,
        FaqController.class,
        RoleController.class,
        DocumentController.class,
        RoleController.class,
        FileController.class,
        AuthController.class,
        FileVersionController.class
})
@Import({TestSecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
public class ControllerTestSupport {

    ...
     
}

 

컨트롤러 테스트에 기존 사용하던 JwtFilter 내 SecurityContext에 set 하는 로직을 적용하기 위해 custom 한 annotation을 작성했다.

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithSecurityContextFactoryImpl.class)
public @interface MockAdmin {
    long id() default 1L;
    String name() default "admin";
    String department() default "인사부";
    RoleType role() default RoleType.MASTER;
}

 

테스트 내 context를 set하기 위한 Impl class이다. 위 annotation 내 id, name, department, role 을 가져와 mockUser를 만들고, 이를 context에 set하고 있다. 

public class WithSecurityContextFactoryImpl implements WithSecurityContextFactory<MockAdmin> {

    @Override
    public SecurityContext createSecurityContext(MockAdmin annotation) {
        User mockUser = User.of("abc@def.com", "password", "A123", "김현우", "nickname", LocalDateTime.of(2025, 8, 7, 0, 0, 0),
                "https://s3.url", Department.of(annotation.department()), Role.of(annotation.role(), "설명", RoleStatus.ACTIVE));
        mockUser.setIdForOnlyTest(annotation.id());

        CustomUserDetails userDetails = CustomUserDetails.from(mockUser);
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(authentication);

        return context;
    }
}

 

 

사용 예시이다. @MockAdmin 에 role를 SET해 403이 정상적으로 발생하는 지 테스트했다.

@MockAdmin(role = RoleType.USER)
@DisplayName("일반 사용자는 파일을 업로드할 수 없다.")
@Test
void uploadFile_USER() throws Exception {
        // given
        FileMetaInfoUploadRequest fileMetaInfoUploadRequest = FileMetaInfoUploadRequest.of("image.jpg", "파일입니다", "1.0.0", true);
        String requestAsString = objectMapper.writeValueAsString(fileMetaInfoUploadRequest);

        MockMultipartFile file = new MockMultipartFile("file", "image.jpg", "image/jpg", new byte[10]);

        FileUploadResponse response = FileUploadResponse.of(1L, "http://s3.url");

        given(fileManagementFacade.uploadMediaFile(any(), any(FileMetaInfoUploadRequest.class), any(User.class)))
                .willReturn(response);

        // when // then
        mockMvc.perform(
                        multipart("/admin/file/upload")
                                .file(file)
                                .file(new MockMultipartFile("fileInfo", "", "application/json", requestAsString.getBytes(StandardCharsets.UTF_8)))
                                .contentType(MediaType.MULTIPART_FORM_DATA_VALUE)
                                .accept(MediaType.APPLICATION_JSON)
                                .characterEncoding("UTF-8")
                )
                .andDo(print())
                .andExpect(status().isForbidden())
                .andExpect(jsonPath("$.code").value("403"))
                .andExpect(jsonPath("$.status").value("FORBIDDEN"))
                .andExpect(jsonPath("$.message").value("접근할 수 있는 권한을 가지고 있지 않습니다!"));
 }

 

 

정상적으로 403이 뜨는 걸 볼 수 있다!