본문 바로가기
Programming/TDD Project

Pull Request 022. 사용자 인증 및 권한 확인 기능 TDD로 구현하기 - Spring Security 설정 마무리

by JKROH 2023. 12. 7.
반응형

 지난 글에서 사용자 로그인 기능까지 마무리했다. 이번에는 사용자 인증 기능을 구현하면서 Spring Security 적용을 마치고자 한다. 물론, 이후 로그아웃 구현에서 또 등장하긴 하겠지만... 아무튼 귀찮은 설정은 끝난다.

 

 일단은 테스트 먼저 작성해보자. 테스트하고자 하는 점은 다음과 같다.

  • 인증되지 않은 사용자도 조회는 가능하다. 그러나 게시글이나 댓글의 작성, 수정, 삭제는 불가능하다.
  • 인증된 사용자는 위의 모든 기능의 이용이 가능하다.

 해당 조건을 테스트하기 위해서는 세 가지 부분을 테스트해야 한다.

  • 인증되지 않은 사용자가 조회 요청을 보냈을 때 조회가 가능해야 한다.
  • 인증되지 않은 사용자가 등록 요청을 보냈을 때 거절되어야 한다.
  • 인증된 사용자가 등록 요청을 보내면 수락되어야 한다.

 이제 테스트 메서드를 작성해보자.

    @Test
    @DisplayName("인증되지 않은 사용자가 조회 요청을 보내면 성공한다.")
    @WithAnonymousUser
    void testUnauthorizedRequestUnauthorizedMethod() throws Exception {
        mockMvc.perform(
                        get("/free-board")
                                .accept(MediaType.APPLICATION_JSON)
                                .contentType(MediaType.APPLICATION_JSON)
                ).andExpect(status().isOk());
    }

    @Test
    @DisplayName("인증되지 않은 사용자가 등록 요청을 보내면 실패한다.")
    @WithAnonymousUser
    void testUnauthorizedUserRequestAuthorizedMethod() throws Exception {
        String testTitle = "title";
        String testContent = "content";

        FreeBoardDto.Post testPost = new FreeBoardDto.Post();
        testPost.title = testTitle;
        testPost.content = testContent;

        String content = gson.toJson(testPost);
        mockMvc.perform(
                post("/free-board")
                        .accept(MediaType.APPLICATION_JSON)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(content)
        ).andExpect(status().isUnauthorized());
    }

    @Test
    @DisplayName("인증된 사용자가 등록 요청을 보내면 성공한다.")
    @WithUserDetails(value = "jk@gmail.com", setupBefore = TestExecutionEvent.TEST_EXECUTION)
    void testAuthorizedUserRequestAuthorizedMethod() throws Exception {
        String testTitle = "title";
        String testContent = "content";

        FreeBoardDto.Post testPost = new FreeBoardDto.Post();
        testPost.title = testTitle;
        testPost.content = testContent;

        String content = gson.toJson(testPost);
        mockMvc.perform(
                post("/free-board")
                        .accept(MediaType.APPLICATION_JSON)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(content)
        ).andExpect(status().isCreated());
    }

 

 위의 테스트를 기반으로 기능을 구현해보자. 일단은 인증 처리를 해주는 필터를 하나 만들어야겠다. 어떤 요청이 올 때마다 토큰을 검증해서 해당 사용자의 권한을 확인해야 하기 때문이다.

public class JwtVerificationFilter extends OncePerRequestFilter {

    private final JwtTokenizer jwtTokenizer;
    private final JwtVerifier jwtVerifier;
    private final CustomAuthorityUtils authorityUtils;
    private final MemberDetailsService memberDetailsService;

    public JwtVerificationFilter(JwtTokenizer jwtTokenizer, JwtVerifier jwtVerifier, CustomAuthorityUtils authorityUtils, MemberDetailsService memberDetailsService) {
        this.jwtTokenizer = jwtTokenizer;
        this.jwtVerifier = jwtVerifier;
        this.authorityUtils = authorityUtils;
        this.memberDetailsService = memberDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            jwtVerifier.verifyJws(request);
            Map<String, Object> claims = getClaims(request);
            setAuthenticationToContext(claims);
        }catch (CustomAuthenticationException | SignatureException ce){
            request.setAttribute("exception", ce);
        } catch (ExpiredJwtException ee) {
            request.setAttribute("exception", ee);
        } catch (Exception e) {
            request.setAttribute("exception", e);
        }

        filterChain.doFilter(request, response);
    }

    private Claims getClaims(HttpServletRequest request) {
        String jws = jwtTokenizer.getTokenFromHeader(request.getHeader("Authorization"));
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
        return jwtTokenizer.getClaims(jws, base64EncodedSecretKey).getBody();
    }
    
    private void setAuthenticationToContext(Map<String, Object> claims) {
        String username = (String) claims.get("email");
        UserDetails memberDetails = memberDetailsService.loadUserByUsername(username);
        List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List<String>) claims.get("roles"));

        Authentication authentication = new UsernamePasswordAuthenticationToken(memberDetails, null, authorities);
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String authorization = request.getHeader("Authorization");

        return authorization == null || !authorization.startsWith("Bearer");
    }
}

 

 OncePerRequest 필터를 상속한 JwtVerificationFilter를 구현해보자. 메서드들을 하나하나 살펴보자

  • doFilterInternal() : 이번 필터에서 할 일을 지정한다. 받아온 JWT를 검증하고, 토큰 안에 담긴 claims 정보를 가져오며, Authentication 객체를 Spring Context에 저장한다.
  • shouldNotFilter() : 만약 JWT를 함께 전송하지 않는다면 어떨까? doFilterInternal()의 모든 동작에서 예외처리가 발생할 것이다. 그것을 방지하기 위한 메서드이다. 만일 JWT와 함께 온 요청이 아니라면, 이 필터를 거치지 않는다.

 다른 주입된 객체들은 지난 포스팅에서 살펴보았으나, jwtVerifier만 살펴보지 않아서 살펴보고자 한다.

@Component
public class JwtVerifier {

    private final JwtTokenizer jwtTokenizer;

    public JwtVerifier(JwtTokenizer jwtTokenizer) {
        this.jwtTokenizer = jwtTokenizer;
    }

    public void verifyToken(String token, String base64EncodedSecretKey) {
        try{
            jwtTokenizer.getClaims(token, base64EncodedSecretKey).getBody();
        }catch (ExpiredJwtException ee){
            throw new CustomAuthenticationException(ExceptionCode.TOKEN_EXPIRED);
        }
    }
}

 

 흠.... 이 정도면 굳이 이렇게 안쓰는게 낫겠다. JwtTokenizer의 이름을 JwtManger로 바꾸고 해당 기능을 넣어줬다. 일단 Configuration 작성에 필요한 객체는 다 만들었다! 한 번 Configuration을 다시 보러 가자.

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    private final JwtManager jwtManager;
    private final CustomAuthorityUtils authorityUtils;
    private final MemberDetailsService memberDetailsService;

    public SecurityConfig(JwtManager jwtManager, CustomAuthorityUtils authorityUtils, MemberDetailsService memberDetailsService) {
        this.jwtManager = jwtManager;
        this.authorityUtils = authorityUtils;
        this.memberDetailsService = memberDetailsService;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .headers().frameOptions().sameOrigin()
                .and()
                .csrf().disable()
                .cors().configurationSource(corsConfigurationSource())
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .formLogin().disable()
                .httpBasic().disable()
                .exceptionHandling()
                .authenticationEntryPoint(new MemberAuthenticationEntryPoint())
                .accessDeniedHandler(new MemberAccessDeniedHandler())
                .and()
                .apply(new CustomFilterConfigurer())
                .and()
                .authorizeHttpRequests(authorize -> authorize
                        .anyRequest().permitAll()
                );

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {

        ...
        
    }

    public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
        @Override
        public void configure(HttpSecurity builder) {
            AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);

            JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtManager);

            jwtAuthenticationFilter.setFilterProcessesUrl("/auth/sign-in");

            jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler());
            jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler());


            JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtManager, authorityUtils, memberDetailsService);

            builder
                    .addFilter(jwtAuthenticationFilter)
                    .addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class);
        }
    }
}

 

 코드가 상당히 긴 데 사실 기능은 세 개가 끝이다. 순서대로 Security Filter Chain구성, CORS 연결 구성, SecurityFilterChain에 적용될 Filter Configurer다. CORS는 현재 살펴볼 일은 없다. 추후 프론트 엔드 구현 후 연결하기 위해서 필요하다.

 

 먼저 filterChain()을 하나하나 뜯어보자.

  • headers().frameOptions().sameOrigin() : 동일 출처로부터 들어오는 request만 페이지 렌더링을 허용한다.
  • csrf().disable() : csrf 공격에 대한 Spring Security의 설정을 비활성화 한다
  • cors().configurationSource() : 설정한 cors configuration을 적용한다.
  • sessionManagement().sessionCreationPolicy() : 
  • formLogin().disble() : 폼 로그인 방식을 사용하지 않는다.
  • httpBasic().disable() : httpBasic은 요청시마다 usernama과 password를 같이 보내는 방식이다. 토큰 방식이므로 사용하지 않는다.
  • exceptionHandling().authenticationEntryPoint().accessDeniedHandler() : 필터에서 예외 발생 시 어떻게 처리할 것인지를 설정한다.
  • authenticationEntryPoint() : 인증 과정에서 문제가 발생하면 해당 객체가 예외 처리한다.
  • accessDeniedHandler() : 인증 이후 권한이 없으면 해당 객체가 예외처리한다.
  • authorizeHttpRequests() : 요청 URI에 대한 접근 권한을 설정한다. 사실상 오늘의 핵심이 된다. 순서에 주의하자.

 다음으로 CustomFilterConfigurer 클래스와 configure()다. 해당 클래스는 우리가 구현한 JwtAuthenticationFilter를 등록하는 역할을 한다.

 

 configure()메서드에서는 필터에 등록할 AuthenticationManager 객체를 만들고 AuthticationFilter에 진입점("/auth/sign-in")을 만들어주며, 인증에 성공 실패 했을 때의 handler를 등록한다. 다음으로 JwtVerificationFilter를 만들고 마지막으로 HttpSecurityBuilder에 두 필터를 추가해준다. 로그인을 한 다음 검증을 하는 순서이므로 Auth가 먼저 Verify가 나중이다.

 

 그래서, 권한 검증은 어떻게 해야 하나? 코드로 바로 알아보자.

.authorizeHttpRequests(authorize -> authorize
        .antMatchers(HttpMethod.POST, "/free-board/**", "/review/**", "/recruitment/**", "/comment/**").hasRole("CUSTOMER")
        .antMatchers(HttpMethod.PUT, "/free-board/**", "/review/**", "/recruitment/**", "/comment/**", "/member/**").hasRole("CUSTOMER")
        .antMatchers(HttpMethod.DELETE, "/free-board/**", "/review/**", "/recruitment/**", "/comment/**", "/member/**").hasRole("CUSTOMER")

        .antMatchers(HttpMethod.POST, "/barber-shop/**").hasRole("BARBER")
        .antMatchers(HttpMethod.PUT, "/barber-shop/**").hasRole("BARBER")
        .antMatchers(HttpMethod.DELETE, "/barber-shop/**").hasRole("BARBER")

        .anyRequest().permitAll()
);

 

 antMatchers()를 사용하면, 어떤 HTTP 메서드에 대해서 권한 제한을 적용할 건지, 어떤 uri에 적용할 건지를 설정할 수 있다. 마지막 anyRequest().permitAll()을 통해 위에 적용하면 위에서 제한한 사항들 외에는 모두 접근할 수 있다.

 

 앞서 말했듯이 GET요청은 모두에게 허용되어야한다. 따라서 antMatchers()로 별도의 제약을 걸지 않았다. 반면, POST, PUT, DELETE의 경우엔 회원의 가장 기본 권한인 CUTOMER를 요구한다. 바버샵에 대한 등록, 수정, 삭제 요청은 바버 권한이 있어야 한다.

 

 마지막으로 테스트를 수행하면 잘 통과함을 확인할 수 있다. 핸들러 클래스에 대한 구현은 별도로 기술하지는 않겠다. 궁금하시면 답변 드릴 수는 있다.

 

 전체 코드는 링크에서 확인할 수 있다.

반응형

댓글