728x90
반응형

JWT(Json Web Token)

JWT란 Json 객체로 안전하게 전송하기 위한 방식이다. 이 정보는 디지털 서명이 되어 있으므로 신뢰할 수 있다.

보통 HMAC 알고리즘이나 RSA 알고리즘을 사용하여 암호화를 한다.

핵심은 서명된 토큰이라는 점이고, 내가 쓴게 맞다! 라는 의미로 사용한다.

서명된 토큰은 그 안에 포함된 클레임(정보)의 무결성을 확인할 수 있게 해준다.

 

JSON은 Base64URL로 인코딩되어 있다. Base64URL은 암호화를 하고, 복호화를 할 수 있게 해주는 방법이다. (해시랑 다른 것이다. 예를 들어 비밀번호를 데이터베이스에 저장할 때는 해시 함수를 이용하여 암호화를 해주기 때문에 암호화된 데이터로는 원래의 비밀번호가 어떤건지 알 수 없다. -> 그래서 비밀번호 찾기에서 비밀번호를 알 수는 없는 것이다. )

그러니까 사실 이상하게 적혀있긴 하지만 Header와 payload에 대한 정보는 사실 그냥 디코딩만 하면 알 수 있다. (Base64 URL을 통해 디코딩)

JWT의 핵심은 signature이다. 

 

JWT의 구조는 xxxx.yyyy.zzzz -> 헤더, payload, signature로 구성되어 있다.

signature은 헤더 + payload + 개인키 를 HMAC(HS256)으로 암호화한 것이다. 

 

헤더에 들어가는 정보

  • 사용중인 알고리즘
  • 토큰 유형(JWT)

payload에 들어가는 정보

  • 클래임
    • 등록된 클레임 : 필수 조건은 아니지만 권장되는 미리 정의된 클레임(발행자, 주체, 청중..)
    • 개인 클레임 : 유저아이디 같은 정보를 넣을 수 있다. 유저를 특정할 수 있는 것을 개인 클레임에 보통 넣는다.

secret키는 서버만 알고 있는 키이다. 클라이언트가 username으로 tae77777을 보내고 비밀번호로 1234를 보낸다고 생각해보자. 그리고 secret키가 'secret'이라고 했을 시 아래와 같다. 여기서 signature만 HS256으로 암호화를 한 뒤에 Base64로 인코딩을 하게 되는 것이다. HS256 방식은 HMAC 방식과 SHA256방식을 합해서 말한 것으로 시크릿 키를 포함한 암호화 방식이다. 

중요한 점은 복호화 할 수 없다 는 점이다. 

 

JSON web token을 client는 보통 local Storage에 넣어둔다. 이후에 클라이언트가 서버에 요청할 때 JWT를 실어서 요청하면 서버가 JWT가 신뢰할 수 있는 token인지 확인한다.

그럼 서버에서는 JWT 토큰에 대해서 어떻게 검증할까? -> Signature에 HS256으로 암호화된 정보가 유효한지 알아야 한다.

일단, JWT를 받으면 Header와 Payload를 알 수 있다. 그럼 Base64URL로 인코딩된 header와 Base64URL로 인코딩된 payload와 secret key를 한번에 HS256으로 암호화를 한다. 그럼 클라이언트에게서 받은 JWT의 signature부분을 Base64URL로 복호화해서 같은지 비교해서 인증한다.
-> 인증이 되면 payload에 있는 user 정보를 통해서 DB에서 select해서 돌려주면 된다.

이 방법은 비밀번호를 저장할 때도 같은 방식이다!


세션과 JWT 토큰

Http는 stateless인데 stateful처럼 쓰기 위해서 세션을 만들고 쿠키를 만든다. 쿠키는 동일 도메인에서만 요청이 올 때 발동한다. (CSRF)

 

쿠키와 세션은 http의 장점인 stateless를 벗어나는 stateful하게 되는 것이다. 즉 서버에 상태를 저장한다. 서버가 한 대일때는 세션과 쿠키에 별다른 단점이 없다. 세션의 문제는 서버가 여러 개가 생길 때 문제가 생긴다. 

 

만약 서버를 scale out하여 개수를 늘린다면 서버마다 세션에 대한 정보를 공유하여야 사용자를 확인할 수 있다. 그럼 결국 중앙 세션 저장소를 반드시 만들어야 한다. 

 

Security Config 작성

Security Filter Chain은 다른 Filter보다 무조건 먼저 작동한다. 그리고 Jwtfilter는 Security 동작전에 실행되어야 한다.

 

http Basic 방식 : 매번 요청할 때마다 ID와 Password를 다 알고 요청하게 된다. -> 쿠키, 세션을 만들 필요가 없다.
-> ID, pw가 중간에 노출될 수 있기 때문에 https서버를 사용해야 한다.

 

우리가 쓰려는 방식은 Bearer 방식이다.
: Authorization에 token을 넣는 방식. -> 노출되어도 얘 자체가 ID, PW가 아니기 때문에 위험부담이 적다.
이런 방식이 http Bearer방식이다.

728x90
반응형
728x90
반응형

세션

사용자가 웹 브라우저를 킨다고 가정해보자. 예를 들어서 http://www.naver.com을 웹 브라우저에 입력하였다. 최초로 웹브라우저에 들어가면 일단 서버는 해당 주소에 맞는 controller 메서드를 찾아서 html을 사용자에게 리턴해줄 것이다.
그런데 다음에 접속할 때는 상황이 다르다. 서버는 이제 사용자가 누구인지 알아서 거기에 따른 맞춤 광고나 정보들을 제공해준다. 다음에 요청할 때는 세션 ID라는 것이 같이 전송되기 때문이다. 여기서 세션 이라는 것을 처음 접할 수 있다.

웹 브라우저는 세션 ID를 받아서 특정한 설정을 안해주는데도, 자동으로 웹 브라우저에 쿠키라는 저장 영역에 세션ID가 담기게 된다. 이 쿠키라는 영역에 담기는 세션 ID는 언제 생기냐 하면, 최초로 사용자가 서버에 요청했을 때 생기게 된다.

만약에 최초 요청이 아니라, 두번째, 세번째 요청일 시 세션 ID를 header 영역에 달고 요청하게 되는 것이다. (header안에 쿠키라는 영역이 있다)

세션ID는 그럼 뭘 확인해주는가? 세션 ID는 처음 접속했는지, 아니면 2번째, 3번째 접속했는지를 알 수 있다.

여기서 그럼 이런 생각을 할 수도 있다. 세션 ID를 위조해서 header의 쿠키에 넣어서 전달하면, 내가 아닌 다른 사람으로 서버가 인식한다는 것이니까, 위조를 할 수 있는 것 아닐까?

그럼 세션 ID를 위조할 수는 없을까?

이를 방지하기 위해서 세션 ID를 만들어줄때마다 서버는 세션ID의 목록을 가지고 있어야 된다.

즉, 세션ID는 최초 요청 시에 만들어지고, 웹 브라우저는 계속 요청할때마다 세션ID를 들고간다.

그럼 세션ID는 언제 사라지게 되는 것일까? 3가지 경우가 있다.

  1. 서버 쪽에서 session 목록을 날려버릴 때
  2. 사용자가 브라우저를 닫을 때 (종료할 때) -> 서버에 값이 살아있지만 특정 시간(보통 30분)이 지나면 이것도 사라짐
  3. 특정 시간이 지났을 시 -> "우리가 세션이 종료되었습니다. 다시 로그인해주세요" 이런 메시지가 오는 경우 생각

세션을 사용한 로그인

세션을 로그인할 때 사용한다면 로직은 다음과 같다.

  1. 유저가 처음에 서버에 요청을 한다.
  2. 서버는 세션에 세션ID를 하나 만들어주고(ex> 1234) 서버 세션공간에 기록해둔다.
  3. 서버는 유저에게 응답(response)를 돌려준다.
  4. 유저가 다음 요청을 하게 될 때는 세션 1234를 쿠키에 넣어서 이번엔 로그인을 요청한다.
  5. 서버는 DB에 로그인 정보가 있는지 확인한다.
  6. 해당하는 user가 있다면, 서버의 세션에서 세션ID가 1234인 곳에 user 정보를 저장하게 된다.
  7. 그럼 이제 다음번에 사용자가 예를 들어 사용자 프로필 화면에 접속했다고 하면, 세션 있는지 먼저 확인한다
  8. 세션이 있다면 DB에서 사용자 정보를 찾아서 서버에 응답해준다.
  9. 해당 정보를 돌려준다!

즉, 세션을 통해서 그 사용자 인증도 할 수 있고, 민감한 정보를 요청할 때 세션을 통해서 세션값이 있는지 확인하고 정보가 존재하는지 확인한다.

세션의 단점

세션의 단점에 대해서 생각하려면 사용자가 많을 때를 가정해봐야 한다.
만약 서버가 100명을 처리할 수 있는 서버인데, 1000명이 동시에 접속했다고 가정해보자. 그럼 900명은 대기해야한다. 그래서 회사는 서버를 늘리고 싶어한다. 늘리면 당연히 1000명을 받을 수 있을텐데 문제는 세션이다.

세션은 서버에 같이 존재하는데, 만약 이전 서버에 로그인해서 세션에 기록되었다면 새로 늘린 서버에 접속하면 해당 서버의 세션에는 로그인 정보가 없기 때문에 로그인이 잘 되었는지 이용자에게 전달해줄 수 없는 문제가 생긴다.

이걸 해결하려면 몇 가지를 생각해볼 수 있다.

  1. 먼저 Sticky Server라는 것을 만든다. 이 서버는 이제 최초에 들어온 사람은 로드 밸런싱 이런거 없이 무조건 처음 접속한 서버로만 들어가게 강제하는 것이다.
  2. 아니면 세션 전체를 복제시켜서 서버마다 세션을 저장해줄 수 있다.
  3. 또, 세션에 저장하는 게 아니라 다른 한 군데 데이터베이스에 값을 넣고 이걸 세션이라고 생각해서 공유해서 사용하는 것이다.

문제는 해결책들이 기존의 세션의 장점을 사라지게 한다.

세션은 일단 서버에 인메모리 즉, 속도가 빠르다는 장점이 있는데, 3번째 방법은 데이터베이스를 따로 둔다는 것은 하드디스크에서 읽어와야 하기 때문에 속도가 느려진다. (하드디스크에 접속한다는 것은 I/O가 일어난다는 것이기 때문에..)

그래서 현재 세션을 사용하는 서버들은 DB가 아니라 메모리 공유 서버를 사용한다. 그래서 모든 서버들이 메모리 서버에 접근하는데 여기서 나오는 대표적인 인메모리 데이터베이스가 Redis이다.

세션을 사용하지 않고, JWT 토큰을 사용하는 방법이 더 많이 사용된다. JWT 토큰에 대해서도 정리해보겠다.

728x90
반응형
728x90
반응형

이전에 웹 브라우저의 세션을 이용해서 로그인과 회원가입을 구현하고 쿠키에 저장하는 방법을 통하여 로그인 회원가입을 처리해본 경험이 있다.
하지만 세션과 쿠키를 사용하는 것에는 단점들이 있다. 먼저 jwt는 STATELESS하므로 상태를 저장하지 않는 RESTful API에 적합하였고, 모바일 환경에서도 쉽게 사용할 수 있다는 장점이 있었다.

해당 내용을 바탕으로 JWT와 스프링 시큐리티를 사용하여 로그인, 회원가입, JWT 토큰을 이용한 나의 정보 확인 이렇게 3가지 기능을 동작하도록 만들었다.

먼저 3가지 기능에 해당하는 내가 만든 API endpoint는 다음과 같다.

  1. /members/login
  2. /members/signup
  3. /members/check

이제 스프링 시큐리티와 JWT를 알아보면서 해당 기능들을 구현해보겠다.

스프링 시큐리티, 사용이유

원리 : 서블릿 필터를 이용한다.

Spring의 내부 원리는 사용자(client)가 api를 호출하면 servlet container(tomcat)가 받고, request 받은 것을 spring의 dispatcherservlet에게 요청하게 된다. 요청에 따라서 controller에게 전달하고 서블릿 컨테이너가 응답을 받아서 사용자에게 전달한다.

servlet container에서 spring dispatcher servlet까지 전달될때까지 개입을 할 수 있는데, 개입을 필터를 적용해서 할 수 있다. 즉, servlet 객체들이 dispatcher servlet에 도착하기 전에 필터체인을 통과한 뒤에 dispatcherservlet에 도착하게 되는 것이다.

따라서 필터에서는 DispatcherServlet의 빈에 직접적으로 접근할 수 없다. 그러나 Spring Security의 SecurityFilterChain을 사용하는 경우에는 DispatcherServlet의 빈에 접근할 수 있다.

Spring Security는 내부적으로 DispatcherServlet과 연동하여 보안 관련 작업을 처리하기 때문에, 필터 체인을 통과한 후에 DispatcherServlet의 빈에 접근할 수 있다. 스프링 시큐리티와 DispatcherServlet 간에는 위임이 필요하며, SecurityFilterChain을 통해 이러한 위임이 이루어진다.

(chain이라는 말은 여러 개의 연결고리라는 뜻인데, 필터를 여러 가지 등록하는 것을 chain을 따라 인증을 진행한다고 생각하면 된다.)

개발자가 spring security를 해서 구현하는 방법이 여러 가지이다. filter 자체를 이해해서 filter를 구현하는 방법이 있고, 필터에서 돌아가는 여러 가지 함수 메소드들을 오버라이딩해서 일부를 커스터마이징할 수도 있다. (나는 일부 흐름만 커스터마이징하였다.)

JWT

JWT는 JSON Web Token의 약자이다. 정보를 JSON 형태로 전송하는 토큰이라는 의미이다. JWT는 URL로 이용할 수 있는 문자열로만 구성되어 있고 서버와의 통신에서 사용된다.

JWT의 구조

  • 헤더(Header) : 검증과 관련된 내용, 해싱 알고리즘과 토큰 타입 지정
  • 내용(Payload) : 토큰에 담는 정보. 여기에 포함된 속성들은 클레임이라고 한다.
  • 서명(Signature) : 인코딩된 헤더, 인코딩된 내용, 비밀키, 헤더의 알고리즘으로 서명이 생성됨.

https://jwt.io/#debugger-io
여기에 들어가면 JWT를 생성할 수 있고 디코딩된 정보도 볼 수 있다.

스프링 시큐리티와 JWT 적용하기

UsernamePasswordAuthenticationFilter를 통한 인증 과정

  1. 클라이언트가 사용자의 아이디와 비밀번호를 포함한 인증 요청을 서버로 전송한다.
  2. 요청을 받게 되면 서블릿 필터에서 SecurityFilterChain으로 작업이 위임된다. (DispatcherServlet의 bean에 접근하기 위해)
  3. SecurityFilterChain 중에서 AuthenticationFilter에서 인증을 처리한다. 아무 설정하지 않았으면 AuthenticationFilter 중에서 UsernamePasswordAuthenticationFilter가 인증을 처리한다.
  4. AuthenticationFilter는 요청받은 username이랑 password를 가지고 UsernamePasswordAuthenticationToken에서 토큰을 생성한다.
  5. 그러면 만든 토큰을 AuthenticationManager에게 전달한다. AuthenticationManager는 인터페이스이고 이를 구현한 것은 ProvideManager이다.
  6. ProvideManager는 인증을 해주어야 하기 때문에 AuthenticationProvider로 토큰을 전달한다. 해당 Provider에서 인증을 수행하고 성공하게 되면 ProvideManager로 권한을 담은 토큰을 전달한다.
  7. AuthenticationProvider는 UserDetailsService에 토큰의 정보를 전달한다.
  8. UserDetailsService는 전달받은 정보를 통해 데이터베이스에서 일치하는 사용자를 찾아서 UserDetails 객체를 생성한다
  9. 생성된 UserDetails객체는 다시 AuthenticationProvider로 전달되고, Provider에서 인증이 성공하면 ProviderManager로 권한을 담은 토큰을 전달하게 된다.
  10. 그럼 ProviderManager는 다시 검증된 토큰을 AuthenticationFilter로 전달한다.
  11. AuthenticationFilter는 토큰을 SecurityContextHolder에 있는 SecurityContext에 저장한다.

UsernamePasswordAuthenticationFilter는 접근 권한을 확인하고 인증이 실패할 경우 화면을 보내는 역할을 한다.

이전에 SpringSecurity와의 변경점(feat. WebSecurityConfigureAdapter 상속받는 방법 없어짐)

스프링 공식문서에 나와있는 JWT 적용방식을 응용하여서 구현해보겠다. JWT 샘플코드는 아래의 것을 사용하였다.
https://github.com/spring-projects/spring-security-samples/tree/main/servlet/spring-boot/java/jwt/login

SecurityConfiguration 구현

일단 책이랑 정보를 찾아보았을 때 WebSecurityConfigureAdapter를 상속받아서 스프링 시큐리티를 설정한다고 하였으나 실제로 IntelliJ에서 해당 클래스가 deprecated되어서 현재는 위의 document를 응용해서 구현하였다. (Security 5.7부터 바뀌었다고 한다)
Spring Blog에도 해당 내용이 나와있다.
https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter
(기존에는 WebSecurityConfigurerAdapter를 상속받아 설정을 오버라이딩 하는 방식이었는데 바뀐 방식에서는 상속받아 오버라이딩하지 않고 모두 Bean으로 등록을 한다.)

document에서는 authorize.anyRequest().authentication()으로 되어있다. 그렇게 되면 로그인 하지 않으면, 권한이 없으므로 login 페이지로 계속 이동하기 때문에 일단 permitAll()을 해놓고 login 페이지가 잘 보이는 것을 확인하고 구현을 시작하였다. 공식문서의 SecurityConfig 페이지는 아래와 같고, 여기서 rule을 추가시키겠다.

@Configuration  
public class SecurityConfig {  
    @Bean  
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {  
    http  
        .authorizeHttpRequests((authorize) -> authorize  
        .anyRequest().permitAll()  
    )  
        .httpBasic(withDefaults())  
        .formLogin(withDefaults());  
    return http.build();  
    }  
}

Rule 추가하기

위의 코드에서는 anyRequest()가 되어 있어서 어떤 url이든 인증이 필요하다. anyRequest()를 mvcMatchers()(아니면 antMatchers사용)를 사용하고 안에 parameter로 url을 넣을수도 있고, 아니면 HTTPMethod중 GET이나 POST 같은 것들로 지정해줄 수도 있다. 로그인과 회원가입, swagger 문서들은 jwt 토큰이 있을 수가 없다. 그래서 해당 url은 인증의 범위에서 제외시켜주었다.

// SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/members/login").permitAll()
                .antMatchers("/members/signup").permitAll()
                .antMatchers("/members/check").permitAll()
                .antMatchers("/swagger-ui/index.html").permitAll()
                .anyRequest().authenticated()
                .and()
                    .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}
  • httpBasic().disable() : Spring Securtiy를 처음 의존성을 추가하면 BASE64를 이용하여 비밀번호가 나온다(https://jobdong7757.tistory.com/205 참고) 그걸 disable하는 내용이다. (사용안하고 jwt 사용할 거니까)
  • csrf().disalbe() : 쿠키 기반이 아니라 jwt 기반이므로 사용하지 않는다
  • `sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) : Spring Security 세션 정책 : 세션을 생성 및 사용하지 않는다.
  • antMatchers({허용할 endpoint}) : 여기에는 인증이 필요없는 endpoint를 작성해서 permitAll() 해주면 된다.
  • addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class) : JWT 인증 필터UsernamePasswordAuthenticationFilter.class 앞에 적용하겠다는 의미로 jwtTokenProvider를 이후에 작성할 예정이다.

PasswordEncoder

PasswordEncoder는 스프링 시큐리티에서 비밀번호를 안전하게 저장하고 검증하기 위해 사용되는 인터페이스이다. PasswordEncoder를 구현한 구체적인 클래스는 비밀번호를 해싱하고, 솔트(salt)를 적용한다.

PasswordEncoderFactories.createDelegatingPasswordEncoder()는 스프링 시큐리티에서 제공하는 PasswordEncoder의 구현체를 생성하는 메서드이다. 이 메서드는 현재 스프링 시큐리티의 기본 비밀번호 인코더를 생성하고 반환한다.

주로 사용자의 비밀번호를 저장하기 전에 해당 비밀번호를 해싱하여 안전하게 저장하고, 로그인 시 비밀번호를 검증하는 등의 작업에 PasswordEncoder를 사용할 수 있다.

jwt 토큰과 jwt 인증 필터를 어떻게 사용하면 될까

jwt 토큰을 어떻게 사용할지 정리해보겠다. (이메일을 아이디처럼 사용할 것이다. 회원의 데이터베이스에 id로 저장되는 것과 다른 것이다.)
먼저, 회원가입을 할 때는 이메일과 비밀번호만 받으면 된다. 해당 정보를 입력하였을 때, 나는 회원가입이 성공하면 id, 이메일, 생성시간 3가지를 전달받는다.

그리고 로그인을 할 때는 이메일, 비밀번호를 입력하면 된다. 그럼 나는 반환 값으로 이메일, grantType(여기서는 Bearer), accessToken, refreshToken같은 인증 토큰을 전달받으면 된다.

그럼 이후부터 api 요청을 할 때, accessToken을 header에 넣어서 보내야 api가 요청이 되게 한다. 그 의미는 인증된 사용자라는 뜻이기 때문이다.

accessToken과 refreshToken을 따로 두는 것은, accessToken이 탈취될 위험이 있기 때문에, accessToken의 기한을 짧게 두고 해당 기한이 만료되면 새로 발급받기 위해 기한이 긴 refresToken을 이용해서 새로 발급받으면 되기 때문이다.

그럼 하나하나 구현을 해보겠다.

Member Controller 구성

먼저 로그인이랑 회원가입을 할 수 있도록 controller를 구성한 다음, service를 구현할 생각을 하였다. (맨 처음에 적용할 endpoint 적용)

@RestController
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {
    private final MemberService memberService;

    @PostMapping("/login")
    @ResponseStatus(HttpStatus.ACCEPTED)
    public MemberLoginResponseDto login(@RequestBody MemberLoginRequestDto memberLoginRequestDto) {
        String email = memberLoginRequestDto.getEmail();
        String password = memberLoginRequestDto.getPassword();
        return memberService.login(email, password);
    }

    @PostMapping("/signup")
    @ResponseStatus(HttpStatus.CREATED)
    public MemberSignUpResponseDto signup(@Valid @RequestBody MemberSignUpRequestDto memberSignUpRequestDto) {
        String email = memberSignUpRequestDto.getEmail();
        String password = memberSignUpRequestDto.getPassword();
        return memberService.signup(email, password);
    }

    @GetMapping("/check")
    @ResponseStatus(HttpStatus.OK)
    public MemberSignUpResponseDto checkMember(HttpServletRequest request) {
        return memberService.checkMember(request);
    }

}

회원가입

회원가입을 먼저 구현해 보겠다. 위의 controller에서 memberService의 signup함수를 구현하면 된다.
일단 중복 이메일을 체크해야 하므로, 중복된 이메일을 처리해주었다. 그리고 아직은 role에 대한 것을 해야할 요구조건이 없으므로 Role은 USER로 저장해주었다. (그럼 모든 권한이 가능하게 아래에서 처리하게 하였다.)

// MemberService의 signup 함수
    @Transactional
    public MemberSignUpResponseDto signup(String email, String password) {
        // 중복된 이메일 체크
        if (memberRepository.findByEmail(email).isPresent()) {
            throw new RuntimeException("중복된 이메일이 존재합니다");
        }
        Member member = Member.builder()
                .email(email)
                .password(password)
                .createdAt(LocalDateTime.now())
                .updatedAt(LocalDateTime.now())
                .build();
        Member savedMember = memberRepository.save(member);
        MemberSignUpResponseDto responseDto = new MemberSignUpResponseDto(savedMember.getId(), savedMember.getEmail(), savedMember.getCreatedAt());
        return responseDto;
    }

먼저 중복된 이메일을 체크하고, Member 도메인의 builder 패턴으로 입력받은 이메일과 비밀번호로 만들어준다.

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class Member extends BaseEntity {
    @Id @Column(updatable = false, unique = true, nullable = false, length = 250)
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String email;

    @Column(nullable = false, length = 250)
    private String password;

    @CreatedDate
    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
    LocalDateTime createdAt;

    @LastModifiedDate
    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
    LocalDateTime updatedAt;

    @Builder
    public Member(String email, String password, LocalDateTime createdAt, LocalDateTime updatedAt) {
        this.email = email;
        this.password = password;
        this.createdAt = createdAt;
        this.updatedAt = updatedAt;
    }
}

UserDetails
UserDetails 인터페이스는 스프링 시큐리티에서 사용자의 정보와 권한을 제공하기 위한 인터페이스이다. 사용자의 인증과 권한 부여를 처리하는 과정에서 스프링 시큐리티가 UserDetails 객체를 통해 사용자 정보를 관리하고 제공한다. (하지만, 현재로서는 권한을 부여할 필요는 없어서 해당 내용은 제외하였다.)

UserDetails 인터페이스는 다음과 같은 주요 메서드를 정의하고 있다.

  1. getUsername(): 사용자의 고유한 식별자인 사용자 이름을 반환
  2. getPassword(): 사용자의 비밀번호를 반환
  3. getAuthorities(): 사용자의 권한 목록을 반환. 각 권한은 GrantedAuthority 인터페이스를 구현한 객체로 표현된다.
  4. isAccountNonExpired(): 사용자 계정의 유효 기간이 만료되었는지 여부를 반환
  5. isAccountNonLocked(): 사용자 계정이 잠겨있는지 여부를 반환
  6. isCredentialsNonExpired(): 사용자의 인증 정보(비밀번호)의 유효 기간이 만료되었는지 여부를 반환
  7. isEnabled(): 사용자 계정이 활성화되었는지 여부를 반환

UserDetails 인터페이스를 통해 애플리케이션에서 보안 관련 작업을 보다 쉽게 구현할 수 있다.

로그인 구현하기

로그인은 이메일과 비밀번호를 받으면 인증을 처리하고 jwt token을 만들어주어야 하기 때문에 처음 배우는 부분이 많았다. 하나하나 차례대로 보겠다.

MemberService에 있는 login함수

@Transactional  
public MemberLoginResponseDto login(String email, String password) {  
    // 1. Login ID/PW 를 기반으로 Authentication 객체 생성  
    // 이때 authentication 는 인증 여부를 확인하는 authenticated 값이 false
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(email, password);  

    // 2. 실제 검증 (사용자 비밀번호 체크)이 이루어지는 부분  
    // authenticate 매서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행  
    Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);  

    // 3. 인증 정보를 기반으로 JWT 토큰 생성  
    TokenInfo tokenInfo = jwtTokenProvider.generateToken(authentication);  

    return new MemberLoginResponseDto(email, tokenInfo.getGrantType(), tokenInfo.getAccessToken(), tokenInfo.getRefreshToken());  
}
  • UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(email, password) : UsernamePasswordAuthenticationToken 객체는 사용자의 인증 정보를 담고 있으며, 인증 매니저에게 전달하여 실제로 사용자를 인증하는 과정에서 사용된다.
  • Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); :
    AuthenticationFilter는 생성한 authenticationToken AuthenticationManager에게 전달한다. AuthenticationManager은 실제로 인증을 처리할 여러개의 AuthenticationProvider를 가지고 있다. 그럼 UserDetailService에서 해당 내용을 기반으로 조회를 한다.

UsernamePasswordAuthenticationToken 클래스
UsernamePasswordAuthenticationToken은 스프링 시큐리티에서 사용자의 인증을 처리할 때 사용되는 객체이다. 주로 사용자가 제출한 아이디(여기서는 email)와 비밀번호(password) 정보를 포함하여 인증 작업에 필요한 정보를 전달하는 데 사용된다.

UsernamePasswordAuthenticationToken은 인증 매커니즘에서 인증을 수행하는 과정에서 생성되고 사용된다. 일반적으로 사용자가 로그인 페이지에서 사용자 이름과 비밀번호를 입력하면, 스프링 시큐리티는 UsernamePasswordAuthenticationToken 객체를 생성하여 사용자가 제출한 인증 정보를 담아 인증 처리를 시작한다.

UsernamePasswordAuthenticationTokenAuthentication 인터페이스를 구현한 클래스로, 사용자의 인증 정보를 담고 있다. Authentication은 스프링 시큐리티에서 인증에 필요한 정보를 캡슐화하는 인터페이스이다.

UsernamePasswordAuthenticationToken은 주로 AuthenticationManager에 의해 처리되며, 인증 과정에서 제공된 사용자 이름과 비밀번호를 사용하여 사용자를 인증한다. 인증이 성공하면 Authentication 객체가 인증된 상태로 반환되고, 인증 실패시에는 예외가 발생할 수 있다.

CustomUserDetailsService

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String email) throws RuntimeException {
        return memberRepository.findByEmail(email)
                .map(this::createUserDetails)
                .orElseThrow(() -> new RuntimeException("해당하는 유저를 찾을 수 없습니다."));
    }

    // 해당하는 User 의 데이터가 존재한다면 UserDetails 객체로 만들어서 리턴
    private UserDetails createUserDetails(Member member) {
        return User.builder()
                .username(member.getEmail())
                .password(passwordEncoder.encode(member.getPassword()))
                .build();
    }
}

CustomUserDetailsService 클래스가 Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); 에서 동작되는 원리

  1. AuthenticationManager는 인증 요청에 필요한 사용자 정보를 DB에서 가져와야 한다. 이때 loadUserByUsername 메서드가 호출된다.
  2. loadUserByUsername 메서드는 UserDetailsService 인터페이스를 구현한 CustomUserDetailsService에서 구현된다. 해당 메서드는 주어진 사용자 이름(username)을 기반으로 사용자 정보를 조회하고, UserDetails 객체를 반환한다.
  3. AuthenticationManagerloadUserByUsername 메서드를 통해 반환된 UserDetails 객체를 사용하여 인증 과정을 진행한다. 인증 과정에서는 사용자가 입력한 인증 정보와 UserDetails 객체에 저장된 정보를 비교하여 인증을 수행한다.
  4. AuthenticationManager는 인증 결과인 Authentication 객체를 생성하여 반환한다. 이때 Authentication 객체는 인증된 사용자 정보와 권한 정보를 포함한다.

이제 DB에 사용자가 있다는 사실을 인증받은 것이므로 해당 내용을 바탕으로 jwtToken을 생성하는 jwtTokenprovider를 만들면 된다.

토큰 정보 클래스 만들기

클라이언트가 요청하면 서버는 jwt token을 클라이언트에게 response로 주어야 한다.
해당 정보를 클래스로 만들어보자.

@Builder  
@Data  
@AllArgsConstructor  
public class TokenInfo {  
    private String grantType;  // Bearer
    private String accessToken;  
    private String refreshToken;  
}

grantType
grant typedms 클라이언트가 어떤 유형의 인증 흐름을 사용하여 액세스 토큰을 요청하는지 나타낸다.
그 중에서 Bearer를 사용할 건데, Bearer 토큰은 서버에 상태를 저장하지 않고, 클라이언트가 요청을 보낼 때마다 토큰의 유효성을 검증한다. 그래서 이후에 HTTP 요청을 보낼때 헤더에 붙여서 보내면 인증된 사용자인지 검증할 수 있는 것이다.

JwtTokenProvider 클래스

JwtTokenProvider는 사용자 인증과 인가를 처리하는 데 사용되는 클래스이다. JWT (JSON Web Token)을 생성, 유효성 검사 및 해석하는 데 사용되고, 주로 Spring Security와 함께 사용되며, 사용자의 인증 정보를 JWT 토큰으로 인코딩하여 전달하고, 토큰의 유효성을 검사하여 사용자를 인증하고 권한을 부여한다. secret key를 사용해서 jwt token을 만들 것이다.

yml 파일에 secret key 추가

토큰을 암호화하고 복호화할 때 HS256 알고리즘을 사용하려면 secret key를 설정해주어야 한다. 해당 코드를 yml 파일에 적어주자. (아무거나 해당 길이만 맞으면 된다!)

jwt:  
    secret: VlwEyVBsYt9V7zq57TejMnVUyzblYcfPQye08f7MGVA9XkHa

JwtTokenProvider 클래스

// 토큰 생성  
@Slf4j
@Component
public class JwtTokenProvider {

    private final Key key;

    @Value("${jwt.secret}")
    String salt;

    private final long at_exp = 1000L * 60 * 30;
    private final long rt_exp = 1000L & 60 * 60;
    // secret key를 가지고 key값 저장
    public JwtTokenProvider(@Value("${jwt.secret}") String salt) {
        this.key = Keys.hmacShaKeyFor(salt.getBytes(StandardCharsets.UTF_8));
    }

    // 토큰 생성
    public TokenInfo generateToken(Member member) {
        // Access Token 생성
        Claims claims = Jwts.claims().setSubject(member.getEmail());
        claims.put("memberId", member.getId());

        long now = (new Date()).getTime();
        Date accessTokenExpiresIn = new Date(now + at_exp);
        String accessToken = Jwts.builder()
                .setClaims(claims)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + rt_exp))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return TokenInfo.builder()
                .grantType("Bearer")
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

    // JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        // UserDetails 객체를 만들어서 Authentication 리턴
        UserDetails principal = new User(claims.getSubject(), "", Collections.emptyList());
        return new UsernamePasswordAuthenticationToken(principal, "", Collections.emptyList());
    }

    // 토큰 정보를 검증하는 메서드
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (RuntimeException e) {
            throw e;
        }

    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (RuntimeException e) {
            throw e;
        }
    }

    // Bearer 제외부분
    public String getToken(String token) {
        token = token.substring(7).trim();
        return token;
    }

    public String getEmail(String token) {
        return Jwts.parserBuilder().setSigningKey(salt.getBytes()).build().parseClaimsJws(token).getBody().getSubject();
    }

    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("Authorization");
    }
}
  1. authentication.getAuthorities()를 통해 사용자의 권한 정보를 가져온다. 권한은 GrantedAuthority 객체로 표현되며, getAuthority()를 사용하여 권한 이름을 추출한다. 여러 권한이 있을 경우 쉼표로 구분하여 문자열로 합친다.
  2. 현재 시간을 기준으로 액세스 토큰의 만료 시간을 계산한다. new Date(now + ac_exp)를 통해 현재 시간으로부터 30분 후의 시간을 나타내는 accessTokenExpiresIn을 생성한다.
  3. Jwts.builder()를 사용하여 JWT 빌더를 생성한다. setSubject(authentication.getName())로 JWT의 subject(주체)를 사용자명으로 설정한다. 권한은 필요없으므로 empty로 둔다.
  4. setExpiration(accessTokenExpiresIn)으로 액세스 토큰의 만료 시간을 설정한다. signWith(key, SignatureAlgorithm.HS256)로 시크릿 키와 알고리즘을 사용하여 토큰을 서명한다. compact()를 호출하여 토큰을 문자열로 변환한다.
  5. refreshToken을 생성한다. 액세스 토큰과 동일한 방식으로 빌더를 생성하고, 만료 시간을 현재 시간으로부터 24시간 후로 설정한다.
  6. TokenInfo 객체를 생성하고, grantType을 "Bearer"로 설정하고, 생성된 액세스 토큰과 리프레시 토큰을 할당한다.

이 코드는 사용자의 인증 정보를 JWT 토큰으로 변환하여 반환하는 메서드로, 액세스 토큰과 리프레시 토큰을 생성하여 TokenInfo 객체로 래핑하여 반환한다.

JwtAuthenticationFilter

JWT 인증을 하기 위해 사용되는 필터로 JwtAuthenticationFilter를 통과하고 UsernamePasswordAuthenticationFilter를 진행하게 된다.
해당 과정은 토큰이 유효한지 검사하고 토큰 정보를 추출하는 함수이다.

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 1. Request Header 에서 JWT 토큰 추출
        String token = resolveToken((HttpServletRequest) request);

        // 2. validateToken 으로 토큰 유효성 검사
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }

    // Request Header 에서 토큰 정보 추출
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}
728x90
반응형

+ Recent posts