Spring Security

Spring Security, OAuth2.0 Client, JWT 로그인 구현하기 - 2

후뿡이 2024. 11. 11. 23:43

0. 지난 내용


저번 포스트에서 OAuth2.0 Client 의 기능이 뭔지

어떻게 OAuth 를 간단하게 구현할 수 있는지 알아 보았다.

이번에는 OAuth 로그인 이후의 과정을 알아보자 !

 

 

1. 이번 목표


저번 포스트에서 OAuth 로그인을 통해 유저 정보를 받아 왔다.

이 정보를 이용해서 우리 서버와 계속 통신할 수 있도록 JWT 토큰을 발급해보자

 

JWT 가 왜 필요한지 간단하게 설명하자면

HTTP, HTTP 통신을 기반으로 하는 REST API 는 무상태성 통신을 지향하기 때문에

매 요청마다 서버는 클라이언트가 누구인지 알 수 없다.

이러한 단점을 해결하고 각 요청마다 요청한 사람이 누구인지 인증하기 위해 JWT, Session 등의 기술을 사용한다.

 

그 중에서 이번에는 보편적으로 이용되는 JWT 토큰 방식을 이용해서 인증서버를 구현해보자

 

 

2. 구현 흐름


구현하기 전에 큰 그림을 그려보자

 

이번에 구현할 Service 는 총 2개 이다.

  1. OAuth2SuccessHandler
  2. TokenService

OAuth2SuccessHandler 는 OAuth2Service 요청을 성공한 후의 과정을 처리한다.

AuthenticationSuccessHandler 인터페이스를 구현함으로 구현할 수 있다.

이곳에서 JWT Token 을 발급하는 TokenService 를 주입 받아서

OAuth 로그인 성공 후에 카카오 구글 등의 서비스로부터 받은 토큰이 아닌

우리 서버와 통신하기 위한 JWT 토큰을 발급할 것이다.

 

먼저 TokenService 부터 구현을 해보자.

3. TokenService


3-1. jjwt 라이브러리 의존성 추가

먼저 build.gradle 파일에 JWT 구현을 위해 필요한 라이브러리를 설치해 줍시다.

implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'

 

3-2. JWT 설정 파일 application.yaml 에 추가해주기

저는 yaml 형식이 더 읽기 편한 것 같아서 주로 yaml 형식을 사용합니다.

 

여기에 JWT 관련한 설정값을 추가해 줍시다.

저는 refresh_token 과 access_token 의 key 값을 다르게 가져갈 생각이기 때문에 key 값을 두 개 설정하겠습니다.

그리고 토큰의 만료시간을 설정해 줍시다.

 

jwt:
  acceess_token_secret_key: supersecretsupersecretsupersecretsupersecretsupersecretsupersecretsupersecret
  refresh_token_secret_key: supersecretsupersecretsupersecretsupersecretsupersecretsupersecretsupersecret
  expiration_in_seconds: 86400000

 

이 값은 뒤에 나올 TokenService 에서 꺼내서 사용해 주도록 합시다 !

 

3-3. TokenService 구현

본격적으로 JWT 토큰을 발급해줄 TokenService 를 구현해 봅시다.

 

@Service
@RequiredArgsConstructor
public class TokenService {
    @Value("${jwt.acceess_token_secret_key}")
    private String ACCESS_TOKEN_SECRET_KEY;
    @Value("${jwt.refresh_token_secret_key}")
    private String REFRESH_TOKEN_SECRET_KEY;
    @Value("${jwt.expiration_in_seconds}")
    private Long EXPIRATION;

    private Key ENCODED_ACCESS_KEY;
    private Key ENCODED_REFRESH_KEY;

    private final String EMAIL = "email";
    private final String USER_ID = "userId";

 

먼저 상수값 세팅입니다.

application.yaml 에서 설정한 acceess_token_secret_key, refresh_token_secret_key, expiration_in_seconds 값을 꺼내어 ACCESS_TOKEN_SECRET_KEY, REFRESH_TOKEN_SECRET_KEY, EXPIRATION 에 저장해 줍니다.

 

JWT 토큰은 key 값을 Base64 로 인코딩 해서 사용하기 때문에

인코딩한 키 값을 저장할 ENCODED_ACCESS_KEY, ENCODED_REFRESH_KEY 변수를 선언해 줍니다.

 

EMAIL, USER_ID 는 KakaoUser 정보를 파싱하기 위한 상수값입니다.

 

    @PostConstruct
    public void init() {
        byte[] accessKeyBytes = Decoders.BASE64.decode(ACCESS_TOKEN_SECRET_KEY);
        this.ENCODED_ACCESS_KEY = Keys.hmacShaKeyFor(accessKeyBytes);
        byte[] refreshKeyBytes = Decoders.BASE64.decode(REFRESH_TOKEN_SECRET_KEY);
        this.ENCODED_REFRESH_KEY = Keys.hmacShaKeyFor(refreshKeyBytes);
    }

 

 

인코딩 키 값 세팅입니다.

사용자가 입력한 key 값을 Base64 로 인코딩하기 위한 함수입니다.

@PostConstruct 데코레이터를 활용해서 TokenService 객체가 생성된 직후에 자동으로 실행되게 해줍시다.

Base64로 인코딩한 Key 값을 위에서 선언한 ENCODED_ACCESS_KEY, ENCODED_REFRESH_KEY 에 저장해 줍시다.

 

    public String createAccessToken(Long userId, String email) {
        return createToken(userId, email, false);
    }

    public String createRefreshToken(Long userId, String email) {
        return createToken(userId, email, true);
    }

    private String createToken(Long userId, String email, boolean isRefreshToken) {
        return Jwts.builder()
                .claim(USER_ID, userId)
                .claim(EMAIL, email)
                .signWith(isRefreshToken ? ENCODED_REFRESH_KEY : ENCODED_ACCESS_KEY, SignatureAlgorithm.HS256)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION * 1000))
                .compact();
    }

 

토큰 생성 부분입니다.

accessToken, refreshToken 생성 로직은 비슷하니 createToken을 공통 private 함수로 분리하고 public 으로 createAccessToken, createRefreshToken 으로 분리해 줍시다.

createToken을 public 으로 노출해도 되지만 호출하는 쪽에서 flag 성 변수인 isRefreshToken 으로 어떤 토큰인지 판별해야 하기 때문에 명시적이지 않다고 생각해 accessToken, refreshToken 생성 함수를 따로 분리했습니다.

그리고 추가적으로 둘은 Expiration 또한 달라질 수 있기 때문에 분리하는게 더 맞다고 생각합니다.

 

이제 TokenService 와 토큰 생성 함수를 구현했으니 OAuth@SuccessHandler로 넘어갑시다.

 

3-4. 전체 코드

@Service
@RequiredArgsConstructor
public class TokenService {
    @Value("${jwt.acceess_token_secret_key}")
    private String ACCESS_TOKEN_SECRET_KEY;
    @Value("${jwt.refresh_token_secret_key}")
    private String REFRESH_TOKEN_SECRET_KEY;
    @Value("${jwt.expiration_in_seconds}")
    private Long EXPIRATION;

    private Key ENCODED_ACCESS_KEY;
    private Key ENCODED_REFRESH_KEY;

    private final String EMAIL = "email";
    private final String USER_ID = "userId";

    @PostConstruct
    public void init() {
        byte[] accessKeyBytes = Decoders.BASE64.decode(ACCESS_TOKEN_SECRET_KEY);
        this.ENCODED_ACCESS_KEY = Keys.hmacShaKeyFor(accessKeyBytes);
        byte[] refreshKeyBytes = Decoders.BASE64.decode(REFRESH_TOKEN_SECRET_KEY);
        this.ENCODED_REFRESH_KEY = Keys.hmacShaKeyFor(refreshKeyBytes);
    }

    public String createAccessToken(Long userId, String email) {
        return createToken(userId, email, false);
    }

    public String createRefreshToken(Long userId, String email) {
        return createToken(userId, email, true);
    }

    private String createToken(Long userId, String email, boolean isRefreshToken) {
        return Jwts.builder()
                .claim(USER_ID, userId)
                .claim(EMAIL, email)
                .signWith(isRefreshToken ? ENCODED_REFRESH_KEY : ENCODED_ACCESS_KEY, SignatureAlgorithm.HS256)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION * 1000))
                .compact();
    }
}

 

4. OAuth2SuccessHandler


4-1. 구현

Spring Security 는 인증, 인가에 필요한 많은 부분을 추상화 해줍니다.

AuthenticationSuccessHandler interface 의 onAuthenticationSuccess 함수를 Overide 하게 되면

Authentication 에 성공한 후의 로직을 구현할 수 있습니다.

 

우리는 OAuth 인증이 성공한 후에 DB에 회원 정보를 삽입하는 것에 성공했으니

OAUth2SuccessHandler 에서는 TokenService 를 이용해서 JWT Token을 발급해 봅시다.

 

@Component
@Slf4j
@RequiredArgsConstructor
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {

    private final TokenService tokenService;
    private final UserService userService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {
		// OAuth2UserService 에서 생성한 DefaultOAuth2User 정보를 authentication 에서 꺼낼 수 있다.
        KakaoUser kakaoUser = new KakaoUser((OAuth2User) authentication.getPrincipal());

		// accessToken 생성 및 쿠키설정
        Cookie accessTokenCookie = getAccessTokenCookie(kakaoUser);
        accessTokenCookie.setPath("/");
        accessTokenCookie.setHttpOnly(true);

		// refreshToken 생성 및 쿠키설정
        String refreshToken = tokenService.createRefreshToken(kakaoUser.getId(), kakaoUser.getEmail());
        Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
        refreshTokenCookie.setPath("/");
        refreshTokenCookie.setHttpOnly(true);
		
        // refreshToken 은 DB에 저장해주자 ! ( Redis에 저장하는 방법도 있지만 RDBMS를 선택했다.)
        // 이 부분은 어렵지 않으니 직접 구현하시면 됩니다.
        userService.updateRefreshTokenByEmail(kakaoUser.getEmail(), refreshToken);

		// response 객체에 쿠키를 세팅해주자.
        response.addCookie(refreshTokenCookie);
        response.addCookie(accessTokenCookie);

		// 로그인 페이지로 리다이렉트 시켜주자
        response.sendRedirect("/login");
    }

    private Cookie getAccessTokenCookie(KakaoUser kakaoUser) {
        String token = tokenService.createAccessToken(kakaoUser.getId(), kakaoUser.getEmail());
        return new Cookie("accessToken", token);
    }

}

 

여기까지 설정하게 되면

accessToken, refreshToken 을 cookie 에 담아서 /login 으로 리다이렉트하게 됩니다.

 

한 번 서버를 실행시켜서 결과를 확인해 봅시다 !

 

4-2. 결과 

 

읭 ??? 

Cookie 에 토큰이 설정되지도 않고 로그인도 실패했습니다.

왜 그럴까요 ?

 

먼저 유저 정보가 제대로 삽입됐는지 DB를 살펴봅시다.

 

 

데이터베이스에는 데이터가 정상적으로 삽입 되었습니다.

 

SuccessHandler 가 동작하지 않은 것 같습니다.

 

4-3. SecurityConfig 등록

네 맞습니다.

Spring Security 는 구현한 것을 설정에 항상 등록해 줘야합니다.

우리가 만든 SuccessHandler 를 등록하지 않았으니 동작하지 않는게 당연하겠네요.

 

SpringSecurity에 구현한 Handler 를 등록해 줍시다.

 

@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final OAuth2UserService oAuth2UserService;
    private final OAuth2SuccessHandler oAuth2SuccessHandler;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(sessionManagement ->
                        sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .headers(headers ->
                        headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
                );

        http
                .oauth2Login(config -> config
                        .userInfoEndpoint(endpointConfig -> endpointConfig
                                .userService(oAuth2UserService))
                        // 이 부분에 successHandler 옵션을 추가해 줍시다.
                        // 추후에 failHandler 도 같은 방법으로 등록해 주시면 됩니다.
                        .successHandler(oAuth2SuccessHandler)
                );

        return http.build();
    }
}

 

 

5. 최종 결과


자 이제 SuccessHandler 등록도 마쳤으니 결과를 확인해 봅시다.

 

cookie에 토큰이 설정된 모습

 

다시 /login 페이지로 redirect 시켰기 때문에 화면은 변화가 없지만 Cookie 를 확인해보면 accessToken 과 refreshToken 이 잘 설정된 것을 확인할 수 있다.