이번 프로젝트에서는 사용자 권한이 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이 뜨는 걸 볼 수 있다!
