수정이나 삭제 등의 비즈니스 로직을 구현할 때, 엔티티의 소유자를 확인하고자 하는 로직을 작성해야할 때가 있습니다.
이런 경우에 서비스 코드마다 유저가 소유자인지 확인하는 코드를 써야 합니다.
아래에 보이는 코드는 짧을 지 모르지만, 서비스 코드마다 중복된 검증 로직이 들어가는 것은 비효율적일 수 있습니다.
// diary fetched ...
if (!diary.getUserAccount().getId().equals(userId)) {
throw new CustomBaseException(DIARY_NOT_OWNER);
}
// 이후 비즈니스 로직
Spring Security 에서는 AOP 개념을 활용한 Method 단위 Security 전략을 구현할 수 있습니다.
MethodSecurity 를 이용하기 위해서는 SecurityConfig 와
@EnableMethodSecurity
어노테이션을 선언한 Configuration 과MethodSecurityExpressionHandler
이 필요합니다.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.cors(cors -> cors
.configurationSource(corsConfigurationSource())
)
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(
auth -> auth.anyRequest().permitAll()
)
.build();
}
//따로 정의한 AuthConfig 클래스 내용 일부
@Bean
public AuthenticationManager authenticationManager() throws Exception {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService());
authProvider.setPasswordEncoder(passwordEncoder());
return new ProviderManager(authProvider);
}
}
Spring Security 5.7 이후 부터는 WebSecurityConfigurerAdapter 를 상속받아 구현하지 않고 SecurityFilterChain 이라는 메소드 체인 형식의 코드를 구현하여 빈으로 주입하는 방식으로 이루어 집니다.
AuthenticationManager 는 useDetailService와 passwordEncoder를 주입받고, PrioviderManager 를 반환함으로써 login 시나 다른 인증관련 로직 시 Security Context 에 접근해 user 정보를 저장하거나 관리 할 수 있습니다.
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@RequiredArgsConstructor
@EnableMethodSecurity
@Configuration
public class MethodSecurityConfig {
private final CustomPermissionEvaluator customPermissionEvaluator;
@Bean
public MethodSecurityExpressionHandler createExpressionHandler() {
DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
expressionHandler.setPermissionEvaluator(customPermissionEvaluator);
return expressionHandler;
}
}
위 클래스에서 중요한 것은 @EnableMethodSecurity
와 MethodSecurityExpressionHandler
를 구현하고 등록하는 것입니다.
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import java.io.Serializable;
import static com.bodytok.healthdiary.exepction.CustomError.*;
@Slf4j
@RequiredArgsConstructor
@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {
private final DiaryRepository diaryRepository;
@Override
public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
return false;
}
@Override
public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {
// Security Context 에 존재하는 유저 정보 가져오기
CustomUserDetails userPrincipal = (CustomUserDetails) authentication.getPrincipal();
TargetType type = TargetType.fromString(targetType);
// targetId 가 중요하다.
return switch (type) {
case DIARY -> {
Diary diary = diaryRepository.findById((Long) targetId)
.orElseThrow(() -> new CustomBaseException(DIARY_NOT_FOUND));
yield diary.getUserAccount().getId().equals(userPrincipal.getId());
}
case COMMENT -> {
Comment comment = commentRepository.findById((Long) targetId)
.orElseThrow(() -> new CustomBaseException(COMMENT_NOT_FOUND));
yield comment.getUserAccount().getId().equals(userPrincipal.getId());
}
default -> {
log.error("[인가 실패] 사용자 소유가 아닙니다.");
yield false;
}
};
}
public enum TargetType {
DIARY,
COMMENT;
public static TargetType fromString(String targetType) {
try {
return TargetType.valueOf(targetType.toUpperCase());
} catch (IllegalArgumentException e) {
throw new RuntimeException("Invalid target type: " + targetType);
}
}
}
}
PermissionEvaluator
을 구현하는CustomPermissionEvaluator
를 작성합니다.구현해야하는 함수인 시그니처가 다른 두 개의 hasPermission 을 이용해 boolean 값을 리턴함으로서 인가를 수행할 수 있습니다.
위 함수에서 중요한 인자는 targetId, targetType 입니다.
아래는 @PreAuthorize()
를 활용한, 컨트롤러에서의 메소드 단위 보안 예시 코드입니다.
@PreAuthorize("hasPermission(#diaryId, 'DIARY', 'UPDATE')")
@PutMapping("/{diaryId}")
@Operation(summary = "다이어리 수정 - 이미지 먼저 저장 or 삭제 후 진행") // Swagger-ui 를 위한 어노테이션임
public ResponseEntity<Void> updateDiary(
@PathVariable(name = "diaryId") Long diaryId,
@RequestBody DiaryUpdate request,
@AuthenticationPrincipal CustomUserDetails userDetails
) {
... request 를 dto 로 변환 후 서비스로 넘기는 로직
}
@PreAuthorization
안의 사용된 문법은 SpEL 이라고 하여 Spring 에서 사용하는 표현식입니다.
참고 링크
디버깅 시 아래와 같이 targetId 와 targetType 이 잘 들어간 것을 확인할 수 있습니다.
SpringSecurity 를 활용하여 메소드 단위의 인가를 AOP 기반으로 하는 코드를 구현해 보았습니다. 이를 더 잘 사용하기 위해서는 AOP 에 대한 이해가 더욱 필요할 것 같습니다.
Method Security - SpringSecurity
IntelliJ 로 개발 시 주의
위와 같이 설정하면 잘 돼야 할텐데 계속해서 targetId 가 들어가지 않는 경우가 있습니다.
그런 상황이라면,
settings -> Build Tools -> Gradle 에서 Intellij
로 빌드하지 말고 Gradle
로 빌드를 하면 해결할 수 있습니다.
Spring Boot 3.2 이상인가에서
@PathVariable
에 들어온 타입을 Serializable 로 읽지 않는 것 같습니다.