비로그인 사용자의 권한 처리

서비스를 개발하면서 비로그인 사용자의 권한 처리가 필요하게 되었다.

비로그인 사용자도 글 조회는 가능하지만 글 작성, 수정, 삭제는 불가능하게 만들어야했다.

기존의 JWT Filter에서는 비로그인 사용자의 경우 필터단에서 바로 400번대 오류를 반환했기 때문에 수정이 필요했다.

어떻게 구현할까 고민하다가 스프링 시큐리티의 기능인 메서드 시큐리티를 적용하여 구현하기로 하였다.


구현하기

구현 순서는 아래와 같다.

  1. JWT Filter 및 SecurityConfig 수정
  2. MethodSecurityConfig 추가
  3. 메서드 시큐리티 적용

1. JWT Filter 및 SecurityConfig 수정

1-1. JWT Filter 수정

우선 기존의 JWT Filter 로직을 변경해야 했다.

기존에는 JWT 인증이 성공했을 때만 인증된 유저 정보를 시큐리티 컨텍스트에 담아 다음 필터로 넘겼었다.

실패하면 바로 필터단에서 HTTP Response 를 반환하였다.

수정 후에는 JWT 인증이 성공하면 인증된 유저 정보를, 실패하면 GUEST 유저를 시큐리티 컨텍스트에 담아서 다음 필터로 넘긴다.

성공하든 실패하든 다음 필터를 거치는 방식이다.

JwtAuthFilter

@Slf4j
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtUtils jwtUtils;

    public JwtAuthFilter(JwtUtils jwtUtils) {
        this.jwtUtils = jwtUtils;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        Cookie[] cookies = request.getCookies();
        log.info("[JWT 필터 입장]");
        try {
            String jwt = jwtUtils.getJwtFromCookie(cookies);
            Authentication authentication = jwtUtils.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);

            filterChain.doFilter(request, response);
        } catch (AuthenticationException e) {
            User guest = User.builder()
                .id(0L)
                .email("guest")
                .password("guest")
                .build();
            Authentication authentication = new AnonymousAuthenticationToken(
                "guest",
                new UserPrincipal(guest),
                List.of(
                    new SimpleGrantedAuthority("ROLE_GUEST")
                )
            );
            SecurityContextHolder.getContext().setAuthentication(authentication);

            filterChain.doFilter(request, response);
        }
    }
}

1-2. SecurityConfig수정

JWT 필터 로직이 수정됐으므로 JWT Filter를 빈 등록 할 때 ExcludeUrlsRequestMatcher가 필요 없어져서 제거하였다. 또한 SecurityConfig의 SecurityFilterChain을 모든 요청에 대해 허용해두었다.만약 인증이 필요한 URL이 있다면 추가하면 된다.

SecurityConfig

@Slf4j
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@Profile("!test")
public class SecurityConfig {

    private final ObjectMapper objectMapper;
    private final UserDetailsService userDetailsService;
    private final JwtUtils jwtUtils;

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring()
            .requestMatchers("/favicon.ico")
            .requestMatchers("/error")
            .requestMatchers(toH2Console()); // TODO: 배포 시 제거
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .sessionManagement(session -> session.sessionCreationPolicy(STATELESS))
            .authorizeHttpRequests(authorize -> authorize
                .anyRequest().permitAll()
            )
            .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(jsonAuthFilter(), UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(e -> {
                e.authenticationEntryPoint(new Http401Handler(objectMapper));
                e.accessDeniedHandler(new Http403Handler(objectMapper));
            })
            .csrf(AbstractHttpConfigurer::disable)
            .build();
    }

    @Bean
    public JwtAuthFilter jwtAuthFilter() {
        return new JwtAuthFilter(jwtUtils);
    }

    @Bean
    public JsonAuthFilter jsonAuthFilter() {
        JsonAuthFilter filter = new JsonAuthFilter("/api/auth/login", objectMapper);
        filter.setAuthenticationManager(authenticationManager());
        filter.setAuthenticationSuccessHandler(new LoginSuccessHandler(objectMapper, jwtUtils));
        filter.setAuthenticationFailureHandler(new LoginFailHandler(objectMapper));
        return filter;
    }

    @Bean
    public AuthenticationManager authenticationManager() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(provider);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new SCryptPasswordEncoder(
            16,
            8,
            1,
            32,
            64
        );
    }
}

2. MethodSecurityConfig 추가

스프링 시큐리티의 메서드 시큐리티 기능을 쓰려면 @EnableMethodSecurity 애노테이션을 달아줘야 한다

SecurityConfig에서 추가해도 되지만 따로 Config 파일로 뺐다.

추후 Permission Evaluator를 추가하기에도 이 쪽이 관리하기 편할거라고 생각했다.

MethodSecurityConfig

@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class MethodSecurityConfig {

    @Bean
    public MethodSecurityExpressionHandler methodSecurityExpressionHandler() {
        DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
        return handler;
    }
}

3. 메서드 시큐리티 사용

메서드 시큐리티는 Spring EL(Spring Expression Language)을 사용한다.

아래처럼 @PreAuthorize 애노테이션과 문자열을 입력하는 방식이다.

"hasRole('ROLE_USER')" 를 입력하면 ROLE_USER 인 유저만 사용가능하다.

비로그인 유저는 ROLE_GUEST 로 설정해두었기 때문에 시큐리티에서 걸러지게 된다.

 

단순 ROLE 뿐 아니라 추가 필터링 필요 시 Permission Evaluator 를 사용할 수도 있지만 이건 다음에 다뤄보도록 하겠다.

스프링 시큐리티

나는 스프링 시큐리티가 너무 싫다.

공부하면서 느낀건 편한것도 많지만 간단한 JWT 정도 적용하는데는 굳이 써야하나? 라는 생각이다.

우선 변경되는 부분이 많아서 전에 썼던 프로젝트 코드를 다시 쓰려면 deprecated 된게 한가득이다.

그렇다보니 바꿀 부분이 많아져서 스프링의 장점인 레퍼런스가 많다는 장점이 퇴색된다.

또 스프링 시큐리티라는 컨텍스트에 너무 의존적이라고 생각한다.

그래서 이걸 쓰려면 내부 구현을 속속들이 알아야 의도치 않은 결과를 만나지 않는다.

로그인을 FormData 형식으로 받아서 Json으로 로그인을 하려면 따로 필터를 만들어야 하는것도 불편하다.

복잡한 과정들을 추상화해서 쓰기 편하게 만들었단건 알겠지만 덕분에 공부해야할거도 꽤 있고 복잡하다.

여러데서 쓴다니까 공부는 해둬야지 별 수 있나 싶긴 하지만 그래도 싫은건 싫다.


시큐리티 환경에서 컨트롤러 테스트

무엇보다 내가 싫은건 시큐리티 환경에서 테스트 코드를 작성하기가 너무 번거롭다는 것이다.

아마 지금의 내 JWT 필터 로직이 스프링 시큐리티 컨텍스트와 따로 노는게 문제라고 생각은 하지만,

도무지 시큐리티 컨텍스트 내에 내 JWT 필터 로직을 넣는 방법을 모르겠다.

Argument Resolver로 JWT 기능을 구현했으면 진작 끝냈을것 같다.

이전 JWT 필터 글에서 썼던 대로 기본적으로 모든 요청에 대해 JWT를 검사한다.

따라서 컨트롤러 테스트를 할 때도 이 필터를 거쳐야하기 때문에 JWT를 안넣어주면 필터단에서 걸러진다.

이걸 어떻게 처리해야 하는지 고민하다가 시행착오 끝에 결국 테스트용 SecurityConfig를 만들기로 했다.


1. 구현

TestSecurityConfig

프로덕션 코드에서 검색되지 않도록 테스트 디렉토리에 아래처럼 테스트용 SecurityConfig를 작성한다.

@TestConfiguration
@EnableWebSecurity
@RequiredArgsConstructor
public class TestSecurityConfig {

    private final ObjectMapper objectMapper;
    private final UserDetailsService userDetailsService;
    private final JwtUtils jwtUtils;

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring()
            .requestMatchers("/favicon.ico")
            .requestMatchers("/error")
            .requestMatchers(toH2Console());
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .sessionManagement(session -> session.sessionCreationPolicy(STATELESS))
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jsonAuthFilter(), UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(e -> {
                e.authenticationEntryPoint(new Http401Handler(objectMapper));
                e.accessDeniedHandler(new Http403Handler(objectMapper));
            })
            .csrf(AbstractHttpConfigurer::disable)
            .build();
    }

    @Bean
    public JsonAuthFilter jsonAuthFilter() {
        JsonAuthFilter filter = new JsonAuthFilter("/api/auth/login", objectMapper);
        filter.setAuthenticationManager(authenticationManager());
        filter.setAuthenticationSuccessHandler(new LoginSuccessHandler(objectMapper, jwtUtils));
        filter.setAuthenticationFailureHandler(new LoginFailHandler(objectMapper));
        return filter;
    }

    @Bean
    public AuthenticationManager authenticationManager() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(provider);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new SCryptPasswordEncoder(
            16,
            8,
            1,
            32,
            64
        );
    }
}

SecurityConfig

기존 SecurityConfig에 아래처럼 @Profile 애노테이션을 이용해 특정 프로필에서 사용하지 못하게 설정한다.

본인은 test 프로필에서 사용하지 못하게 했다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@Profile("!test")
public class SecurityConfig {

2. 사용하기

1. 테스트용 config를 Import 및 @ActiveProfiles 설정

테스트 클래스에 아래 스크린샷처럼 @Import(TestSecurityConfig.class), @ActiveProfiles("test") 를 붙여준다.

2. 인증 필요한 테스트에 커스텀 @MockUser 애노테이션 적용

아래처럼 커스텀@MockUser 애노테이션을 적용한다.

@WithMockUser

스프링 시큐리티를 사용하면 시큐리티 컨텍스트에서 Principal 정보를 꺼내 쓸 수 있다.

따라서 인증 완료된 유저는 컨트롤러에서 @AuthenticationPrincipal 을 사용하여 사용자 정보를 이용하곤 한다.

하지만 컨트롤러 테스트를 할 때 컨트롤러를 호출하면 인증을 하지 않았기 때문에 UserPrincipal 객체가 null일 것이다.

당연히 실제 환경처럼 컨트롤러 호출 시 쿠키에 Jwt를 넣는 등의 인증 과정을 거치면 되겠지만,

매 테스트마다 유효한 Jwt를 만들어서 넣어줘야한다는 문제가 생긴다.

유저 정보를 DB에 넣고, 유저 정보로 Jwt를 만들고, 요청에 넣어주는건 굉장히 번거롭다.

 

이런 번거로움을 해소하기 위해 스프링 시큐리티에서는 @WithMockUser 라는 애노테이션을 제공한다.

이를 이용하면 시큐리티 컨텍스트에 MockUser 정보를 넣어두기 때문에 인증 없이 컨트롤러를 호출해도 문제없이 사용할 수 있다.

하지만 서비스마다 유저 객체 정보가 다르기 때문에 기본적인 @WithMockUser 를 사용하기에는 한계가 있다.

따라서 이번 글에서는 @WithMockUser 를 커스텀해서 사용하여 서비스에 맞는 MockUser 정보를 이용해볼 것이다.


1. 구현

CustomMockSecurityContext

MockSecurityContext를 만든다.

아래처럼 WithSecurityContextFactory를 구현하고, 시큐리티 컨텍스트에 MockUser 정보를 넣어준다.

@RequiredArgsConstructor
public class PawLandMockSecurityContext implements WithSecurityContextFactory<PawLandMockUser> {

    private final UserRepository userRepository;

    @Override
    public SecurityContext createSecurityContext(PawLandMockUser annotation) {
        User user = User.builder()
            .email(annotation.email())
            .nickname(annotation.nickname())
            .password(annotation.password())
            .type(annotation.type())
            .profileImage(annotation.profileImage())
            .introduce(annotation.getIntroduce())
            .build();
        userRepository.save(user);

        UserPrincipal userPrincipal = new UserPrincipal(user);
        SimpleGrantedAuthority role = new SimpleGrantedAuthority(annotation.role());
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userPrincipal,
            user.getPassword(),
            List.of(role));

        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(authenticationToken);
        return context;
    }
}

CustomMockUser

이제 위에서 만든 Mock 시큐리티 컨텍스트를 사용할 커스텀 MockUser 애노테이션을 만든다.

아래처럼 원하는 필드를 설정하고, 기본 값을 정해주면 애노테이션을 붙이면 설정한 MockUser 정보를 이용한다.

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = PawLandMockSecurityContext.class)
public @interface PawLandMockUser {

    String email() default "midcondria@naver.com";

    String nickname() default "나는짱";

    String password() default "asd123123";

    String profileImage() default "";

    String getIntroduce() default "";

    LoginType type() default LoginType.NORMAL;

    String role() default "ROLE_USER";

}

2. 사용하기

1. 애노테이션만 붙일 때

아래처럼 애노테이션만 붙이면 기본 값으로 설정한 정보를 사용한다.

2. 애노테이션에 원하는 값을 넣을 수 있음

아래처럼 애노테이션에 원하는 값을 넣으면 원하는 설정한 정보를 사용할 수 있다.

JwtFilter

이번 프로젝트에서는 스프링 시큐리티를 사용하기로 했다.

또한 회원가입/로그인 시 Jwt를 발급하고 쿠키로 Jwt를 받아서 인증을 하는 방식을 채택했다.

그래서 Jwt를 검증하는 필터를 만들 필요가 생겼다.

OncePerRequestFilter는 스프링 시큐리티에서 제공하는 필터가 아니다.

그래서 인증 로직이 시큐리티 컨텍스트에 어거지로 넣는 느낌이 들어서 이게 맞나 싶긴 하다.

하지만 시큐리티 필터들도 기본적으로는 org.springframework.web.filter 에서 파생되지 않을까 싶었고

시간이 부족하기 때문에 우선은 이렇게 구현하기로 했다.

시큐리티는 더 공부하면 더 좋은 방법을 찾을 수 있을지 모르겠다.


구현

JwtAuthFilter

OncePerRequestFilter를 상속받은 JwtAuthFilter를 만든다.

스프링 시큐리티와 따로 노는 느낌이 드는건 이 필터를 빈 등록하면 모든 요청에 대해 필터링을 한다.

시큐리티에서 승인한 URL까지 필터링을 하기 때문에 골치였다.

결국 ExcludeUrlsRequestMatcher를 따로 구현하고,  shouldNotFilter를 오버라이드 해서 필터링 하지 않는 URL들을 설정했다.

JwtUtils에서 Jwt를 검증하고, 만약 잘못된 요청이라면 예외를 던져서 catch 문에서 에러 메시지를 내려줬다.

public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtUtils jwtUtils;
    private final RequestMatcher excludeUrlsMatcher;

    public JwtAuthFilter(JwtUtils jwtUtils, RequestMatcher excludeUrlsMatcher) {
        this.jwtUtils = jwtUtils;
        this.excludeUrlsMatcher = excludeUrlsMatcher;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return excludeUrlsMatcher.matches(request);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        Cookie[] cookies = request.getCookies();
        try {
            String jwt = jwtUtils.getJwtFromCookie(cookies);
            Authentication authentication = jwtUtils.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);

            filterChain.doFilter(request, response);
        } catch (BadCredentialsException e) {
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.setCharacterEncoding(UTF_8.name());
            response.setStatus(SC_UNAUTHORIZED);
            response.getWriter().write(e.getMessage());
        }
    }
}

ExcludeUrlsRequestMatcher

RequestMatcher를 구현하는 구현체를 만들어서 JwtAuthFilter에서 필터링 하지 않는 URL들을 지정했다.

public class ExcludeUrlsRequestMatcher implements RequestMatcher {

    private String[] excludeUrls;

    public ExcludeUrlsRequestMatcher(String... excludeUrls) {
        this.excludeUrls = excludeUrls;
    }

    @Override
    public boolean matches(HttpServletRequest request) {
        String requestURI = request.getRequestURI();
        AntPathMatcher pathMatcher = new AntPathMatcher();
        return Arrays.stream(excludeUrls)
            .anyMatch(excludeUrl -> pathMatcher.match(excludeUrl, requestURI));
    }
}

SecurityConfig

@Slf4j
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final ObjectMapper objectMapper;
    private final UserDetailsService userDetailsService;
    private final JwtUtils jwtUtils;

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring()
            .requestMatchers("/favicon.ico")
            .requestMatchers("/error")
            .requestMatchers(toH2Console()); // TODO: 배포 시 제거
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .sessionManagement(session -> session.sessionCreationPolicy(STATELESS))
            .authorizeHttpRequests(authorize -> authorize               
                .requestMatchers("/**").permitAll()
            )
            .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(jsonAuthFilter(), UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(e -> {
                e.authenticationEntryPoint(new Http401Handler(objectMapper));
                e.accessDeniedHandler(new Http403Handler(objectMapper));
            })
            .csrf(AbstractHttpConfigurer::disable)
            .build();
    }

    @Bean
    public JwtAuthFilter jwtAuthFilter() {
        return new JwtAuthFilter(jwtUtils, new ExcludeUrlsRequestMatcher(
            "/api/auth/**",
            "/api/v1/auth/**",
            "/swagger-ui/**",
            "/swagger-resources/**",
            "/v3/api-docs/**"));
    }

    @Bean
    public JsonAuthFilter jsonAuthFilter() {
        JsonAuthFilter filter = new JsonAuthFilter("/api/auth/login", objectMapper);
        filter.setAuthenticationManager(authenticationManager());
        filter.setAuthenticationSuccessHandler(new LoginSuccessHandler(objectMapper, jwtUtils));
        filter.setAuthenticationFailureHandler(new LoginFailHandler(objectMapper));
        return filter;
    }

    @Bean
    public AuthenticationManager authenticationManager() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(provider);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new SCryptPasswordEncoder(
            16,
            8,
            1,
            32,
            64
        );
    }
}

결과

이렇게 해두면 ExcludeUrlsRequestMatcher 로 설정한 URL을 제외한 모든 요청에 대해 Jwt 인증을 수행한다.

그런데 스프링 시큐리티 세팅과 너무 따로 노는 느낌이 들어서 좋은 방법이 있다면 수정해보고 싶다.

+ Recent posts