본문 바로가기
📕 Spring Framework/Spring 개념 정리

Spring Security [1] - JWT를 이용한 REST API 인증과 인가

by GroovyArea 2022. 8. 18.
기존 인증은 JWT를 이용한 필터로, 인가는 인터셉터로 애노테이션을 정의해 손수 구현했었다.

이번에 리팩터링을 하면서, 스프링이 제공하는 보안 관련 프레임워크인 서큐리티를 사용해보자는 결정을 내려 도입하게 되었다.

어렵다고는 얼핏 들었지만, 이렇게 오래 걸릴 줄 몰랐다. 여러 블로그들을 참조하고, 잘 읽히지도 않는 공식문서들을 보아도 도무지 이해가 가지 않았다.
일단 머릿속에 그려져야 감이 잡히는데, 이건 뭐 필터도 여러 개이며 구현체도 왜 이렇게 많은지 그에 맞는 책임과 역할이 도저히 감이 오지 않는다. 

전부 추상화 되어 있어 커스텀해서 사용하기는 편하게 되었다는데, 전반적으로 모든 내용을 이해하기엔 쉽지 않기도 하고 따로 공부가 필요한 프레임워크라고 생각한다.

거진 1주 반이 넘어서야 내가 구현하고자 하는 인증 방식을 적용할만한 개념은 이해가 가 로그인, 로그아웃, 권한 처리까지는 구현하게 되었다. 맞게 구현한 것인지, 클린 한 코드가 아닐 테지만 그 험난한 과정을 정리해보겠다.

참고로 저는 Access Token 하나만 발급해 진행했답니다~

스프링 서큐리티란

  • 스프링 기반의 Authentication(인증), Authorization(권한)을 담당하는 하위 프레임워크
  • 필터 기반의 동작

  • 스프링 서큐리티의 필터 체인 순서이다. 처음엔 별로 공들여 보진 않았지만, 어느정도 본인이 구현하고 싶은 방식의 필터를 알아보고 순서를 봐 두는 게 좋을 것 같다..
  • 등록한 요청이 오면 이런식의 필터 체이닝을 거쳐 디스 패쳐 서블릿에 도착한다.

 

스프링 서큐리티 설정

이 글은 순전히 Jwt Token을 이용한 인증, 인가의 Rest Api를 위주로 구현했기 때문에 이 방식을 위주로 설정해본다.

 

1. cofigure(WebSecurity web)

@Override
public void configure(WebSecurity web) {
    web.ignoring().antMatchers("/db/**",
            "/mapper/**",
            "/static/**",
            "/templates/**"
            ); // 테스트 시 path 수정할 것.
}

=> 서큐리티 필터 체인을 무시하는 url을 설정할 수 있다. 웬만하면 /resources 패스를 설정

 

2. configure(HttpSecurity http)

@Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .addFilter(corsConfig.corsFilter())
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .httpBasic().disable()

                .authorizeRequests()
                .antMatchers("/login, /logout").permitAll()
                .antMatchers("/api/v1/**").hasAnyRole("USER", "ADMIN")
                .antMatchers("/api/v2/**").hasRole("ADMIN")// 테스트 시 path 관리할 것

                .and()
                .formLogin()
                .loginProcessingUrl("/login")


                .and()
                .logout()
                .logoutUrl("/logout")
                .addLogoutHandler((request, response, authentication) -> {
                    String token =
                            jwtProvider.getResolvedToken(request, JwtProvider.TOKEN_HEADER_KEY);

                    redisService.deleteData(token);
                })
                .logoutSuccessHandler((request, response, authentication) -> response.getWriter().write("Logout succeed"))

                .and()
                .addFilter(new JwtAuthenticationFilter(authenticationManager(), jwtProvider, redisService))
                .addFilter(new JwtAuthorizationFilter(authenticationManager(), jwtProvider, jwtValidator, jwtAuthenticator, redisService))

/*                .exceptionHandling()
                .authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                .accessDeniedHandler(new CustomAccessDeniedHandler())*/;
    }

=> 자... 이게 도대체 무슨 메서드 체인이다냐..

처음 봤을 땐 뭐 이런게 다 있어라는 생각을 했다. 걱정 말자,, 계속 찾아보다 보면 이 메서드가 이거구나 하고 설정하게 된다.. ㅎㅎ

기본적으로 rest api 이므로, crsf(), httpBasic() 은 disable() 설정을 했다.

또 jwt 인증 기반이므로 세션 인증은 꺼두었다.

 

그리고, authorizeRequests() 메서드로 권한을 허용하는 패스들을 관리할 수 있다. 

저 hasRole() 이런 메서드들은 access()를 대신 이용해 표현식을 파라미터로도 받는데, 그럴 경우에 가독성이 안 좋아 그냥 이 메서드를 이용했다.

 

로그인과, 로그아웃에 해당되는 설정을 할 수도 있다. 나는 인증 관련 작업은 전부 필터에서 처리할 것이므로, 요청 url을 설정하고 필터에서 인증 처리를 하기 위해 적절히 login()을 잘 체이닝 했다. 

 

로그아웃 관련도 마찬가지다. logout()을 이용해 필요한 메서드들을 잘 체이닝 해 람다식으로 간단히 액션을 커스텀할 수 있다.

 

다시 한번 말하지만, 서큐리티에서 기본적으로 제공하는 디폴트 동작이 있는데, 본인이 원하는 방식의 인증이 아니라면 반드시 상속받아 커스텀해주고, Bean으로 등록하거나 해야 한다.

@Bean
public AuthenticationProvider authenticationProvider() {
    return new CustomAuthenticationProvider(userRepository, principalDetailService);
}

=> 요 놈처럼~

나만의 비밀번호 암호화 방식이 있어 그걸 구현하기 위해 커스텀했다.

 

대략적인 플로우

이 플로우를 이해하는게 핵심이다..!!

=> 처음 보면 감이 잘 안 올 수도 있다. 계속 보고 코드에 적용시키고 실행해보면 머리에 자리 잡힌다.

플로우를 그려보면서 복기하는 것도 좋은 방법 같다.

 

  1. 요약하자면, 요청이 들어오면 인증 필터에서 사용자 인증용 토큰을 발급한다. (UsernamePasswordAuthenticationToken)
  2. AuthenticatioManager라는 인터페이스에서 구현체인 ProvideManager가 디폴트 인증 방식을 통해 토큰을 이용하여 인증한다. 그 과정에서 필요한 것이 UserDetails 객체인데 이 것도 인터페이스다! (무슨 말이냐면 구현하라는 뜻이겠죠? ㅎㅎ)
  3. UserDetailService 인터페이스에는 loadByUsername(String username)이라는 추상 메서드가 있는데 당연히 오버라이드 해서 DB에서 유저를 가져와 UserDetials를 계승한 객체 놈에다가 매핑해 리턴 시킴 된다~ (냐는 MapStruct 이용)
  4. 그 후 각자의 방식에 맞는 인증과 인가 필터를 골라 구현하고 인증이 완료된 객체를 SecurityContextHolder라는 곳에다가 저장시키면된다. 그럼 필요한 곳에서 전역적으로 저장한 authentication 객체를 참조할 수 있다. 자세한 내용은 이곳을 참조하길 바란다. (아주 설명이 좋음) https://catsbi.oopy.io/f9 b0 d83 c-4775-47da-9c81-2261851 fe0d0
 

스프링 시큐리티 주요 아키텍처 이해

목차

catsbi.oopy.io

 

코드를 봅시다

JwtAuthenticationFilter.java - 로그인

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final JwtProvider jwtProvider;
    private final RedisService redisService;

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtProvider jwtProvider, RedisService redisService) {
        super(authenticationManager);
        this.jwtProvider = jwtProvider;
        this.redisService = redisService;
        setFilterProcessesUrl("/login");

    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        try {
            LoginRequestDto loginRequestDto = new ObjectMapper()
                    .readValue(request.getInputStream(), LoginRequestDto.class);

            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                    loginRequestDto.getLoginId(), loginRequestDto.getPassword(), new ArrayList<>()
            );

            return getAuthenticationManager().authenticate(authenticationToken);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();

        String token = jwtProvider.createToken(String.valueOf(principalDetails.getId()), principalDetails.getLoginId(), principalDetails.getRole());

        redisService.setDataExpire(principalDetails.getLoginId(), token, JwtProvider.getEXPIRED_TIME());

        response.addHeader(JwtProperties.TOKEN_HEADER_KEY.getKey(), JwtProperties.AUTH_TYPE.getKey() + token);
    }
}
  • /login 요청에 한해 필터를 거친다. setFilterProcessesUrl() 이용
  • attempAuthentication()을 Override 해서 인증을 시도할 토큰을 생성 후  authenticationManager를 통해 인증을 한다.
  • 성공할 경우 Override된 successfulAuthentication()을 통해 인증 객체를 받아와 그것을 기반으로 토큰을 생성해 헤더로 넣어준다.
  • 나 같은 경우는 로그아웃까지 구현했기 때문에 redis에도 토큰을 넣어주었다.

 

JwtAuthorizationFilter.java - 인증&인가

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    private final JwtProvider jwtProvider;
    private final JwtValidator jwtValidator;
    private final JwtAuthenticator jwtAuthenticator;
    private final RedisService redisService;

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, JwtProvider jwtProvider, JwtValidator jwtValidator, JwtAuthenticator jwtAuthenticator, RedisService redisService) {
        super(authenticationManager);
        this.jwtProvider = jwtProvider;
        this.jwtValidator = jwtValidator;
        this.jwtAuthenticator = jwtAuthenticator;
        this.redisService = redisService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {

        String header = request.getHeader(JwtProperties.TOKEN_HEADER_KEY.getKey());

        if (header == null || !header.startsWith(JwtProperties.AUTH_TYPE.getKey())) {
            chain.doFilter(request, response);
            return;
        }

        String token = jwtProvider.getResolvedToken(request, JwtProperties.AUTH_TYPE.getKey());

        Authentication authentication = getAuthentication(token);

        if (token != null && jwtValidator.validateAccessToken(token)) {
            if(!redisService.getData((String) authentication.getPrincipal()).equals(token)) {
                throw new RuntimeException(ErrorMessages.TOKEN_MISMATCH.getMessage());
            }
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        chain.doFilter(request, response);
    }

    private Authentication getAuthentication(String token) {
        PrincipalDetails principalDetails = (PrincipalDetails) jwtAuthenticator.getAuthentication(token).getPrincipal();

        return new UsernamePasswordAuthenticationToken(
                principalDetails.getLoginId(), principalDetails.getPassword(), principalDetails.getAuthorities()
        );
    }
}
  • 토큰에 대한 유효성 검사와 인증 객체를 생성해 SecurityContext에 저장한다.
  • Redis에도 토큰을 넣었기 때문에, 추가적으로 일치 여부를 검사한다.

=> 이렇게 거치게 되면 인증 객체인 Authentication (실질적으로 UsernamePasswordAuthentication이지만 얘가 하위클래스이므로 대신 이용하겄지)를 SecurityConfig에 정의한대로 role을 체크해 권한 부여가 이루어져 접근이 가능하다.

 

Logout - 익명 내부 클래스 이용

.and()
.logout()
.logoutUrl("/logout")
.addLogoutHandler((request, response, authentication) -> {
    String token =
            jwtProvider.getResolvedToken(request, JwtProperties.TOKEN_HEADER_KEY.getKey());

    redisService.deleteData(token);
})
.logoutSuccessHandler((request, response, authentication) -> response.getWriter().write("Logout succeed"))

=> 위에서 보셨다시피 람다식으로 간편화된 내부 클래스의 메소드를 제정의 해 Redis에서 토큰을 삭제하는 방식으로 로그아웃을 간단히 구현했다.

 

참조 : https://catsbi.oopy.io/f9b0d83c-4775-47da-9c81-2261851fe0d0

 

스프링 시큐리티 주요 아키텍처 이해

목차

catsbi.oopy.io

다음엔 예외처리 과정을 진행하겠습니다~

반응형