날씨가 많이 쌀쌀해졌다.

image

요즘 학습량이 늘어나 새벽까지 공부하다보니
컨디션 난조가 심하다.. 일찍자려고해도
어떻게 하다보니까 해야할 것들이 밀려 늦게 잠을 청하게된다.

그러다보니 아침,점심에 정신을 못차리고 공부를 제대로 못하는..
악순환의 반복이다. 오늘은 최대한 일찍 공부를 마치고
월드컵도 시작이니 일찍 잠을 자야겠다.


어제 JWT 기초 파트에서 JWT에 대해 기초지식을
공부했었고, 실제로 AcessToken, RefreshToken을 만들어보기도 했다.

이제 JWT 토큰을 만드는 방법을 알았으니
이 토큰을 어떻게 Headers로 전송하고
Spring Security를 이용해 인증과정을 거치게 되는지? 에 대한
내용을 오늘 공부해보려 한다.

JWT를 이용한 Spring Security 인증

어떻게 인증을하지?

우선 Spring Security 인증처리에 대한
선수지식을 가지고 있어야 해당 내용을 이해할 수 있다.

JWT를 사용하여 인증을 진행하려면
기존에 인증처리에서 사용하는 UsernamePasswordAuthenticationFilter
비활성화로 변경한 후에 상속받아 클래스를 구현해 인증을 처리해줄 수 있다.
(구현하는건 이제부터 개발자가 해야하는 영역)

image

그림으로 한번 정리해보자.

UsernamePasswordAuthenticationFilter를 상속받는
JwtAuthenticationFilter 라는 클래스를 만들어 구현할 것이다.
그리고 우리가 구현한 필터는 등록해놓아야하고, 기존의
UsernamePasswordAuthenticationFilter 필터는 비활성화해줘야한다.

구현할때 유의할점은 인증을 총괄하는 AuthenticationManager
.authenticate();메서드를 이용해 인증 처리를 위임해줘야한다.
그러면 우리가 자주보았던 인증처리 흐름대로 코드가 진행되고

Spring Security에서 구현했던 UserDetails 객체를 만들어서
.loadUserByUsername();메서드 호출시 반환만 해주면
구현하는 부분은 끝난다.

그리고 인증완료된 객체가 돌아오면, JWT를 생성 후 클라이언트에
Response Header에 담아 보내주면된다.


다시 한번 정리해보자면
JwtAuthenticationFilter 이름으로 JWT Filter를 하나 만들 것이다.
AbstractAuthenticationProcessingFilter추상 클래스를 에서 filter가 시작된다.
UsernamePasswordAuthenticationFilter 클래스를 상속하면, 위에 추상클래스도
상속관계이기때문에 Overriding하여 구현할 수 있다.

Overriding한 메서드를 구현해야하는데 2가지를 구현해야한다.

  1. attemptAuthentication(); 메서드 구현
    -> 인증을 위임해주고 인증처리 완료된 객체가 반환되는 메서드이므로
    인증과 관련된 정보를 넘겨주는 것을 구현해야함.
  2. successfulAuthentication(); 메서드 구현
    -> 인증 완료 후 JWT 토큰을 만들어 Header에 담아주는 클래스를 구현해야함
    -> Security Context에 저장하는 부분은 추후 JWT 비교필터에서 추가


코드로 한번 확인해 보자 !

1). JwtAuthenticationFilter 구현

@Slf4j
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final JwtTokenizer jwtTokenizer;

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtTokenizer jwtTokenizer) {
        this.authenticationManager = authenticationManager;
        this.jwtTokenizer = jwtTokenizer;
    }
    
    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
        ObjectMapper objectMapper = new ObjectMapper();
        LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class); // ServletInputSteam 을 LoginDto 클래스 객체로 역직렬화

        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
        return authenticationManager.authenticate(authenticationToken);
    } 
    
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws ServletException, IOException {
        
        Member member = (Member) authResult.getPrincipal();

        String accessToken = delegateAccessToken(member); // accessToken 만들기
        String refreshToken = delegateRefreshToken(member); // refreshToken 만들기

        String headerValue = "Bearer "+ accessToken;

        response.setHeader("Authorization",headerValue); // Header에 등록
        response.setHeader("Refresh",refreshToken); // Header에 등록

        this.getSuccessHandler().onAuthenticationSuccess(request,response,authResult);
    }

    private String delegateAccessToken(Member member){
        Map<String,Object> claims = new HashMap<>();
        claims.put("username",member.getEmail());
        claims.put("roles",member.getRoles());

        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());

        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
        String accessToken = jwtTokenizer.generateAccesToken(claims, subject, expiration, base64EncodedSecretKey);

        return accessToken;
    }

    private String delegateRefreshToken(Member member){
        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
        String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);

        return refreshToken;
    }
}

해당 코드에서 유의 깊게 봐야할 부분은 JwtAuthenticationFilter클래스를 만들었고
extend로 UsernamePasswordAuthenticationFilter 클래스를 상속받고
@Overriding 하여 attemptAuthentication();successfulAuthentication(); 메서드를
구현하는 코드를 작성 중이다. 위에서 그림으로 보았던 부분이다.

구현 내용을 살펴보면 attemptAuthentication(); 메서드는
우리가 기존에 Form Login으로 파라미터를 가져오고 Authentication 객체를 만들어
authenticate(); 메서드로 인증 처리 위임을 시작해주는 부분이다.
Form Login 방식을 쓰지않고, 클라이언트 쪽에서 JSON 객체로

{
    "username" : "dhfif718@naver.com",
    "password" : "1111"
}

로그인 인증 정보를 이렇게 보내줄 것이다.
그렇기 때문에 attemptAuthentication(); 메서드를 구현한 것이고
받은 JSON 객체는 request.getInputStream();으로 꺼낼 수 있다.
위에서는 ObjectMapper 클래스를 이용해 Dto를 하나 만들어
맵핑하여 값을 저장해 주었다. 그리고 저장해준 데이터를 가지고 Authentication 객체를 만들었다.


successfulAuthentication();메서드는
인증된 객체가 넘어왔을 것이고, 인증된 객체 authResult
이전에 Spring Security 사용할 때 UserDetails 객체를 반환해 구현했었다.
실제 구현할때 UserDetailsMember 클래스를가 상속하도록 구현해서
authResult.getPrincipal();메서드로 Down Casting하여 Member 객체를
가져올 수 있게되는 것이다.

이렇게 가져온 Member 객체를 활용해서 AccessToken과 RefreshToken을 만들어
response.setHeader(); 메서드를 활용해 HEADER에 값을 추가할 수 있다.
HEADER에 까지 정상적으로 넣었으면 .onAuthenticationSuccess();를 호출해
성공적으로 인증된 객체를 만들었다고 알리고, 다음 Filter를 진행하게 된다.

여기까지 보았을때 한가지 의아한 점을 찾을 수 있다.
공부했던 인증 처리흐름에 마지막은 Security Context에 저장을해야
나중에 권한부여를 할때 Security Context에서 꺼낼 수 있는데
이부분을 구현하지 않았던점을 의아해할 수 있다.

해당부분은 다음 Filter에서 처리할 것이다.
그럼 다음 추가할 Filter를 알아보자


2). JwtVerificationFilter 구현

해당 필터는 이전에 필터 JwtAuthenticationFilter의 다음 Filter로
우리가 구현하는 새로운 Filter 클래스이다.

해당 필터의 역할은 넘어온 JWT에 대한 검증과
검증이 완료된 Authentication 객체를 Security Context
저장하는 역할을 구현해주는 클래스이다.

여기서 한가지 중요한점은 Security Context에 저장은 하지만
Session 방식을 사용하지 않는 형식으로 SecurityFilterChain에서 설정할 것이다.
그래야 JWT를 사용하는 목적과 맞기 때문이다.

public class JwtVerificationFilter extends OncePerRequestFilter {

    private final JwtTokenizer jwtTokenizer;
    private final CustomAuthorityUtils authorityUtils;

    public JwtVerificationFilter(JwtTokenizer jwtTokenizer, CustomAuthorityUtils authorityUtils) {
        this.jwtTokenizer = jwtTokenizer;
        this.authorityUtils = authorityUtils;
    }

    /*
    * 필터 추가, JWT 객체를 꺼내서 비교후 SecurityContext에 저장함
    * */

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

        // 예외처리 추가
        try{
            Map<String,Object> claims = verifyJws(request);
            setAuthenticationToContext(claims);
        } catch (SignatureException se){
            request.setAttribute("exception",se);
        } catch (ExpiredJwtException ex){
            request.setAttribute("exception",ex);
        } catch (Exception e){
            request.setAttribute("exception",e);
        }

        // 다음 Filter 실행
        filterChain.doFilter(request,response);
    }

     /*
     * 만약 request에 전달받은 authorization이 없으면 해당 필터는 실행안함
     * */
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        String authorization = request.getHeader("Authorization");
        return authorization == null || !authorization.startsWith("Bearer");
    }

    /*
    * request 객체로 claims 객체 꺼내는 메서드
    * */
    private Map<String,Object> verifyJws(HttpServletRequest request){
        String jws = request.getHeader("Authorization").replace("Bearer ","");
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
        Map<String ,Object> claims = jwtTokenizer.getClaims(jws, base64EncodedSecretKey).getBody();
        return claims;
    }

    /*
    * SecurityContext에 저장하는 부분
    * */
    private void setAuthenticationToContext(Map<String, Object> claims){
        String username = (String) claims.get("username");

        List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List)claims.get("roles"));

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

해당 클래스는 생각보다 간단하다.
우선 OncePerRequestFilter를 상속받아 필터로 사용하는 클래스이다.
사용자 한번의 요청에 딱한번만 실행하는 필터이고 minkukjo님의 블로그를 참고하면 좋을 것 같다.

우리는 로그인할때 JWT토큰을 HEADER에 싦어 클라이언트에 발행해 줬다.
이제 만약 클라이언트에서 어떠한 요청이 있다고 가정할때 HEADER Access Token을 보내면
request 객체를 이용해서 HEADER에서 JWT를 가져와서, 우리가 기존에 만들어둔
.getClamis();메서드를 이용해 비교후 claims 객체를 가져올 수 있다.

만약 토큰이 만료시간이 다되었거나 서명형식이 틀렸다거나하면
예외가 발생하면서 JWT 인증이 되었다고 판단하지않아
다음 필터를 진행하게 된다. 여기서 다음 필터를 진행하게 되면 Security Context에는
아무런 객체가 없기때문에 권한확인하는 필터에서 인가가 되지 않는다.

즉, 우리가 발행한 Access Token과 일치하는 지 확인한다음
Security Context에 인증된 객체를 저장해주는 필터 클래스이다.
만약 일치하지않거나 예외가 발생하면 Security Context에 객체를
저장하지 않고 다음 Filter로 넘어가는 방식이다.

이렇게 까지만 구현하면 JWT 관련해서 발행과, 인증에 대한
구현 부분은 끝났다. 이제 우리가 마지막으로 해줘야할 부분은
Spring SecurityFilterChain에 설정정보를 설정하는일만 남았다.


3). Spring Security FilterChain 설정

@Configuration
@EnableWebSecurity(debug = true)
public class SecurityConfigurationV6 {

    private final JwtTokenizer jwtTokenizer;

    private final CustomAuthorityUtils authorityUtils;

    public SecurityConfigurationV6(JwtTokenizer jwtTokenizer, CustomAuthorityUtils authorityUtils) {
        this.jwtTokenizer = jwtTokenizer;
        this.authorityUtils = authorityUtils;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .headers().frameOptions().sameOrigin()
                .and()
                .csrf().disable()
                .cors(Customizer.withDefaults()) // corsConfigurationSource라는 이름으로 등록된 Bean을 사용한다고 정의
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 정책 추가 (JWT사용으로 STATELESS로 설정)
                .and()
                .formLogin().disable() // CSR 방식사용으로 formLogin 비활성화
                .httpBasic().disable() // UsernamePasswordAuthenticationFilter 등 비활성화
                .exceptionHandling() // 예외처리 기능이 작동
                .authenticationEntryPoint(new MemberAuthenticationEntryPoint())  // 인증 실패시 처리
                .accessDeniedHandler(new MemberAccessDeniedHandler()) // 인증 실패시 처리
                .and()
                .apply(new CustomFilterConfigurer()) // 커스터마이징한 필터를 추가할 수 있음
                .and()
                .authorizeHttpRequests(authorize -> authorize
                        .antMatchers(HttpMethod.POST, "/*/members").permitAll()
                        .antMatchers(HttpMethod.PATCH, "/*/members/**").hasRole("USER")
                        .antMatchers(HttpMethod.GET, "/*/members").hasRole("ADMIN")
                        .antMatchers(HttpMethod.GET, "/*/members/**").hasAnyRole("USER", "ADMIN")
                        .antMatchers(HttpMethod.DELETE, "/*/members/**").hasRole("USER")
                        .anyRequest().permitAll()
                );
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    /*
    * 구체적인 CORS 정책을 설정
    * */
    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*")); // 스크림트 기반의 HTTP 통신을 허용
        configuration.setAllowedMethods(Arrays.asList("GET","POST", "PATCH", "DELETE")); // HTTP Method에 대한 HTTP 통신 허용

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); // CorsConfigurationSource 구현체 생성
        source.registerCorsConfiguration("/**", configuration); // 모든 URL에 정책 적용
        return source;
    }

    public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer,HttpSecurity> {
        @Override
        public void configure(HttpSecurity builder) throws Exception {
            AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class); // AuthenticationManager 객체얻기

            JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer); // JwtAuthenticationFilter 객체만들기 (생성하면서 DI하기)

            // 상속받은 AbstractAuthenticationProcessingFilter 클래스의 FilterProcessesUrl 설정해주기 (설정안할시 default: /Login)
            // 즉, 로그인 요청할때 이 Url로 요청해야함, 우리가 기존에 UsernamePassword 필터 사용시에는 /process_login 하던부분임
            jwtAuthenticationFilter.setFilterProcessesUrl("/v11/auth/login");
            // Exception 추가
            jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler());
            jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler());

            JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer, authorityUtils);

            // Spring Security FilterChain에 추가
            builder.addFilter(jwtAuthenticationFilter)  // 우리가만든 jwtAuthenticationFilter 필터추가
                    .addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class); //  jwtVerificationFilter 필터추가, 뒤에 클래스는 어느클래스 다음에 실행할지 설정

        }
    }
}

이전에 Spring Security 설정과 크게 달라진 점은 3가지 정도 있다.

  1. CORS 정책을 추가
  2. 세션 정책 추가
  3. 우리가 만든 Filter 추가
  4. 예외처리에 대한 기능 추가

이렇게 3가지에 대한 내용을 추가 설정해 주었다. 자세한 내용은 코드옆에 설명을 적어 놓았다.

여기서 세션정책관련해서는 아까 SecurityContext에 객체를 저장하지만
STATELESS로 설정하여 서버에서 관리하지 않게 되어진다.
즉, 무상태성으로 서버에서 관리하지 않는 것이다.

또한 예외처리를 위한 우리가 구현한 클래스들도 설정해놓았고
Cors 정책관련해서도 Bean으로 등록해 추가해주었다.

그리고 마지막으로 사용자에 따른 페이지 권한에 대한
antMatchers();를 설정한 다음 마무리를 지었다.

Security filter chain: [
  DisableEncodeUrlFilter
  WebAsyncManagerIntegrationFilter
  SecurityContextPersistenceFilter
  HeaderWriterFilter
  CorsFilter
  LogoutFilter
  JwtAuthenticationFilter
  JwtVerificationFilter
  RequestCacheAwareFilter
  SecurityContextHolderAwareRequestFilter
  AnonymousAuthenticationFilter
  SessionManagementFilter
  ExceptionTranslationFilter
  AuthorizationFilter
]

최종적으로 완성된 SecurityFilterChain 구성은
보이는 것과 같이 CorsFilter 추가와
UsernamePasswordAuthenticationFilter가 비활성화 되었고
우리가 만들어준 필터 JwtAuthenticationFilterJwtVerificationFilter
추가된 모습을 콘솔로 확인이 가능하다. (@EnableWebSecurity(debug = true))


이렇게 JWT 설정관련해서 주요하게 우리가 구현해야하는
클래스를 설명했고 구현한 코드도 적어보았다.

구현했던 클래스 중 가장 중요한 클래스만 정리를 해두었고
해당 클래스를 구현하기위해 보조로 필요한 예외처리 클래스라든가 등등..은
따로 코드를 올리진 않았다는 점은 참고해주실 바란다.


JWT 예외관련

정상적인 JWT를 하나 만들었다.

image

해당 JWT를 가지고 값을 변경해보면서
예외가 발생하는지 테스트를 해보았다.


1). ExpiredJwtException 예외

JWT를 생성할 때 지정한 유효기간이 초과할때 발생하는 예외다

io.jsonwebtoken.ExpiredJwtException: JWT expired at 2022-11-24T07:28:32Z. Current time: 2022-11-24T07:31:05Z, a difference of 153263 milliseconds.  Allowed clock skew: 0 milliseconds.

로그인 요청을 할때, 우리는 JWT 토큰을 Header에 담아서 보내준다.
토큰을 만들때는 만료시간을 설정하는데
설정한 만료시간이 지났는데 리소스에 접근요청을 보내면 g해당 예외가 발생한다.


2). MalformedJwtException 예외

JWT가 올바르게 구성되지 않았을때 발생하는 예외이다.

io.jsonwebtoken.MalformedJwtException: Malformed JWT JSON: 

실제로 구성을 바꿨을때 예외가 발생했다.

image

위의 사진처럼 Header의 맨앞부분을 변경했더니
디코딩이 되지 않아 형식을 확인할 수 없는 문제가 생겼다.

실제로 JWT 홈페이지에서 사진처럼 디코딩을 해보면
인코딩쪽에 형식이 맞지않아 빨간색으로 표시가 되는 것을 볼 수 있다.


3). SignatureException 예외

JWT의 기존 서명을 확인하지 못했을 때 생기는 예외이다.

io.jsonwebtoken.security.SignatureException: Unsupported signature algorithm 'H{256'

실제로 서명을 확인하지 못할경우 위와 같이
Exception이 발생한다.

image

정상 JWT에서 Header 부분을 변경해 예외를 터트려보았다.
형식이 깨지지 않는 선에서 서명이 확인되지 않으면
발생하는 예외인 것 같다.

사진에서 볼 수 있는 것처럼 Header의 'alg' : "H{256"으로
형식은 깨지지 않았으나, Header에 이상한 값이 들어가
서명을 확인하지 못하는 경우가 발생한 것이다.



이렇게 오늘은 JWT를 통해 인증까지 해보는 작업을 해보았다.
사실은 내가 정리하면서 맞게 적는지 엄청 코드를 반복적으로 확인하면서
글을 써내려갔다. 조금 덜 정리된 부분이 많아서 아쉽지만
나름 내가 코드를 분석하고 정리를 해보니 어느정도 감은 잡힌 것 같다.

오늘까지해서 JWT에 대한 내용은 끝났고
실제로 내가 어드밴스한 내용을 구현하고 싶다면
인증처리 흐름만 익힐게 아니라 내부의 코드들이 어떻게 구현되있는지
완벽하게 이해하고 있어야 어드밴스한 구현이 가능할 듯 싶다.

오늘까지 공부하면서 많은 소득이 있었고
큰 그림을 배웠고, 세부적인 내용도 어느정도 뜯어보니
코드에 대한 자신감이 조금 더 생긴 주였던 것 같다.

오늘 공부는 여기서 끝!!


오늘의 커피량: ☕️ ☕️
오늘의 점심: 라면, 밥