SpringSecurity - Method 단위 인가

twonezero·2일 전
0

기존 서비스 코드에서의 유저 검증

수정이나 삭제 등의 비즈니스 로직을 구현할 때, 엔티티의 소유자를 확인하고자 하는 로직을 작성해야할 때가 있습니다.
이런 경우에 서비스 코드마다 유저가 소유자인지 확인하는 코드를 써야 합니다.
아래에 보이는 코드는 짧을 지 모르지만, 서비스 코드마다 중복된 검증 로직이 들어가는 것은 비효율적일 수 있습니다.

// diary fetched ...
if (!diary.getUserAccount().getId().equals(userId)) {
            throw new CustomBaseException(DIARY_NOT_OWNER);
}
// 이후 비즈니스 로직

@EnableMethodSecurity 활용

Spring Security 에서는 AOP 개념을 활용한 Method 단위 Security 전략을 구현할 수 있습니다.

MethodSecurity 를 이용하기 위해서는 SecurityConfig 와 @EnableMethodSecurity 어노테이션을 선언한 Configuration 과 MethodSecurityExpressionHandler 이 필요합니다.

기본적인 SecurityConfig

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 정보를 저장하거나 관리 할 수 있습니다.

MethodSecurityConfig

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;
    }
}

위 클래스에서 중요한 것은 @EnableMethodSecurityMethodSecurityExpressionHandler 를 구현하고 등록하는 것입니다.

CustomPermissionEvaluator

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 입니다.

  • CustomUserDetails 는 UserDetails 를 구현하여 작성한 인증 클래스입니다.
  • 작성된 로직은 Security Context 에 존재하는 유저의 정보를 가져와 현재 조회하는 엔티티의 유저 아이디와 비교해서 소유자인지 검증하는 것입니다.

아래는 @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 로 변환 후 서비스로 넘기는 로직
    }
  • hasPermission( #diaryId, 'DIARY', 'UPDATE' ) 의 인자들은 순서대로, 위에서 봤던 targetId, targetType, permission 입니다.

@PreAuthorization 안의 사용된 문법은 SpEL 이라고 하여 Spring 에서 사용하는 표현식입니다.
참고 링크

Debug

디버깅 시 아래와 같이 targetId 와 targetType 이 잘 들어간 것을 확인할 수 있습니다.

결론

SpringSecurity 를 활용하여 메소드 단위의 인가를 AOP 기반으로 하는 코드를 구현해 보았습니다. 이를 더 잘 사용하기 위해서는 AOP 에 대한 이해가 더욱 필요할 것 같습니다.
Method Security - SpringSecurity

참고

IntelliJ 로 개발 시 주의

위와 같이 설정하면 잘 돼야 할텐데 계속해서 targetId 가 들어가지 않는 경우가 있습니다.
그런 상황이라면,
settings -> Build Tools -> Gradle 에서 Intellij 로 빌드하지 말고 Gradle 로 빌드를 하면 해결할 수 있습니다.

Spring Boot 3.2 이상인가에서 @PathVariable 에 들어온 타입을 Serializable 로 읽지 않는 것 같습니다.

profile
소소한 행복을 즐기는 백엔드 개발자입니다😉

0개의 댓글