우리 회사는 다단계 회사이다.
단계 별로 role tier가 있고 각 tier 별 관리자가 있다.
관리자는 자기가 속한 role tier 이하의 사용자 정보만 조회 및 수정할 수 있다.
(2 tier 관리자일 경우 3 tier 사용자를 2 tier로 승격 가능. 1 tier로 승격 및 1 tier 사용자를 수정하는 것은 불가)
이러한 상황에서 role tier check logic을 분리하여 처리하는 방식에는 여러가지가 있겠지만 이번에는 @Valid
, @Constraint
와 ConstraintValidator
로 구현해보자.
public class User {
private String userId;
private Role userRole;
private boolean admin;
/* 생략 */
}
public class UserDto {
private String userId;
private Role userRole;
private boolean admin;
/* 생략 */
}
@Getter
@AllArgsConstructor
public enum Role {
MASTER("master", 0),
DIAMOND("diamond", 1),
GOLD("gold", 2),
SILVER("silver", 3),
BRONZE("bronze", 4),
;
private String name;
private int tier;
/* 생략 */
}
@RestController
@RequestMapping("/users")
public class UserController {
/* 생략 */
@GetMapping("/{userId}")
public ResponseEntity<UserDto> getUserById(@PathVariable String userId){
UserDto userDto = this.userService.getUserByUserId(userId);
return ResponseEntity.ok(userDto);
}
@PostMapping("/update")
public ResponseEntity<UserDto> updateUser(@RequestBody UpdateUserRequest request) {
UserDto updatedUserDto = this.userService.updateUser(request.getUserDto());
return ResponseEntity.ok(updatedUserDto);
}
}
@Getter
@Setter
public class UpdateUserRequest {
private UserDto userDto;
/* 생략 */
}
@AccessibleUser
라는 Custom Annotation을 붙여서 검증하고 싶다.@AccessibleUser
로 검증하고 싶다.@ModifiableRole
annotation을 만들어 검증한다. @Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface AccessibleUser {
String message() default "Access denied for this user.";
}
@RequiredArgsConstructor
public class AccessibleUserValidator implements ConstraintValidator<AccessibleUser, String> {
private final UserService userService;
@Override
public boolean isValid(String userId, ConstraintValidatorContext context) {
// SecurityContext에서 요청자 정보 가져오기
User currentUser = getCurrentUser();
if (currentUser == null || !currentUser.isAdmin()) {
return false;
}
// 조회 혹은 수정 할 user의 role tier가 요청자의 tier 이하 면 OK!
User userToCheck = userService.getUserById(userId);
return currentUser.getUserRole().getTier() <= userToCheck.getUserRole().getTier();
}
// 대충 Spring Security로 사용자 정보 가져오는 로직
private User getCurrentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return null;
}
return ((CustomUserDetails) authentication.getPrincipal()).getUser();
}
}
ConstraintValidator
를 구현한 class로 첫 번째 generic으로 Annotation이 들어가고 두 번째 generic에는 검증 할 파라미터(필드)의 타입이 들어간다.true
아니면 false
를 return 하면 되겠다.
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = AccessibleUserValidator.class)
public @interface AccessibleUser {
String message() default "Access denied for this user.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
validatedBy
에 조금전에 만든 Validator class를 넣어주었다. 해당 class의 isValid
로 검증을 하겠다는 뜻@Validated
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping("/{userId}")
public ResponseEntity<UserDto> getUserById(@PathVariable @AccessibleUser String userId){
UserDto userDto = this.userService.getUserByUserId(userId);
return ResponseEntity.ok(userDto);
}
/* 생략 */
}
@AccessibleUser
어노테이션을 붙여 @Constraint
에 지정된 AccessibleUserValidator
로 검증하게 한다.
만약 validation이 실패하면 위와 같이 ConstraintViolationException
이 떨어진다.
이 exception을 핸들링하여 활용 가능하겠지만 일단 지금은 생략하자.
- @RequestBody로 들어오는 userDto의 userId와 userRole을 검증한다.
- userId는 위에서 만든 @AccessibleUser로 검증하고 싶다.
- userRole은 @ModifiableRole annotation을 만들어 검증한다.
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ModifiableRole {
String message() default "Cannot change to the specified role.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
마찬가지로 만들어주고 @Constraint안에 넣어 줄 validator까지 만들어보자.
@RequiredArgsConstructor
public class RoleValidator implements ConstraintValidator<ModifiableRole, Role> {
private final AuthFacade authFacade;
@Override
public boolean isValid(Role targetRole, ConstraintValidatorContext context) {
// SecurityContext에서 요청자 정보 가져오기
User currentUser = authFacade.getCurrentUser();
if (currentUser == null || !currentUser.isAdmin()) {
return false;
}
// 수정할 권한이 요청자의 권한 이하일때만 valid
return currentUser.getUserRole().getTier() <= targetRole.getTier();
}
}
@ModifiableRole
로 Role
타입을 검증하겠다는 의미이다.@Constraint(validatedBy = RoleValidator.class)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ModifiableRole {
String message() default "Cannot change to the specified role.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Constraint에 RoleValidator class를 지정해주자.
public class UserDto {
@AccessibleUser
private String userId;
@ModifiableRole
private Role userRole;
private boolean admin;
/* 생략 */
}
UserDto에 담겨있는 userId와 userRole에 제약 조건을 담은 어노테이션을 붙여주고
@Validated
@RestController
@RequestMapping("/users")
public class UserController {
/* 생략 */
@PostMapping("/update")
public ResponseEntity<UserDto> updateUser(@RequestBody @Valid UpdateUserRequest request) {
UserDto updatedUserDto = this.userService.updateUser(request.getUserDto());
return ResponseEntity.ok(updatedUserDto);
}
}
MethodArgumentNotValidException이 발생하는데 DefaultHandlerExceptionResolver가 처리하면서 400으로 떨어뜨린다.
이번에는 우리가 직접 저 Exception을 간단하게 핸들링해보자.
@RestControllerAdvice
@RequiredArgsConstructor
public class ValidationExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach((error) -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
}
}
@RestControllerAdvice
를 하나 만들어서 MethodArgumentNotValidException
처리를 위한 @ExceptionHandler
를 추가했다.
간단하게 Exception안에 있는 errorMessage를 ResponseBody에 넣어줬다.
이렇게 하면..
userRole 검증 실패 시
userId와 userRole 모두 검증 실패 시