본문 바로가기

Projects/Monkey Penthouse

Spring Security를 이용한 JWT 로그인 2 - 구현 과정

목표

  • 로그인 요청 시, 이메일과 비밀번호를 검증하여 JWT 발급
  • '/user/all/*'을 제외한 모든 요청에 대하여 JWT 인증/인가 처리

 참고: https://bcp0109.tistory.com/301

 

Spring Security 와 JWT 겉핥기

Introduction 이 글에서는 Spring Boot + JWT + Security 를 사용해서 회원가입/로그인 로직을 구현했습니다. JWT 와 Spring Security 코드는 인프런 Spring Boot JWT Tutorial (정은구) 강의를 수강하면서 만들고..

bcp0109.tistory.com

필요한 설정

  • 'spring-boot-starter-security' Dependency 추가
  • 'io.jwonwebtoken' Dependency 추가
  • JWT secret key을 Base64로 인코딩한 문자열을 env 파일에 설정 : HS512(SHA-512) 알고리즘을 사용할 것이므로 512bit(64byte) 이상의 secret key가 필요
더보기

HMAC

MAC (Message Authentication Code) 이란 메시지의 인증에 쓰이는 코드이다. 메시지의 무결성 및 신뢰성을 보장하는 용도로 사용한다고 한다.

HMAC (Hash-based Message Authentication Code) 은 인증을 위한 Secret Key와 임의의 길이를 가진 Message를 해시 함수를 사용해서 생성한다.

해시 함수로는 MD5, SHA-256과 같은 일반적인 해시 함수를 그대로 사용할 수 있으며 각 알고리즘에 따라 다른 고정길이의 MAC(Hash value)가 생성된다.

HMAC는 메시지를 암호화하지 않는다. 그 대신, 메시지의 암호화 여부에 관계 없이 메시지는 HMAC 해시와 함께 송신되어야 한다. 

SHA-2

SHA-2는 미국 국가안보국에서 설계한 암호화 해시 함수들의 집합으로 224, 256, 384, 512 비트로된 해시값이 있는 6개의 해시 함수를 구성하고 있다. 일반적으로는 SHA-256이 많이 사용된다.

Base64

인코딩(encoding)은 정보의 형태나 형식을 표준화, 보안, 처리 속도 향상, 저장 공간 절약 등을 위해서 다른 형태나 형식으로 변환하는 처리 혹은 그 처리 방식을 말한다. 

Base64란 Binary Data를 Text로 바꾸는 Encoding(binary-to-text encoding schemes)의 하나로써 Binary Data를 Character set에 영향을 받지 않는 공통 ASCII 영역의 문자로만 이루어진 문자열로 바꾸는 Encoding이다.

JWT의 헤더와 페이로드는 특정한 암호화 없이 base64를 통해 인코딩된다.

출처: https://jesstory-codinglish.tistory.com/68

토큰 발급/인증 기능 구현 - TokenProvider

설정한 문자열을 토대로 secret key 생성 - 생성자 TokenProvider()

  • io.jsonwebtoken에서 제공해주는 Decoders.BASE64를 통해 문자열을 디코딩하여 byte 배열 생성
  • 해당 byte 배열을 토대로 HMAC-SHA 알고리즘에 따른 SecretKey 인스턴스 생성하여 저장

토큰 정보 검증 - validateToken()

  • io.jsonwebtoken에서 제공해주는 JwtParser을 통해 토큰을 검증한다.
  • secret key를 통해 JwtParser를 build하고, 토큰 문자열을 파싱한다.
  • 유효성 검증 과정에서 발생할 수 있는 에러에 따라 에러 처리를 한다.
    • 잘못된 토큰 서명, 만료된 토큰 서명, 지웓되지 않는 토큰, 유효하지 않은 토큰

토큰 복호화 - parseClaims()

  • 토큰 정보를 검증할 때와 마찬가지로 JwtParser를 통해 토큰을 파싱한다.
  • parseClaimsJws().getBody() 메서드는 토큰의 페이로드에 담긴 Claim 정보를 포함하는 Claims를 리턴한다.

토큰으로부터 유저 정보 추출 - getAuthentication()

  • parseClaims()를 통해 토큰을 파싱하여 얻은 Claim을 리턴받는다.
  • Claims 객체는 Map을 extends하고 있기 때문에 claim 정보를 key-value 형태로 조회할 수가 있다
  • "," 로 열거된 Authority 정보를 Collection<GrantedAuthority> 형태로 변환한다.
  • Claim 정보를 갖고, UserDetail의 구현체인 User 객체(커스텀x)를 생성한다.
  • UserDetail과 Authority 정보를 토대로 username과 password 형태의 인증을 위한 Authentication 구현체인 UsernamePasswordAuthenticationToken 객체를 생성한다. 이 때 사용하는 생성자는 isAuthenticated()를 true로 설정한다.

유저 정보로부터 토큰을 생성 - generateTokens()

  • Authentication에서 Collection<GrantedAuthority> 형태인 Authority 정보를 가져와서 ","로 연결된 문자열 형태로 변환한다.
  • 토큰의 만료기간을 현재로부터 특정 시점으로 설정한다.
    • accessToken은 15분
    • refreshToken은 2주
  • io.jsonwebtoken에서 제공해주는 Jwts.builder()를 통해 accessToken과 refreshToken을 생성한다.
  • 생성된 두 개의 토큰을 만들어놓은 Token DAO에 담아 리턴한다.

JWT 인증을 할 Filter 정의 - JwtFilter

  • JwtFilter는 요청 하나당 꼭 한번만 거치도록 제한된 Filter 구현체, OnceperRequestFilter를 상속받아 정의한다.
  • doFilterInternal()을 오버라이딩하여 JWT 토큰의 인증 정보를 해당 쓰레드의 SecurityContext에 저장한다.
    • 요청의 header에서 accessToken을 추출한다.
    • 위에서 정의한 tokenProvider.validateToken()을 통해 유효성을 검증한다.
    • 토큰이 유효하다면 TokenProvider.getAuthentication()을 통해 authentication을 리턴 받아, SecurityContext에 authentication을 저장한다.
    • FilterChain.doFilter를 통해 다음 Filter로 요청을 넘긴다. 

JwtFilter를 기존의 FilterChain에 끼워넣기 - SecurityConfig

  • WebSecurityConfigurer 구현체인 WebSecurityConfigureAdapter를 상속 받아 간단하게 Spring Security를 설정할 수가 있다.
  • 일단 구체적인 설정을 하기 전에 BcryptPasswordEncoder를 생성하여 bean으로 등록한다.
  • 오버라이딩할 수 있는 대표적인 메서드는 다음과 같다.
    • configure(AuthenticationManagerBuilder) : AuthenticationProviders를 쉽게 추가할 수 있도록 한다.
    • configure(Httpsecurity) : 리소스 수준에서 웹 기반 보안을 설정할 수 있다. 예를 들어 'admin'으로 시작하는 URL을 ADMIN 역할을 가진 사용자로 접근을 제한할 수가 있다.
    • configure(WebSecurity)는 전역 보안에 영향을 주는 설정을 하는 데 사용된다. 예를 들어, 자원 요청을 무시하거나, 디버그 모드 설정, 사용자 지정 방화벽 정의 등의 설정이 가능하다. 
  • 우리는 여기서 configure(HttpSecurity)를 오버라이딩하여 기본적인 보안 설정을 하고, JwtFilter를 기존의 FilterChain에 끼워넣을 것이다.
    • exceptionHandling()을 통해 JWT 토큰 인증에 실패했을 경우의 exception을 처리
    • authenticationEntryPoint()을 통해 인증된 authentication이 없을 경우를 처리한다.
    • accessDeniedHandler()을 통해 authorized되지 않는 경우를 처리한다.
  • Security는 기본적으로 세션을 사용하지만 우리는 JWT를 사용하기 때문에 세션 설정을 Stateless로 설정
    • sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
  • authorizeRequest()을 통해 로그인을 포함한 JWT 인증을 필요로 하지 않는 요청을 인증 단계를 설정한다.
    • andMatchers("/user/all/*").permitAll()
    • anyRequest().authenticated()
  • addFilterBefore()을 통해 만들어놓은 JwtFilter를 UsernamePasswordAuthenticationFilter 앞에 끼워넣는다.

loadUserByUsername 오버라이딩 - UserDetailsService

전체적인 구조

  • ProviderManager는 AuthenticationProvider의 구현체인 DaoAuthenticationProvider를 주입받아서 authenticate()를 호출한다.
  • DaoAuthenticationProvider가 상속받은 추상클래스 AbstractoUserDetailsAuthenticationProvider의 authenticate()에서는 retrieveUser()와 additionalAuthenticationChecks()를 호출한다.
  • DaoAuthenticationProvider가 구현한 retrieveUser()에서는 loadUserByUsername()를 통해 DB에 있는 사용자 정보를 가져온다.
  • DaoAuthenticationProvider가 구현한 additionalAuthenticationChecks()에서는 주입받은 passwordEncoder를 통해 authentication에 저장된 비밀번호와 DB의 유저 정보에 저장된 비밀번호를 비교한다.

따라서 우리는 여기서 loadUserByUsername()을 오버라이딩하여 username(우리프로젝트에서는 email)을 갖고 DB에서 유저 정보를 조회하는 로직을 짜기만 하면 된다.

로그인 요청 처리 - UserService.login()

이메일과 비밀번호를 인증하는 과정은 Filter로 구현하지 않았다.

그렇게 하려면 login 요청에만 인증이 되도록 filter chain을 하나 더 추가해야 한다.

굳이 그렇게 할 필요는 느끼지 못해서 service 단계에서 직접 인증 처리를 했다.

  • requestBody에서 이메일과 비밀번호를 갖고 UsernamePasswordAuthenticationToken()을 만든다.
    • isAuthenticated()를 false로 설정된다.
  • AuthenticationMangerBuilder.getObject()을 통해 ProviderManager를 리턴받는다.
  • ProviderManager에 대하여 앞에서 만든 authentication을 파라미터로 넘겨 authenticate()을 호출한다.
  • 위의 구조대로 이메일과 비밀번호를 통해 인증처리를 한다.
  • 인증이 정상적으로 완료되면 refreshToken을 redis에 저장한다.
  • 유저 정보와 두 개의 토큰을 리턴한다.

refresh token으로 토큰 재발급 처리 - UserService.reissue()

accessToken이 만료되었을 경우, refreshToken을 requestBody로 받아 accessToken과 refreshToken을 재발급해줘야 한다.

  • tokenProvider.validateToken()을 통해 refreshToken을 검증한다.
  • tokenProvider.getAuthentication()을 통해 토큰을 파싱하여 유저 정보를 가져온다.
  • 유저 정보에 있는 ID값을 기반으로 redis에서 refreshToken을 조회한다.
  • refreshToken이 일치 여부를 확인한다.
  • 불일치하면 에러를 발생시키고, 일치하면 tokenProvider.generateToken()을 통해 새로운 토큰을 생성한다.
  • redis에서 새로 생성된 refreshToken으로 정보를 업데이트한다.
  • 토큰을 리턴한다.

 

 

이렇게 인증/인가를 구현하면 한 요청에서 토큰 인증이 완료되었을 때, SecurityContext에서 인증된 유저 정보를 가져와서 사용할 수가 있다.