JWT(Json Web Token)은 인증에 필요한 정보들을 암호화 시킨 Json 토큰으로 리소스에 대한 엑세스 권한을 부여하기 위한 인증 방식으로 사용된다.  Base64(이진 데이터를 문자열로 인코딩하기 위해 사용) 방식으로 인코딩을 하며 HMAC-SHA 이라는 해시 기반의 암호화 알고리즘을 사용한다.

 

HMAC은 암호화와 인증을 위한 코드를 해시 알고리즘과 secret키를 사용해서 생성하는 메시지 인증 코드(Message Authentication Code)이며 뒤에 붙은 SHA(Secret Hash Algorithm)는 대표적인 해시 알고리즘이다. 

 

해시란?

단방향성 암호화를 해싱(암호화를 시킨 값으로만 같은지 확인 가능, 원본이 변하면 해시 값도 변함)이라 하고 해시에 의해 암호화된 데이터를 다이제스트라고 하는데 SHA-256으로 암호화를 한다고 하면 같은 입력값에 대해서는 항상 똑같은 다이제스트가 나오게 된다.

 

 

JWT 토큰헤더와 페이로드, 시그니처(서명)으로 구분이 되며 헤더에는 알고리즘의 정보가 들어있고 페이로드에는 claims 데이터들이 들어간다. 일반적으로 권장되는 sub(제목), exp(만료 시간) 등이 있으며 따로 필요한 정보를 넣기도 있다. 


JWT는 기본적으로 암호화에 특화된 것이 아니라 데이터가 변하지 않았다는 무결성에 특화되어 있기 때문에 노출되면 안되는 민감한 정보를 페이로드에 넣지 않아야 한다. 

 

마지막으로 시그니처는 Base64Url로 인코딩된 헤더와 페이로드, 서버측에서 관리하는 시크릿키를 이용하여 HMAC-SHA 알고리즘으로 생성해낸다. 

 

왜 JWT를 사용하는가?

 

세션 방식은 서버 자원을 사용하기 때문에 부담이 많이 갈 수 있고 서버 장애시 사용자가 재로그인을 해야 되는 등의 문제가 생길 수 있다. 반면 JWT는 클라이언트에게 인증 토큰을 전달하면 이후 클라이언트에서 JWT 토큰을 헤더에 담아서 전달하기면 하면 되기 때문에 세션 방식의 문제점을 해결할 수 있다.

세션 방식도 메모리 DB를 이용해 세션을 관리하는 식으로 이런 문제를 해결할 수 있지만 서버 부하나 복잡성 면에서 JWT가 사용하기 편리할 것 같다. 하지만 JWT는 토큰을 탈취 당했을 때 서버 측에서 해결하기가 어려워서 클라이언트에는 만료 시간이 짧은 AccessToken을 전달하고 서버에서 비교적 만료 시간이 긴 RefreshToken을 관리하면서 AccessToken을 재발급 해주는 방식으로 관리를 해야 한다. 그래서 토큰을 새로 발급받기 위해 RefreshToken 확인을 빈번하게 하기 때문에 Redis 같은 메모리 DB에 관리를 하는 경우도 있다고 하는데 아직은 JWT를 처음 구현해보기도 하고 트래픽이 많지 않기 때문에 RDB에서 관리를 하는 방식으로 하였다.

 

그리고 JWT를 사용하면 서버를 무상태성(Stateless)으로 관리할 수 있는 것이 세션 방식과의 큰 차이점이다. ㅇJWT 토큰도 많은 데이터를 담으면 부하가 심해지기 때문에 되도록 인증을 위한 데이터만 담아서 전달해야 한다. (애초에 중요한 데이터는 노출하면 안 된다.)

 

이전 글에서 login 메서드를 보면 스프링 시큐리티로 인증 과정을 거치고 인증이 완료된 Authentication 인스턴스를 생성하는 것까지 완료했다. 다음에는 JwtAuthenticationProvider를 통해 JWT 토큰을 생성한다.

 

 

위에서 보았듯이 JWT 토큰은 헤더 + 페이로드 + 서명으로 구성이 되는데 기본 헤더는 알아서 생성이 되고 페이로드에는 사용자의 이름, 권한, 만료시간을 담는다. 마지막으로 서명은 헤더와 페이로드, 시크릿키를 사용해서 HS512(HMAC-SHA512)로 암호화를 하는데 시크릿키는 사용하는 SHA에 따라 최소 길이를 충족시키도록 Base64로 인코딩해서 생성한다. (예시는 짧게 입력한 것)

 

 

RefreshToken은 만료시간만 들어가며 DB에서 따로 관리하기 위해 아이디와 토큰, 만료시간을 담은 DTO를 추가로 생성해서 반환한다.

 

 

JWT 토큰이 생성이 되었으면 응답 헤더에 AccessToken을 담아 클라이언트에 반환을 해준다.

 

이전 글을 보면 SecurityConfig 설정에서 아래와 같이 Jwt 토큰 확인을 위한 커스텀 필터를 추가해줬었다.

JwtFilter 필터는 OncePerRequestFilter를 상속 받아서 구현하였는데 이는 Request에 대해 딱 한번만 적용되는 필터로 JwtFilter는 처음 요청에 대한 인증을 하기 위함이므로 OncePerRequestFilter를 상속 받았다.

 

 

먼저 토큰 재발급(reissue)의 경우에는 인증을 스킵하고 그 외에는 accessToken의 존재 여부와 JwtAuthenticationProvider의 토큰 검증 메서드를 통해 유효성을 확인한다.

 

유효성 검증이 되었으면 해당 토큰으로 Authentication 인스턴스를 생성해서 SecurityContext에 저장하고 SecurityContextHoder에 담는다.

 

 

 

이전에는 스프링 시큐리티 아키텍쳐에 따라 어떤식으로 동작이 되는지 보았다.

 

 

[Spring] 스프링 시큐리티(Spring Security) + JWT 토큰 구현(1)

스프링 시큐리티는 스프링 기반 애플리케이션의 인증, 보안 등을 처리하기 사용되는 스프링 하위 프레임워크로  권한을 부여하여 접근을 제한하고 로그인 유지 등의 용도로 사용이 된다. 스프

treecode.tistory.com

 

스프링 시큐리티를 사용하기 위해서는 Security Configuration 설정 클래스를 만들어야 되는데 이전에는 WebSecurityConfigureAdapter를 extends해서 configure 메소드를 오버라이드 했지만 최근에 스프링 버전이 바뀌면서 WebSecurityConfigureAdapter가 Deprecated 되었다. 그래서 SecurityFilterChain을 Bean으로 등록해서 설정해야 한다.

 

1번을 보면  @EnableWebSecurity 애노테이션은 스프링 시큐리티를 사용하기 위해 필수로 적용하는 애노테이션이다.

filterChain 파라미터를 보면 HttpSecurity를 받을 수 있는 것도 @EnableWebSecurity에 HttpSecurityConfiguration이 Import되어 있기 때문에 가능하다.  

@EnableWebSecurity에 Import 되어 있는 WebSecurityConfiguration을 보면 springSecurityFilterChain() 메소드를 실행해서 Beand으로 등록한 WebSecurityCustomizer, SecurityFilterChain 등으로 WebSecurity를 생성한다.

 

WebSecurity는 performBuild() 메소드를 통해 FilterChainProxy를 만드는데 이 FilterChainProxy는 DelegatiingFilterProxy로부터 위임을 받아 실제 처리를 담당하는 스프링 시큐리티의 필터의 핵심 클래스이다.

 

DelegatingFilterProxy서블릿 컨테이너와 스프링 컨테이너 사이를 연결하는 서블릿 필터로  Http Request 요청이 오면 FilterChainProxy로 위임(Delegate)을 해서 스프링 시큐리티의 기본 필터와 커스텀 필터를 거치게 된다.

 

아래 그림을 보면 Request가 서블릿에 도달하기 전 SecurityFilterChain이 적용되는 것을 볼 수 있는데 서블릿 필터에서 스프링 빈 필터를 주입 받아 사용할 수 없기 때문에 중간 다리 역할을 해주는 스프링 시큐리티의 특수한 필터인 FilterChainProxy에게 위임을 하는 것이다.

 

 

 

 

 

 

@EnableGlobalMethodSecurity는 컨트롤러에 @PreAuthorize 애노테이션을 사용하여 권한에 따라 접근할 수 있도록 하기 위해 선언한다.

 

 

2번에서는 예외를 핸들링하기 위해  AccessDeniedHandler, AuthenticationEntryPoint를 추가하였고 커스텀 AuthenticationProvider를 주입하였다. 커스텀 AuthenticationProvider는 따로 코드로 자세히 보겠지만 Jwt 토큰을 인증하고 발급하는 등의 처리를 담당한다. 4번, 7번에서 각각 적용하는 것을 볼 수 있다.

 

 마지막으로 3번, 6번을 보면 특정 리소스를 무시하고 어떠한 요청에 인가(authorization)을 해주는지에 대한 설정을 하는 것이고 8번의 PasswordEncoder는 비밀번호를 암호화하기 위해 BcryptPasswordEncoder를 빈으로 등록하였다.

 

다음에는 JWT 토큰에 대해 자제시 알아보고 7번에서 JwtFilter에  jwtAuthenticationProvider를 주입해서 필터로 등록하는 것에 대해 공부한다. 

 

 

[참고]

https://oh-sh-2134.tistory.com/114

https://uchupura.tistory.com/24

https://velog.io/@jeongyunsung/스프링부트-해부학-Security1-

스프링 시큐리티스프링 기반 애플리케이션의 인증, 보안 등을 처리하기 사용되는 스프링 하위 프레임워크로  권한을 부여하여 접근을 제한하고 로그인 유지 등의 용도로 사용이 된다.

 

 

스프링 시큐리티의 기본 동작은 컨트롤러를 통해 유저 정보를 담은 Http Request 요청이 오면 AuthenticationFilter에서 인증 전 상태의 UsernamePasswordAutienticationToken을 생성한다.

UsernamePasswordAutienticationToken은 Authentication 인터페이스를 implements한 AbstractAuthenticationToken을 상속 받은 클래스로 두개의 생성자를 보면 인증 전, 인증 후 토큰을 생성하는 것을 알 수 있다. (principal : 아이디, credentials : 비밀번호)

 

 

해당 토큰으로 AuthenticationManagerBuilder를 통해 AuthenticationManager에서 authenticate() 메서드를 실행하면 내부적으로 AuthenticationManager의 구현체인 ProviderManager가 동작한다.

 

ProviderManager는 DaoAuthenticationProvider를 호출한다. (DaoAuthenticationProvider는 AuthenticationProvider의 추상 클래스인 AbstractUserDetailsAuthenticationProvider를 상속 받음)

DaoAuthenticationProvider의 retrieveUser() 메소드를 보면  UserDetailsService의 loadByUsername() 메서드를 실행하는 것을 볼 수 있다.

 

 

UserDetailsService는 커스텀으로 구현하는데 loadUserByUsername을 오버라이드해서 파라미터로 넘어온 Username으로 조회를 하고 존재할 경우 UserDetails 객체의 인스턴스를 생성해서 반환한다.

유저 정보가 인증이 되었으면 UserDetails 인스턴스가 반환이 되고 다시 DaoAuthenticationProvider로 돌아와서 비밀번호(Credential)가 일치하는지 확인을 한다. 인증 전 로그인 정보의 비밀번호는 암호화가 되어있지 않지만 내부적으로 passwordEncoder가 작동한다.

 

정리를 하자면 ProviderManager에서 authenticate()가 실행이 되면 DaoAuthenticationProvider에서 실질적인 인증 처리를 한다.

 

1) 유저 정보를 확인하기 위해 retrieveUser()가 실행, UserDetailsService의 loadUserByUsername()를 통해 UserDetails 반환

2) 비밀번호를 확인하기 위해 additionalAuthenticationChecks()가 실행, 반환 받은 UserDetails 정보로 비밀번호 검증

 

이렇게 인증이 완료된 Authentication 인스턴스를 SecurityContext에 저장을 하고 SecurityContextHolder에 넣는다.

스프링 시큐리티를 세션 방식으로 구현할 때 SecurityContext에 인증이 완료된 Authentication 인스턴스를 저장하고 이를SecurityContextHolder에 담아 보관을 하는데 JWT 토큰으로 구현을 하더라도 똑같이 저장을 해야 한다.

SecurityContext에 Authentication 객체를 저장하는 이유는 세션과 상관 없이 ThreadLocal 때문이다. SecurityContextHolder는 ThreadLocal을 가지고 있는데 ThreadLocal은 세션의 범위가 아닌 Request 범위에 속한다. SecurityContext에 Authentication 객체가 저장되어 있지 않으면 FilterSecurityIntercepter에서 필터에 걸리게 된다.

(SecurityContextHolder와 ThreadLocal에 대한 부분은 좀 더 공부를 해봐야 알 것 같다.)

https://www.inflearn.com/questions/501092

https://www.inflearn.com/questions/558844

 

 

[참고]

 

https://bcp0109.tistory.com/301

https://mangkyu.tistory.com/76

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-jwt

 

+ Recent posts