OAuth 2.0 + JWT + Spring Security로 회원 기능 개발하기 - JWT 개념과 Security 기본 설정

DevSeoRex·2023년 5월 20일
8
post-thumbnail

👍 Session vs JWT

두번의 프로젝트를 통해 회원 기능을 구현할때는 사용자가 로그인을 성공하면 사용자 정보를 객체에 담아 Session 저장소에 보관하는 방식으로 처리해왔습니다.

Session에 회원객체를 저장하면, 어디서든 Session 저장소에 접근만 하면 회원의 정보를 쉽게 꺼내쓸 수 있다는 장점이 있었습니다.

이번에 Next.js를 사용하는 프론트 개발자분들과 협업하면서 Restful API를 구성하기로 협의하였습니다.

Session을 이용한 인증방식은 클라이언트로부터 요청을 받으면 클라이언트의 상태 정보를 저장하여 유지해야 하므로 Stateful 하기때문에 현재 프로젝트의 방향과 맞지 않았습니다.

또한 Session을 이용하게 되면 Stateful 하다는 부분도 문제가 있었지만, Session 저장소를 사용해서 인증을 구현하는 것이 서버에 무리를 줄 수 있다는 점도 JWT를 이용하게 된 이유 중 하나입니다.

따라서 Session을 사용하지 않고 JWT 토큰을 이용하여 인증을 진행하기로 결정하였습니다.

🥳 JWT는 무엇일까?

JWT(Json Web Token)란 인증에 필요한 정보를 암호화시킨 토큰을 뜻합니다.

Session 인증방식에서는 클라이언트로부터 요청이 오면, 서버에서 Session 저장소에 접근하여 현재 접속중인 사용자의 정보를 꺼내어 인증하는 방식을 사용했습니다.

JWT 토큰을 사용하면 클라이언트는 AccessToken을 HTTP 헤더(Authorization)에 실어서 보내주게 되면 서버는 AccessToken을 복호화하여 해당 유저의 정보를 얻게되고 요청한 API에 대한 권한이 있는지 확인 후 응답하게 됩니다.

사용자가 OAuth 인증을 통한 로그인을 시도하면 로그인 성공시 AccessToken을 포함시켜 클라이언트의 URI로 리디렉션 시킵니다.

클라이언트는 서버로부터 전달받은 엑세스토큰을 저장소에 저장해두고 요청때마다 헤더에 AccessToken을 포함시켜 요청하게 됩니다.

JWT는 어떻게 구성되어 있을까?


JWT는 세 부분으로 나눠지고, 각 부분을 점( . )으로 구분합니다. 각 순서대로 header, payload, signature로 구성됩니다.
JSON 형태로 구성되어 있는 각 부분은 BASE64로 인코딩 되어 표현됩니다.

  • Header

    • 토큰의 타입과 해시 암호화 알고리즘으로 구성됩니다.
    • 토큰의 타입을 넣지 않아도 사용은 가능합니다(저는 안넣었습니다)
    • alg는 헤더를 암호화하는 것이 아니라, Signature를 해싱하기 위한 알고리즘을 지정합니다.
  • payload

    • 토큰에 사용자가 담고자 하는 정보를 담는 곳입니다.
    • 토큰에서 사용할 정보의 조각들인 Claim이 담겨 있습니다.
    • Claim은 등록된 클레임, 공개 클레임, 비공개 클레임이 있습니다.
    • sub(subject), iat(issuedAt), exp(expiraton)은 등록된 클레임입니다.
    • role은 비공개 클레임 이면서 사용자 정의 클레임입니다.
  • Signature

    • 토큰을 인코딩하거나 유효성 검증을 할 때 사용하는 고유한 암호화 코드입니다.
    • 헤더와 내용의 값을 각각 BASE64로 인코딩하고, 인코딩한 값을 비밀키를 이용해 헤더에서 정의한 알고리즘을 이용하여 해싱하고, 이 값을 다시 BASE64로 인코딩하여 생성하게 됩니다.

Header + Payload + Signature를 모두 합치면 아래와 같은 코드가 나오게되고, JWT 공식 사이트에 들어가면 JWT를 만들어보고 디코딩도 해볼 수 있습니다.

JWT 세션 인증보다 좋고 문제는 없는 Silver-Bullet인가?

제목처럼 JWT가 세션 인증보다 좋은 점만 있고 문제가 없다면 세션 인증방식은 역사에서 사라지게 될지도 모릅니다.

모든 기술은 어떠한 불편함이나 개선을 추구하고자 등장하고 발전하지만, 항상 그렇듯이 정답은 없고 최선만 있는 것 같습니다. 세션은 나쁘고 JWT는 좋다는 것이 아니라 현재 우리 애플리케이션에 맞는 방향을 가진 기술을 선택하는 것 또한 개발자의 역량이라고 생각합니다.

JWT는 어떤 문제를 가지고 있을까요?

JWT의 장점부터 나열해본다면 아래와 같을 것 같습니다.

  • Stateless 합니다
  • 확장성이 뛰어납니다(서비스별로 서버를 여러 대 사용하더라도 토큰만 있다면 인증이 가능합니다)

그렇다면 단점도 한번 나열해보겠습니다.

  • 이미 발급된 JWT를 만료시킬 방법은 없습니다. 세션은 악의적인 사용을 시도할 경우 지워버리면 접속이 끊기지만 JWT는 유효기간이 만료 될 때까지는 사용이 가능합니다.
  • 유효기간이 지나기 전까지는 얼마든지 악의적인 이용이 가능합니다.
  • JWT의 길이가 길어서, 인증이 필요한 요청이 많다면 서버의 자원낭비가 발생합니다.

단점중에 가장 큰 문제는 유효기간이 지나기 전까지 악의적인 탈취자에 의해 사용자의 정보가 유출될 수 있다는 점입니다.

이 부분은 AccessToken의 만료시간을 짧게 가져가고 RefreshToken을 이용해서 AcessToken을 재발급받는 형식의 전략을 취해서 보완했습니다.

이 부분은 OAuth 2.0을 이용한 회원 시스템 개발 포스팅을 진행하면서 다루도록 하겠습니다.

🤩 설정이 반이니까..! Spring Security 설정!

이제 기본적인 개념 정리가 끝났으니, Spring Security 설정을 시작해보겠습니다.
Spring Security 사용을 위해 build.gradle에 의존성을 추가해주겠습니다.

Spring Security 기능을 활성화시키고, OAuth 2.0과 JWT 로그인을 활용하기 위한 설정 클래스를 작성하겠습니다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig3 {
    private final MyAuthenticationSuccessHandler oAuth2LoginSuccessHandler;
    private final CustomOAuth2UserService customOAuth2UserService;
    private final JwtAuthFilter jwtAuthFilter;
    private final MyAuthenticationFailureHandler oAuth2LoginFailureHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable() // HTTP 기본 인증을 비활성화
                .cors().and() // CORS 활성화
                .csrf().disable() // CSRF 보호 기능 비활성화
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션관리 정책을 STATELESS(세션이 있으면 쓰지도 않고, 없으면 만들지도 않는다)
                .and()
                .authorizeRequests() // 요청에 대한 인증 설정
                .antMatchers("/token/**").permitAll() // 토큰 발급을 위한 경로는 모두 허용
                .antMatchers("/", "/css/**","/images/**","/js/**","/favicon.ico","/h2-console/**").permitAll()
                .anyRequest().authenticated() // 그 외의 모든 요청은 인증이 필요하다.
                .and()
                .oauth2Login() // OAuth2 로그인 설정시작
                .userInfoEndpoint().userService(customOAuth2UserService) // OAuth2 로그인시 사용자 정보를 가져오는 엔드포인트와 사용자 서비스를 설정
                .and()
                .failureHandler(oAuth2LoginFailureHandler) // OAuth2 로그인 실패시 처리할 핸들러를 지정해준다.
                .successHandler(oAuth2LoginSuccessHandler); // OAuth2 로그인 성공시 처리할 핸들러를 지정해준다.


        // JWT 인증 필터를 UsernamePasswordAuthenticationFilter 앞에 추가한다.
        return http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }

전체 코드만 보면 꽤나 코드가 길어서 아찔한 것 같습니다.
한줄씩 뜯어서 어떤 의미인지 차근차근 설명해드리겠습니다.

Spring Security 예제들을 보면 WebSecurityConfigurerAdapter를 상속받고 메서드를 오버라이딩 하여 설정하는 예제들이 많습니다.


현재는 deprecated 되어 권장되지 않는 방식이므로, 공식 문서에서 권장하는 SecurityFilterChain을 Bean으로 등록하여 사용하는 방식으로 설정 클래스를 작성했습니다.

  • @Configuration, @EnableWebSecurity

    이 클래스가 설정클래스로 등록하고, Spring Security를 활성화시키는 역할을 합니다.
    @EnableWebSecurity 애너테이션은 Spring Security 기능 활성화를 위한 다양한 클래스들을 import하고 있는 것을 볼 수 있습니다.
  • httpBasic().disabled()

    이 설정을 하지 않으면 인증이 되지 않을시 기본 로그인 창으로 이동되게 됩니다. Session을 사용하지 않는 Rest API 방식을 사용할 것이므로 비활성화 해줍니다.

  • cors().and().csrf().disabled()

    저희 팀은 Next.js와 협업하고 있기 때문에 CORS 문제를 해결하기 위해 CORS를 활성화하는 설정을 켜줬습니다.
    CSRF는 사이트간 위조 요청으로 공격방법 중 하나입니다.
    Spring Security 공식 문서에서는 non-browser clients 만을 위한 서비스라면 CSRF를 비활성화시켜도 좋다고 이야기합니다. 우리가 개발해야하는 서버는 Rest-Api 서버이므로 비활성화 시키도록 하겠습니다.

  • sessionManageMent().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

    세션관리 정책에 대한 설정을 STATELESS로 설정하는 코드입니다.
    SessionCreationPolicy는 네가지 값이 존재합니다. 주의해야 할 점은 NEVER는 세션을 아예 사용하지 않겠다는 뜻이 아니라는 점에 유의해야 합니다.


NEVER는 스프링 시큐리티가 세션을 생성하지 않지만, 있으면 사용하겠다는 설정 값입니다.
STATELESS는 스프링 시큐리티가 세션을 생성하지 않고, 있어도 사용하지 않겠다는 설정 값입니다.
따라서 Stateless 상태를 가지기 위해 STATELESS로 설정해주겠습니다.

  • and().authorizeRequests()

    요청에 대한 인증 설정을 시작합니다. 이 메서드에 연이어서 antMatchers()를 이용해 접근 권한을 설정합니다.

  • antMatchers().permitAll()

    antMatchers() 메서드로 URL 패턴을 지정하고 permitAll() 메서드를 이용해 해당 URL 패턴을 가지면 접근 권한이 별도로 필요하지 않은 URL로 등록합니다.
    접근 권한을 검사하지 않는다고 해서 인증이 필요 없다는 뜻은 아닙니다(이 부분 때문에 생기는 문제는 따로 포스팅 하겠습니다)

  • antMatchers().hasAnyRole()
    .anyRequest().authenticated()

antMatchers().hasAnyRole()은
/mypage로 시작하는 요청은 회원(USER) 또는 관리자(MANAGER) 권한을 가지고 있어야 접근이 가능하게 설정하는 코드입니다.

anyRequest().authenticated()는
위에서 나열한 URL들 이외의 다른 요청들은 전부 인증이 필요하다는 뜻입니다.

antMatchers()를 이용한 권한 설정에서 주의할 점

설정 클래스는 위에서 아래로 코드가 진행하게 되어있습니다. 예시 코드를 보겠습니다.

이렇게 코드가 작성된 상황에서 /token/refresh로 요청을 보내면 권한이 없어도 통과되게 됩니다.
Security 설정 클래스는 antMatchers()로 지정된 URL을 순차적으로 탐색하면서 현재 요청과 가장 먼저 매칭되는 규칙을 따르게 됩니다.

/token/refresh는 USER 권한이 필요하다는 것은 후순위에 있으므로, /token/**에 일치하는 요청은 권한없이 모두 허용한다는 규칙이 먼저 적용되어 문제가 생길 수 있습니다.

  • oauth2Login().userInfoEndPoint().userService(customOAuthUserService)

OAuth 로그인 설정을 진행하는 부분입니다. OAuth2 로그인시 사용자 정보를 가져오는 엔드포인트와 사용자 서비스를 설정합니다.
userService()는 OAuth2UserService를 구현한 구현체를 인자값으로 받습니다.

customOAuth2UserService는 계속되는 포스팅에서 작성하도록 하겠습니다.

  • failureHandler(oAuth2LoginFailureHandler)
    .successHandler(oAuth2LoginSuccessHandler)

    OAuth2 로그인시 성공여부에 따라 처리할 핸들러를 지정해줍니다.
    성공시에는 oAuth2LoginSuccessHandler가 처리하고 실패시에는 oAuth2LoginFailureHandler가 처리합니다.

  • addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)

UsernamePassswordAuthenticationFilter가 실행되기 전에 우리가 직접 작성한 customfilter인 jwtAuthFilter가 먼저 실행되게 합니다.

JwtAuthFilter가 먼저 실행되어야 하는 이유는 UsernamePasswordAuthenticationFilter는 인증되지 않은 사용자의 경우 로그인 페이지로 리디렉션 시키기 때문에, JWT 토큰을 검증하고 토큰의 정보를 이용해서 인증 객체를 SecurityContext에 넣어주어야 인증되었다고 보고 로그인 페이지로 리디렉션 되지 않기 때문입니다.

😇 이제 진짜 개발을 시작해보자!

JWT와 OAuth 2.0에 대한 개념 학습과 더불어 Spring Security를 사용하기 위한 기본 설정이 끝났습니다.
다음 포스팅부터는 OAuth 2.0 앱등록부터 하나하나 개발을 시작해보겠습니다. 읽어주셔서 감사합니다!

🙇

다음 시리즈 게시물로 이동
OAuth 2.0 + JWT + Spring Security로 회원 기능 개발하기 - 앱등록과 OAuth 2.0 기능구현
게시물로 이동 ->

참고한 레퍼런스

https://yunb2.tistory.com/3

https://velog.io/@woohobi/Spring-security-csrf%EB%9E%80

2개의 댓글

comment-user-thumbnail
2023년 5월 22일

많은 참고가 되었습니다~ 항상 좋은 글 만들어 주셔서 감사합니다~

1개의 답글