OAuth2를 처음으로 사용하다 보니 코드를 작성할 때 마다 뭔가 엄청 비효율적인 것 같고 코드를 알아보기 힘들었습니다. 그러다 보니 시중에 시스템하는 서비스들의 로그인 기능 수준으로 구현을 하기 위해 계속 갈아엎고 찾아보기를 반복하여 현재 우리 프로젝트에서는 OAuth2 로그인을 어떻게 구현했는지 정리해보았습니다.
코드 관련 설명 위주로 작성할 예정이라 각 사이트별 OAuth2 키를 받는 과정은 생략하겠습니다.
1) application.yaml 작성
여러 삽질 끝에 Spring Security에서 기본적으로 OAuth2 로그인 기능을 지원한다는 것을 알았습니다. 적용하는 방법으로는 application.yaml 파일에서 관련 설정들을 적어주면 되었습니다.
현재 저희 프로젝트에 적용되어 있는 설정을 기준으로 설명하겠습니다.
spring → security → oauth2 → client 안에서 크게 registration과 provider를 설정해주면 됩니다.
registration과 provider란?
registration은 애플리케이션이 특정 OAuth2 **서비스 제공자(provider)**와 통신하기 위해 필요한 클라이언트 설정을 정의하는 섹션입니다.
provider는 애플리케이션이 통신하는 OAuth2 인증 제공자의 세부 정보(엔드포인트, URL 등)를 정의하는 섹션입니다.
위 설명대로 registration에선 각 oauth2 제공 사이트에서 받은 client id와 secret key 등을 설정해줍니다. 그리고 provider에선 각 서비스 제공자별로 인증 엔드포인트, 토큰 엔드포인트, JWK(JSON Web Key) URI가 다르기 때문에 해당 내용들을 입력해줍니다.
2) Spring Security Configuration 클래스 생성
Spring에서는 @Configuration 어노테이션으로 Spring 관련 설정 클래스를 만들 수 있습니다. 이를 활용해 SecurityConfig.java파일을 만들어 SpringSecurity 관련 설정을 하는 코드를 작성했습니다.
SecurityFilterChain 관련 설정들을 Bean으로 등록시켜서 이 안에서 oauth2Login()을 이용해 oauth2 로그인이 성공했을 때와 실패했을 때 어떤 작업을 진행할 것인지 설정할 수 있습니다.
해당 작업들은 Configuration 클래스 내에서 작성하기엔 단일 책임 원칙(SPR)에 어긋나기 때문에 성공했을 때 작업할 과정, 실패했을 때 작업할 과정을 따로 handler로 만들었습니다.
3) OAuth2 Handler 생성
위에서 이름 지어놓은 OAuth2LoginSuccessHandler와 OAuth2LoginFailureHandler를 만들어보았습니다. OAuth2LoginSuccessHandler에선 로그인을 처음 한 사용자라면 회원가입을 진행하고, 이미 가입이 되어 있는 사용자라면 로그인 처리를 할 수 있도록 했습니다. OAuth2LoginFailureHandler에선 간단히 로그인 실패 처리를 하도록 했습니다.
3-1. 로그인 처리는 어떻게 해야하나?
이 부분에서 고민을 가장 많이했습니다.
JWT 토큰을 accessToken과 refreshToken으로 나누어서 refreshToken은 Cookie에 저장하여 accessToken을 재발급하는 용도로 쓰고, accessToken은 Request Header의 Authorization에 넣어 로그인 처리를 하는 건 일반적으로 사용하는 방법이었습니다. 그래서 해당 방법을 사용하기로 정했지만 중요한 것은 “accessToken을 어떻게 프론트엔드 단으로 전달할 것인가” 이었습니다.
OAuth2 로그인 방식을 사용하면 로그인 성공 후 제가 지정한 경로로 Redirect됩니다. Redirect가 되면서 백엔드에서 지정한 Request 설정값은 HTTP의 원칙에 따라 사라지게 되고, 그럼 백엔드에선 Header의 Authorization을 accessToken값으로 설정해봤자 날라간다는 뜻입니다.
그러므로 Header에 넣는 게 아닌 다른 방법으로 프론트엔드에게 accessToken을 전달해줘야 했습니다.
3-1-1. 파라미터에 access token값을 넣어서 준다
가장 먼저 떠오른 방법이었습니다. OAuth2 로그인이 성공한 후 redirect되는 url 경로 뒤에 ?accessToken=%s를 넣고 OAuth2LoginSuccessHandler에서 access token값을 해당 파라미터의 값으로 넣어주면 프론트엔드는 access token을 받을 수 있었습니다.
그러나 문제점이 있었습니다. access token값이 url에서 그대로 노출된다는 것이었습니다. 이는 보안에 매우 취약하기 때문에 해당 방안은 바로 취소해야 했습니다.
3-1-2. 파라미터에 임시 토큰값을 넣어 주고 임시 토큰으로 access token을 발급받도록 한다
어떻게 해야 할지 막막했던 저희 팀은 ChatGPT에게 현업에서 이와 같은 상황을 어떻게 해결하냐고 물어봤습니다. 그리고 위와 같이 임시 토큰을 전달해주는 방식을 사용한다고 들었고, 많이 복잡해 보였지만 하나씩 해보기로 했습니다.
OAuth2LoginSuccessHandler에서 해당 작업을 진행하도록 했습니다.
코드 설명
임시 토큰은 랜덤한 UUID로 지정했고, Redis를 이용해 5분의 유효기간을 갖고 임시 토큰:액세스토큰 형태의 값을 갖도록 설정한 후 OAuth2 로그인이 성공할 때 Redirect되는 URL에서 임시토큰 파라미터를 추가했습니다.
그리고 임시 토큰을 이용해 Redis에 저장되어 있는 access token값을 가져오는 API를 만들어주었습니다.
코드 설명
Redis에서 임시 토큰 값애 매칭되는 access token값을 가져와 return해줍니다.
한 번 교환하면 프론트엔드는 access token값을 가지게 되고 그 이후 임시 토큰이 계속 있다면 보안이 취약해질 수 있으니 한번 교횐한 임시 토큰과 access token의 매칭값은 Redis에서 삭제합니다.
그럼 프론트엔드에선 아래와 같이 처리를 하여 파라미터 중 temporaryToken이 있다면 accessToken을 받도록 백엔드에게 API를 호출한 후 localStorage에 accessToken을 저장한 후 temporaryToken 파라미터가 사라지도록 다시 redirect하면 프론트엔드는 중간에 accessToken을 유출하는 과정 없이 보안성있게 가져올 수 있었습니다.
3-2. OAuth2 제공자별로 어떻게 로그인을 처리할까?
저희 프로젝트에서 구현한 OAuth2에서 제공자는 카카오, 구글, 네이버로 총 3종류입니다. 각 제공자별로 가져오는 사용자 형태가 다른데 이를 어떻게 처리해야할지 고민했습니다.
3-2-1. Adapter 만들기
엔티티와 비슷하게 각 제공자별로 제공하는 사용자 데이터의 형태에 맞게 getter를 사용할 수 있도록 하는 Adapter 클래스를 만들었습니다.
Google에서는 로그인에 성공하면 아래와 같은 형태로 사용자 데이터를 제공합니다.
그래서 이 JSON 데이터를 java에서 엔티티처럼 사용할 수 있도록 Adapter를 만들어줬습니다.
그리고 OAuth2LoginSuccessHandler에선 로그인을 하고 나면 받은 사용자 데이터별로 Adapter를 생성할 수 있도록 했습니다.
여기서 naver는 getPrincipal().getAttributes()를 한 값에서 response 항목으로 더 들어가야 사용자 정보가 있어서 naver만 google과 kakao와는 다르게 지정했습니다.
3-3. 최종 Handler
최종적으론 아래와 같이 성공했을 때의 Handler에선 Adapter를 생성하고, jwt토큰을 만들고, refresh token을 쿠키에 저장하고, 임시 토큰을 파라미터에 넣어 Redirect해주는 기능을 하도록 했습니다.
코드 설명
기본적으로 Spring Security에서 제공하는 SimpleUrlAuthenticationSuccessHandler 클래스를 extend하여 onAuthenticationSuccess() 함수를 구현함을 통해 로그인을 성공했을 때의 과정을 구현할 수 있었습니다.