인증 객체를 DTO 로 주입하고 테스트하기

cooper·2023년 11월 28일
0
post-thumbnail
  1. AS-IS
    1. 인증 객체를 주입받는 로직을 추가
    2. 정적 코드 분석기에서 보안 취약점 D등급 판정 (sonarcube)
    3. 인증 객체 기반 테스트 코드 작성
  2. Challenge
    1. 인증 객체를 주입받는 로직을 추가
      • @AuthenticationPrincipal 기반 복합 어노테이션 @CurrentUser 를 통한 인증 객체 주입 방식 채택
    2. 정적 코드 분석기에서 취약점 D등급 판정 (sonarcube)
      • 인증 객체로 Entity 를 사용하고 있어 발생한 문제로 판단
      • CurrentUserDtoArgumentResolver 를 구현하여 Entity -> DTO 로 변환
    3. 인증 객체 기반 테스트 코드 작성
      • MockCustomUserSecurityContextFactory 작성하여 인증 목 객체 주입
      • sonarcube 취약점 개선을 위한 기존 DTO 형태로 주입하는 방식을 활용하기 위한 커스텀 팩토리 작성 방법
  3. TO-BE
    1. 인증 객체 주입 로직 구현
    2. 취약점 A등급 판정
    3. 인증 객체 기반 테스트 코드 구현

1. 인증 객체를 주입받는 로직을 추가

@AuthenticationPrincipal??

  • SecurityContextHolder 에 존재하는 인증 객체(Principal)를 주입해주는 어노테이션
  • AuthenticationPrincipalArgumentResolver 를 통해 Principal 의 subclass 을 주입한다.

AuthenticationPrincipalArgumentResolver ??

(1) AuthenticationPrincipalArgumentResolver ??

  • 파라미터로 @AuthenticationPrincipal 를 찾아 인증 객체를 주입해주는 ArgumentResolver
  • package org.springframework.security.web.method.annotation 하위에 존재
  • spring 4.0 부터 도입

(2) method 살펴보기

supportsParameter

  1. 외부에서 MethodParameter의 annotaion 목록을 확인한다. (메서드의 각 파라미터를 탐색하면서 확인)
  2. AnnotationUtils를 통해 어노테이션이 AuthenticationPrincipal 타입 을 찾는다.
  3. 만약에 어노테이션 타입이 존재하면 해당 annotaion 을 반환하고 그렇지 않으면 null을 반환한다.
public final class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver {

		// do something...

		public boolean supportsParameter(MethodParameter parameter) {
		    return this.findMethodAnnotation(AuthenticationPrincipal.class, parameter) != null;
		}
		
		private <T extends Annotation> T findMethodAnnotation(Class<T> annotationClass, MethodParameter parameter) {
		    T annotation = parameter.getParameterAnnotation(annotationClass);
		    if (annotation != null) {
		        return annotation;
		    } else {
		        Annotation[] annotationsToSearch = parameter.getParameterAnnotations();
		        Annotation[] var5 = annotationsToSearch;
		        int var6 = annotationsToSearch.length;
		
		        for(int var7 = 0; var7 < var6; ++var7) {
		            Annotation toSearch = var5[var7];
		            annotation = AnnotationUtils.findAnnotation(toSearch.annotationType(), annotationClass); // 여기!
		            if (annotation != null) {
		                return annotation;
		            }
		        }
		
		        return null;
    }
}

resolveArgument
1. 해당 메서드의 파라미터에서 @AuthenticationPrincipal annotation 이 있는지를 탐색한다.
2. StandardEvaluationContext 클래스는 해당 표현식(e.g. spEL) 을 평가할 개체를 지정한다. (by. reflection)
3. Expression 객체는 ExpressionParser 을 통해 표현식 문자열 구문 분석을 한 내용과 StandardEvaluationContext 에 평가할 개체를 토대로 원하는 객체를 호출한다.

public final class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver {

		// do something...

		public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
		        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
		        if (authentication == null) {
		            return null;
		        } else {
		            Object principal = authentication.getPrincipal();
		            AuthenticationPrincipal annotation = (AuthenticationPrincipal)this.findMethodAnnotation(AuthenticationPrincipal.class, parameter); //(1)
		            String expressionToParse = annotation.expression();
		            if (StringUtils.hasLength(expressionToParse)) {
		                StandardEvaluationContext context = new StandardEvaluationContext(); // (2)
		                context.setRootObject(principal);
		                context.setVariable("this", principal);
		                context.setBeanResolver(this.beanResolver);
		                Expression expression = this.parser.parseExpression(expressionToParse); // (3)
		                principal = expression.getValue(context);
		            }
		
		            if (principal != null && !ClassUtils.isAssignable(parameter.getParameterType(), principal.getClass())) {
		                if (annotation.errorOnInvalidType()) {
		                    throw new ClassCastException(principal + " is not assignable to " + parameter.getParameterType());
		                } else {
		                    return null;
		                }
		            } else {
		                return principal;
		            }
		        }
		   }
}

CustomAnnotation 활용

  • 코드 가독성을 위해 @AuthenticationPrincipal 기반한 custom annotation 적용
  • 기존 spring 지원 기능을 활용하면 별도의 로직을 작성할 필요가 없기 떄문에 인증 객체 주입 생산성 향상
@Target({ ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal
public @interface CurrentUser {
}
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/mypages")
public class MyPageController {

    private final MyPageQueryFacade myPageQueryFacade;
    private final MyPageCommandFacade myPageCommandFacade;

    @GetMapping("/me")
    public ResponseEntity<BaseResponse<UserLookUpResponse>> searchMe(@CurrentUser CurrentUserDto currentUserDto) {
        UserLookUpResponse userLookUpResponse = myPageQueryFacade.findUserWithDetailInfo(currentUserDto.id());
        return ResponseEntity.ok(new BaseResponse<>(USER_LOOK_UP_SUCCESS, userLookUpResponse));
    }
}

인증 객체 Entity → DTO 변환 Custom Resolver 작성

  1. 기존 컨트롤러 코드에서 POJO 형태가 아닌 인증객체를 그대로 주입하는 방식으로 구현되어 있었음.
  2. sonarcube(정적 코드 분석) 에서 취약성 부분에서 D등급 판정
  3. 인증 객체는 엔티티가 상속하고 있어 엔티티 객체를 표현 계층에 할당하여 발생한 원인으로 판단
  4. custom resolver 인 CurrenUserDtoArgumentResolver 를 작업하여 반영하여 A 등급으로 판정 변경

코드 변경 이전 등급 판정

CurrentUserDtoArgumentResolver

public class CurrentUserDtoArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return findMethodAnnotation(CurrentUser.class, parameter) != null;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); // LoginAuthenticationToken 타입 authentication
        if (authentication == null) {
            return null;
        }

        CustomUser customUser = (CustomUser) authentication.getPrincipal(); // principal을 CustomUser타입으로 변환

        return new CurrentUserDto(customUser.getId(), customUser.getEmail()); // CustomUserDto 타입으로 반환
    }

    private <T extends Annotation> T findMethodAnnotation(Class<T> annotationClass, MethodParameter parameter) {
        T annotation = parameter.getParameterAnnotation(annotationClass);
        if (annotation != null) {
            return annotation;
        }

        Annotation[] annotationsToSearch = parameter.getParameterAnnotations();
        for (Annotation toSearch : annotationsToSearch) {
            annotation = AnnotationUtils.findAnnotation(toSearch.annotationType(), annotationClass);
            if (annotation != null) {
                return annotation;
            }
        }

        return null;
    }
}

코드 변경 등급 판정

3. 인증 객체 테스트 코드 작성

  1. 스프링에서 제공하는 @WithMockUser 는 Authentication 의 Pricipal 등록타입이 User 이다.
  2. DTO 방식을 등록하기 위해 MockCustomUserSecurityContextFactory 를 선언하고 @WithSecurityContext 를 할당
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = MockCustomUserSecurityContextFactory.class)
public @interface MockCustomUser {

    String value() default "user";

    String username() default "";

    String[] roles() default {"USER"};

    String password() default "password";
}
public class MockCustomUserSecurityContextFactory implements WithSecurityContextFactory<MockCustomUser> {

    @Override
    public SecurityContext createSecurityContext(MockCustomUser annotation) {

        String username = StringUtils.hasLength(annotation.username()) ? annotation.username() : annotation.value();
        Assert.notNull(username, () -> annotation + " cannot have null username on both username and value properties");

        List<GrantedAuthority> authorities = settingRole(annotation);
        CustomUser customUser = new CustomUser(username, annotation.password());
        Authentication authentication = UsernamePasswordAuthenticationToken.authenticated(customUser, "", authorities);

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

        return securityContext;
    }

    private List<GrantedAuthority> settingRole(MockCustomUser annotation) {
        List<GrantedAuthority> grantedAuthorities = new ArrayList<>();

        for (String role : annotation.roles()) {
            Assert.isTrue(!role.startsWith("ROLE_"), () -> "roles cannot start with ROLE_ Got " + role);
            grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + role));
        }

        return grantedAuthorities;
    }
}

Reference

profile
막연함을 명료함으로 만드는 공간 😃

0개의 댓글