shook 서비스에서 인증 기능을 도입하기로 했습니다. 인증 과정이 추가됨에 따라 서비스에 진입 장벽이 생긴다고 팀원들 모두가 생각했습니다. 하지만 저희 서비스에서 신뢰있는 킬링파트 정보를 수집하는 것과 추후에 마음에 드는 킬링파트를 아카이빙 할 수 있는 기능이 필요하다고 판단하여 인증 과정을 추가하기로 했습니다.
인증 방법에 대한 고민이 필요했습니다. 서비스 자체에서 로그인을 만들지 아니면 현재 많은 서비스에서 이용되고 있는 소셜 로그인을 이용할지에 대해 이야기를 팀원들과 나누었고 다음과 같은 이유로 소셜 로그인을 이용하기로 했습니다.
저는 이중에서 사용자가 회원가입 과정을 할 필요가 없다는 점이 OAuth를 도입하는 데 가장 와닿았습니다. 그 이유는 저도 회원가입을 해야하는 서비스를 이용할 때 한 번 제약이 걸리게 되고 괜히 개인정보를 노출하는 기분이 들었기 때문입니다. 그렇기에 이미 안정적인 서비스를 통해서 로그인을 하는 과정이 사용자에게 덜 부담스러운 과정이라고 생각했습니다.
저희는 OAuth 2.0 으로 소셜 로그인을 구현하기로 했고 내부적으로 동작방식은 아래 그림과 같이 구성했습니다.
1,2번의 과정은 프론트엔드 측에서 처리하도록 설계를 했고 3번 이후의 과정에 대해서 백앤드 측에서 처리하도록 구성을 했습니다. 그 이유는 1,2 과정에서 이용되는 Authorization Code의 경우 일회성이고 유효기간이 짧아서 악용할 수 있는 가능성이 적습니다. 하지만 AccessToken과 AccessToken을 통해 발급받는 사용자 Resource는 민감한 정보이며 유출되는 경우 큰 문제가 발생하여 내부적(우리의 어플리케이션과 OAuth 서비스의 백채널)으로 통신해서 가져오도록 설계했습니다.
위의 소셜 로그인 구성도를 살펴보았을 때 백엔드에서 처리해야하는 요구사항에 대해서 정리하면 다음과 같습니다.
위의 요구사항을 구현하는 과정에서 우리 서비스와 OAuth 서비스와의 통신 과정이 필요합니다. 저희는 내부통신을 하기 위해 RestTemplate를 이용했습니다.
OAuthConfig.java
소셜 로그인을 할 때마다 RestTemplate를 계속 생성해서 하는 것 보다는 위의 코드와 같이 bean으로 등록하여 RestTemplate를 재활용하는 방법을 택했습니다. 그 이유는 소셜 로그인을 하는 과정에서 accessToken을 발급받을 때와 accessToken으로 사용자 resource를 발급받을 때 통신이 필요한데 이 과정에서 공통로직이 발생할 뿐만 아니라 필요할 때마다 RestTemplate 객체를 계속 생성해서 이용하는 방법은 메모리 사용에도 좋지 않다고 판단했습니다.
GoogleInfoProvider.java
GoogleInfoProvider에 구글 OAuth 서비스와 통신해서 accessToken이나 사용자 resource를 불러오는 기능을 포함하도록 구성했습니다.
먼저 AccessToken을 발급받는 메소드입니다.
프론트로부터 받은 Authorization code와 accessToken 요청시 필요한 url param를 구성하여 google authorization server로 요청을 보내 accessToken을 발급받습니다.
다음은 사용자 Resource를 발급받는 메소드입니다.
accessToken를 Authorization 해더로 한 httpEntity를 생성하고 이를 google resource server로 요처을 보내서 사용자 resource를 발급받습니다.
앞서서 저희 팀의 소셜 로그인 흐름도를 보면 알 수 있듯이 저희 팀은 인증 과정에서 session 방식이 아닌 토큰을 이용한 방식을 택했습니다. 그 이유는 서버를 scale out하는 과정에서 추가적인 작업이 필요하지 않기 때문이었습니다. 토큰 인증방식은 사용자 인증이 필요한 요청에서 대해서는 토큰을 같이 보내고 서버에서는 토큰을 검증하여 인증이 이루어지는 방식입니다.
💡 JWT 토큰은 어떻게 인증이 되는 것일까?
JWT 토큰의 경우 Header, Payload, Signature로 구성되어 있습니다.
Header의 경우에는 Signature에서 사용되는 알고리즘 방식과 토큰 유형에 대한 정보가 포함되어 있습니다.
Payload에는 토큰에서 사용할 정보의 조각들인 Claim이 담겨져 있습니다.( 유효기간, 발급자, 서버와 클라이언트가 주고받는 시스템에서 실제로 사용될 정보들)
마지막으로 Signature에는 Header와 Payload를 각각 base64로 인코딩한 것에 secert key를 더한 후 Header에서 정의한 알고리즘으로 암호화 한 것입니다.
jwt는 정보보호 목적인 아닌 서명이 목적이기 때문에 payload나 header가 조작 및 위조되면 signature는 자연스럽게 변경이 되어 위조 된 것이 파악됩니다. 따라서 비밀키가 노출되지 않으면 위조나 조작에 대한 정보를 signature를 통해 파악할 수 있습니다.
build.gradle
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
토큰을 다루기 위한 jwt 라이브러리를 이용할 수 있게 denpendency를 먼저 추가해 줍니다.
TokenProvider.java
jwt 토큰을 생성할 때 중요한 것이 토큰에 담기는 내용물입니다. 토큰의 내용물의 경우 https://jwt.io/ 에서 decoding을 하는 경우에 확인이 가능하기 때문에 중요하지 않는 정보이지만 유저를 식별할 수 있는 정보로 하는 것이 중요합니다. 그래서 저희는 유저를 저장할 때 생기는 db id와 닉네임을 토큰 정보로 담아서 처리했습니다.
AuthService.java
위에서 만든 GoogleInfoProvider.java와 TokenProvider.java를 이용해서 소셜로그인 과정이 처리됩니다.
즉 전체적으로 소셜 로그인 요청이 들어왔을 때 처리되는 과정을 도식화 하면 아래의 그림과 같습니다.