본문 바로가기
공부/프로그래밍

springboot Test 중에 403, 401 에러가 날때(spring security)

by demonic_ 2023. 3. 22.
반응형

 

Spring Security 가 구성되어 있는 프로젝트에서 Controller 테스트를 하다보면 인증때문에 에러가 난다.

WebMvcTest로 수행했다 하더라도 Security 는 Filter 영역에 추가되는 것이기 때문에 그렇다.

 

WebMvcTest를 사용하게 되면 내가 설정한 Spring Security Configuration 들을 불러오지 않고 최소한의 것들을 넣기 때문에 내가 필터한 URL도 모두 무시하게 되서 403, 401 에러가 나게 되는 것이다.

 

 

일단 이 상황에서 2가지 방법이 있는데

  1. mockMvc에 .with(SecurityMockMvcRequestPostProcessors.csrf()) 를 추가
  2. 클래스에 @AutoConfigureMockMvc(addFilters = false) 추가

 

1번 방법

1번으로 하게 되면 403 에러는 피하게 되는데 401 에러를 다시 만나게 된다.

어찌됐든 인증을 하지 못했기 때문이다.

한마디로 말하면 대부분에 테스트에서는 실패할 것이다(403 피했더니 401이 나오는 격이라)

 

 

2번 방법

2번 방법으로 하면 테스트 작성이 가능하다.

그러나 이대로만 쓰면 SpringSecurity 의 Authentication.getName() 을 가져오면 에러가 난다. 즉 인증된 유저를 조회할때 null 이 된다

 

Cannot invoke \"org.springframework.security.core.Authentication.getName()\" because \"authentication\" is null

 

그래서 추가해주어야 하는데 메서드에 다음을 추가한다.

    @Test
    @WithMockUser("userName")
    void 내정보조회() throws Exception {
    ...
    }

 

전체로 보면 다음과 같이 한다

@WebMvcTest(LoginController.class)
@AutoConfigureMockMvc(addFilters = false)
class UserControllerTest {
...
    @Test
    @WithMockUser("user1")
    void 내정보조회() throws Exception {
    ...
    }
...
}

 

그럼 이쯤에서 csrf 가 무엇인지와 2번방법에서 사용했던 @WithMockUser 가 어떤 용도인지 확인해 보는게 좋겠다.

 

 

CSRF는 무엇인가?

Cross-Site Request Forgery 문장의 약어로 특정 요청을 보내도록 유도해 공격하는 행위다. 이것을 방지하기 위해 서버에서는 뷰를 만들때 임의로 생성된 CSRF 토큰을 뷰에 심는다. 생성된 뷰에서 서버에 요청할때는 생성된 토큰을 같이 넘겨주게 되고, 이 토큰을 비교하여 유효한지를 체크한다.(AccessToken 과는 전혀 다름)

 

여기서 특징은 뷰를 서버에서 만들어주었다는 점이다. 즉 스프링의 대표적인 view 인 thymeleaf가 대표적이라 하겠다.

 

반대로 클라이언트가 독립적인 뷰를 갖는 경우, 예를들어 모바일 앱이나 React 로 구성된 프론트서버 등으로 구성된 경우 이것을 전달해 줄 수 없기 때문에 AccessToken을 사용하게 되고 CSRF는 잘 사용하지 않는다.

 

때문에 요즘 트랜드의 서버구성에서 SpringSecurity를 구성할때 다음의 옵션으로 비활성화 시키는 것이다.

http
    .csrf().disable()
    ...

 

이때 토큰 생성방식은 랜덤이며, 동일해야지만 통과를 해준다. 이 방식을 Synchronizer Token Pattern 이라고 한다.

 

그래서 위조된 사이트의 경우 view를 똑같이 꾸미고 서버에 호출한다 하더라도 임의로 생성된 csrf 값을 모르기 때문에 차단당하는 것이다.

 

그리고 우리가 테스트를 하면서 이문제 때문에 403 에러를 겪게 되는 것이다.

 

 

@WithMockUser 는 인증된 Mock 유저를 생성해주는 것이다.

그래서 '내정보 가져오기' 등에서 사용할때 인증정보를 통해 내 정보를 조회해서 데이터를 리턴한다.

정상적으로 호출할경우 로그인을 한 뒤에 발급받은 AccessToken을 이용하여 Header에 넣고 호출해야 하지만 테스트 단계에서 그렇게 까지 하기에는 정말 중요한 코드보다 그것을 실행하기 위한 사이드 코드가 많아진다.

 

그래서 Annotation을 이용해 쉽게 접근하는 것이다.

 

이것 외에 2개의 Annotation이 더 있는데 아래와 같다

 

@WithAnonymousUser - 미인증 사용자

@WithUserDetail - pricipal 내부값을 직접 사용하는 경우 사용

 

@WithAnonymousUser 를 사용하면 principal에서 "anonymous"가 들어가있다.

 

@WithUserDetail의 경우는 객체를 집어넣을 수 있는데, 그렇게 하려면 유저를 조회할 수 있는 UserService를 같이 등록해줘야한다. 여기서 UserService는 UserDetailsService를 상속하여 구현한 서비스여야 한다.

 

좀더 자세한 것은 다른 블로그에서 찾길 권장한다.

 

 

내 경우는 이걸 사용하면 테스트가 더 복잡해 질것 같아서 @WithMockUser 를 주로 쓴다

@WithMockUser 에는 다음의 옵션이 추가로 있는데 일단 내부 구성을 보자

@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@WithSecurityContext(factory = WithMockUserSecurityContextFactory.class)
public @interface WithMockUser {

	/**
	 * Convenience mechanism for specifying the username. The default is "user". If
	 * {@link #username()} is specified it will be used instead of {@link #value()}
	 * @return
	 */
	String value() default "user";

	/**
	 * The username to be used. Note that {@link #value()} is a synonym for
	 * {@link #username()}, but if {@link #username()} is specified it will take
	 * precedence.
	 * @return
	 */
	String username() default "";

	/**
	 * <p>
	 * The roles to use. The default is "USER". A {@link GrantedAuthority} will be created
	 * for each value within roles. Each value in roles will automatically be prefixed
	 * with "ROLE_". For example, the default will result in "ROLE_USER" being used.
	 * </p>
	 * <p>
	 * If {@link #authorities()} is specified this property cannot be changed from the
	 * default.
	 * </p>
	 * @return
	 */
	String[] roles() default { "USER" };

	/**
	 * <p>
	 * The authorities to use. A {@link GrantedAuthority} will be created for each value.
	 * </p>
	 *
	 * <p>
	 * If this property is specified then {@link #roles()} is not used. This differs from
	 * {@link #roles()} in that it does not prefix the values passed in automatically.
	 * </p>
	 * @return
	 */
	String[] authorities() default {};

	/**
	 * The password to be used. The default is "password".
	 * @return
	 */
	String password() default "password";

	/**
	 * Determines when the {@link SecurityContext} is setup. The default is before
	 * {@link TestExecutionEvent#TEST_METHOD} which occurs during
	 * {@link org.springframework.test.context.TestExecutionListener#beforeTestMethod(TestContext)}
	 * @return the {@link TestExecutionEvent} to initialize before
	 * @since 5.1
	 */
	@AliasFor(annotation = WithSecurityContext.class)
	TestExecutionEvent setupBefore() default TestExecutionEvent.TEST_METHOD;

}

 

위에서 본것처럼 username, password, roles 을 지정해줄 수 있다. 그래서 이런식으로도 가능하다

@WithMockUser(username = "유저1"
    , password = "password", roles = {"USER","ADMIN"})
void test1(){
...

다만 테스트를 위해 내가 필요한 것은 주로 principal이기 때문에 다음처럼 설정해서 사용한다

@WithMockUser("user1")

 

 

참고로 WebMvcTest를 사용하면 Controller 외에도 ControllerAdvice, JsonComponent, Filter, WebMvcConfig, HandlerMethodArgumentResolver 까지 로드되어 테스트 가능하다.

 

 

끝.

 

반응형

댓글