Oauth2 를 사용한 소셜로그인 구현 과정
얼마 전 지인의 부탁으로 네이버 밴드 그룹에 접근하여 그룹의 게시글 내용을 스크랩하여 출력하는 어플리케이션을 만들어달라는 요청을 받았습니다. 처음에는 어떻게 구현할지 막막했는데, 네이버 밴드에서 이미 제공하는 오픈 api 들이 많이 있어서 그걸 활용하면 되겠다 생각했습니다. 다만 그룹의 게시글에 접근하기 위해서는 해당 밴드 계정에 대한 권한이 필요하기 때문에, 제 어플리케이션에서 Oauth2 를 통해 밴드 계정에 로그인하는 api 를 구현해야 했습니다. Oauth2는 이전 프로젝트에서 구현한 적이 있지만 그 플로우를 전부 이해하며 하진 못했고, 사이드프로젝트 수준이었기 때문에 돌아가기만 하는 기능이라는 느낌이 강했습니다. 이번에 실제 고객이 사용할 서비스의 소셜 로그인을 구현하면서 Oauth2 의 플로우를 제대로 이해하고, 그 과정을 기록하고자 합니다.
Oauth2 의 인증 흐름
네이버 밴드는 네이버와는 별개로 개발자센터를 지원합니다. 밴드에 로그인하는 Oauth2 를 구현하고 오픈 api 를 사용하려면 우선 밴드 개발자센터에 서비스를 등록하고, client ID 와 client secret, redirect Uri 를 가져와야 합니다. 이 과정은 다른 프로바이더의 개발자 센터에서도 비슷할 것입니다.
개발자 센터에 서비스를 등록했다면 위 그림과 같이 Client ID, Client Secret, Redirect Uri 를 얻을 수 있습니다. 여기서 Redirect Uri 는 개발자가 직접 등록하는건데, 어떤 값으로 등록할 지 모르겠다면 나중에 수정할 수 있으니 일단 아무 값이나 적어도 괜찮습니다. 개발자 센터에서 가져온 Client ID, Client Secret, Redirect Uri 는 application.yml 에 저장하고 사용합니다.
기본적인 OAuth2 의 동작 플로우는 다음과 같습니다.
1. 사용자를 인증 화면으로 리다이렉션
2. 인증화면에서 로그인 후 인증 코드 발급
3. 인증 코드를 가지고 프로바이더 인증 서버에 Access token 요청
4. 발급받은 Access token 으로
1) 리소스 서버에 사용자정보를 요청하여 해당 사용자정보로 내 어플리케이션으로 로그인&회원가입
2) token 이 필요한 api 호출
일반적으로 소셜로그인을 구현한다고 하면 4 에서 1) 번과 같이 내 어플리케이션에 회원가입을 시키거나 로그인을 통해 별도의 토큰을 발급받습니다. 그러나 제 어플리케이션은 사용자 정보를 데이터베이스에 저장할 필요가 없고, 얻은 권한으로 밴드에서 제공하는 오픈 api 를 사용하는 것만이 목적이므로 바로 2)번으로 갑니다. 그렇다면 위 플로우를 구현하는 과정을 하나하나 따라가 보겠습니다.
위 내용을 토대로 어플리케이션을 구현하기 전에, 어떤 객체들이 필요할지, 각 객체의 역할은 어떻게 분배할지에 대해 먼저 정리해 보려고 합니다.
먼저 Oauth2 인증 도메인에서 필요한 객체는 다음과 같습니다.
- BandLoginController(로그인 컨트롤러)
- BandOauth2Client(밴드 Oauth2 클라이언트)
로그인 컨트롤러는 Http 요청을 받고, 밴드 Oauth2 클라이언트에게 액세스 토큰을 요청하는 역할을 합니다. 밴드 Oauth2 클라이언트는 프로바이더(네이버, 구글 등 소셜로그인 제공자)에게 액세스토큰을 요청하고 로그인 컨트롤러에게 이를 반환합니다.일반적으로 소셜로그인을 구현할 때는 하나의 프로바이더만 사용하지 않기 때문에, 로그인 컨트롤러와 로그인 서비스를 두고 로그인 서비스에서 추상화된 Oauth2 클라이언트를 참조하는 것이 바람직합니다. 여러 프로바이더에 대한 확장성 있는 설계를 위함입니다.
그러나 제 어플리케이션은 네이버 밴드 api 에 강하게 종속되어 있고, 다른 프로바이더가 필요하지 않으며 당장은 사용자 정보를 저장할 필요도 없다고 생각하기 때문에 간단하게 컨트롤러, 클라이언트 객체만 정의하기로 했습니다.
1. 사용자를 인증 화면으로 리다이렉션
먼저 로그인 api 와 콜백 api 를 정의할 컨트롤러를 구현합니다.
package com.example.bandscrapapplication.oauth2.controller;
import com.example.bandscrapapplication.oauth2.service.OAuth2LoginService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.view.RedirectView;
import java.util.UUID;
@RestController
@RequestMapping("/oauth2")
public class OAuth2LoginController {
private final OAuth2LoginService oAuth2LoginService;
public OAuth2LoginController(OAuth2LoginService oAuth2LoginService) {
this.oAuth2LoginService = oAuth2LoginService;
}
@Value("${band.client-id}")
private String NAVER_CLIENT_ID;
@Value("{band.redirect-uri}")
private String BAND_REDIRECT_URI;
@GetMapping("/naverband/login")
public RedirectView naverBandLogin (HttpServletRequest request){
String state = UUID.randomUUID().toString();
request.getSession().setAttribute("oauth_state", state);
String bandOauthUrl = "https://auth.band.us/oauth2/authorize" // Oauth2 인증 요청을 위해 기본적으로 리디렉션하는 url -> 프로바이더의 개발자센터에 명시되어 있다
+ "?response_type=code" // Oauth2 인증 플로우 중 Authorization code flow 를 사용하겠다는 명시 -> 응답으로 code 반환
+ "&client_id=" + NAVER_CLIENT_ID // 개발자 센터에 등록한 서비스의 cli-Id -> 어떤 어플리케이션에서 요청했는지 식별하기 위해 사용
+ "&redirect_uri=" + BAND_REDIRECT_URI // 로그인 화면에서 인증 완료 후 인증코드를 전달받을 콜백 uri -> 개발자센터에 등록한 redirection uri 와 동일해야 한다
+ "&state=" + state; // CSRF 공격을 방지하기 위해 생성된 난수 코드.
return new RedirectView(bandOauthUrl);
}
}
naverBandLogin() 메서드는 로그인 화면으로 리다이렉션하며 로그인 후 인증코드를 받을 uri 를 명시합니다. 여기서 호출할 api 의 url 을 직접 구성하는데, 기본적인 url 포맷은 개발자 센터에 명시되어 있습니다. 그러나 url 의 각 요소가 의미하는 바를 몰랐기 때문에, 제가 사용한 bandOauthUrl 의 구성요소를 하나씩 짚어보고 넘어가겠습니다.
이 부분입니다.
1. "https://auth.band.us/oauth2/authorize"
사용자를 인증 화면으로 리디렉션하기 위한 기본 Url입니다. 프로바이더마다 다른 값을 가지고 있으며 개발자 센터에서 확인할 수 있습니다.
2. "?response_type=code"
Oauth2 인증 플로우 중 Authorization code flow 를 사용하겠다는 것을 명시합니다. 응답으로 인증 코드를 반환받으며, 이 인증코드를 사용하여 프로바이더의 인증 서버에 Access Token 을 요청합니다. 이 Access Token 을 사용하여 실제 사용자 정보가 저장돼 있는 리소스 서버로부터 사용자 정보 등을 얻을 수 있습니다.
3. "&client_id=" + {your_client_id}
개발자 센터에 어플리케이션 등록 후 얻었던 클라이언트 ID 를 입력 해 줍니다. 어떤 어플리케이션에서 요청했는지 식별하기 위해 사용됩니다.
4. "&redirect_uri=" + {your_redirect_uri}
리다이렉션 된 로그인 화면에서 로그인을 완료했다면, 이후 콜백될 uri 를 기입합니다. 인증에 성공했다면 인증 코드를 돌려받을 텐데, 이 코드를 넘겨주는 방식이 이 redirect uri 에 해당하는 엔드포인트로 요청을 쏴 주면서 파라미터로 code 값을 넘겨주는 것입니다. 이 값은 맨 처음에 개발자센터에 어플리케이션을 등록할 때 기입했던 Redirect URI 값과 일치해야 합니다. 즉, 서버에게 "반송 주소" 를 알려주는 셈이니 정확하게 입력해야 한다는 이야기입니다.
4. "&state=" + state
난수로 생성된 세션 상태로, 요청에 포함시킬지는 선택 사항입니다. 세션 상태를 요청에 포함시키는 이유는 CSRF 공격을 방지하기 위함으로, Oauth2 공식 스펙에서도 state 값을 사용한 CSRF 방어 방법을 권장하고 있습니다. Spring Security 를 사용한다면 이러한 방어 매커니즘이 자동으로 지원됩니다.
2. 인증 화면에서 로그인 후 인증 코드 발급
이제 작성된 Url 로 요청을 리다이렉트 시키고, 인증 후 인증 코드를 받습니다. 로그인 성공 후 얻은 인증 코드는 앞서 말한 것처럼 콜백 uri 를 통해 전달됩니다. 그러면 이제 이 uri 로 구성된 엔드포인트 api 를 작성할 차례입니다. 로그인 api 의 연장이므로, 이 api 는 프로바이더 인증 서버에 Access Token 을 요청하고 반환하는 작업을 처리하게끔 합니다.
작성한 callback 함수 구현은 다음과 같습니다.
@GetMapping("/naverband/callback")
public Map naverBandCallback (@RequestParam String code, @RequestParam String state, HttpServletRequest request){
String sessionState = (String)request.getSession().getAttribute(SESSION_STATE_KEY);
if(sessionState==null || !sessionState.equals(state)){
throw new IllegalStateException("Invalid Session State");
}
String tokenRequestUrl = "https://auth.band.us/oauth2/token";
RestTemplate restTemplate = new RestTemplate();
// 헤더 설정 -> 요청 본문의 데이터 형식을 지정하는 코드
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
// 토큰 요청 실패 시 헤더에 인코딩 값 추가
String credentials = BAND_CLIENT_ID + ":" + BAND_CLIENT_SECRET;
String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes());
headers.set("Authorization", "Basic " + encodedCredentials);
// 요청 바디
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code");
body.add("code", code);
body.add("client_id", BAND_CLIENT_ID);
body.add("client_secret", BAND_CLIENT_SECRET);
body.add("state", sessionState);
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(body, headers);
ResponseEntity<Map> response = restTemplate.exchange(
tokenRequestUrl,
HttpMethod.POST,
entity,
Map.class
);
return response.getBody();
}
뭔가.. 상당히 지저분하고 정리하고 싶은 코드 같습니다.
거기다 naverBandLogin() 메서드와 naverBandCallback() 메서드에 개발자 센터에 등록된 동일한 값을 사용하는 중복된 코드가 등장하고, 직접적으로 프로바이더와 강하게 연관된 책임이 보이는 것 같습니다. 분리해야 할 것만 같은..
그래서 리팩토링이라고 하기엔 간단하지만, 객체를 분리하는 작업을 해 보겠습니다.
본문의 맥락에서 조금 벗어나는 내용일 수 있어, 객체 분리 과정은 접은글로 넣어 두었으니 궁금하신 분들만 펼쳐 주시면 될 것 같습니다.
초반에 사용되는 객체를 정리할 때 BandLoginController 말고도 BandOauth2Client 라는 객체도 등장했던 것 기억하시나요?
BandOauth2Client 는 네이버 밴드라는 프로바이더에게 직접 요청하고 그에 필요한 값들을 관리하는 객체입니다. 이 객체에는 프로바이더에 강하게 종속된 데이터들을 관리하고, 이를 사용해 프로바이더와 직접 컨택하거나 리디렉션 Url 을 생성하는 책임을 부여합니다. BandLoginController 는 구체적인 일은 BandOauth2Client 에게 부탁하면 됩니다.
또, 각 메서드에 session state 를 설정하거나 검증하는 코드가 보입니다. 이 코드는 CSRF 공격을 방어하기 위한 작업으로, 로그인 과정에 필요한 작업이기는 하지만 큰 흐름에서는 깊이 연관되어 있지 않다고 생각됩니다.
따라서 이 책임을 맡아 줄 세션 상태 관리자 객체, SessionStateManager 를 만들기로 하였습니다. 세션 상태를 관리하는 일은 이 객체에게 부탁하면 될 것 같습니다.그렇게 해서 세션 관련 작업은 SessionStateManager 로 분리하고, 프로바이더에 강하게 결합된 데이터와 작업들은 BandOauth2Client 가 가져가도록 수정하였습니다. 수정한 코드는 다음과 같습니다.
public class SessionStateManager {
private static final String SESSION_STATE_KEY = "session_state";
public String setSessionState(HttpServletRequest request){
String state = UUID.randomUUID().toString();
request.getSession().setAttribute(SESSION_STATE_KEY, state);
return state;
}
public void sessionValidate(String state, HttpServletRequest request){
String sessionState = (String)request.getSession().getAttribute(SESSION_STATE_KEY);
if(sessionState==null || !sessionState.equals(state)){
throw new IllegalStateException("Invalid Session State");
}
}
public Object getSessionState(HttpServletRequest request){
return request.getSession().getAttribute(SESSION_STATE_KEY);
}
}
public class BandOauth2Client {
@Value("${band.client-id}")
private String BAND_CLIENT_ID;
@Value("${band.client-secret}")
private String BAND_CLIENT_SECRET;
@Value("${band.redirect-uri}")
private String BAND_REDIRECT_URI;
private String accessToken;
public String getAccessToken() {
return accessToken;
}
public RedirectView redirectLoginView(String state){
String bandOauthUrl = "https://auth.band.us/oauth2/authorize" // Oauth2 인증 요청을 위해 기본적으로 리디렉션하는 url -> 프로바이더의 개발자센터에 명시되어 있다
+ "?response_type=code" // Oauth2 인증 플로우 중 Authorization code flow 를 사용하겠다는 명시 -> 응답으로 code 반환
+ "&client_id=" + BAND_CLIENT_ID // 개발자 센터에 등록한 서비스의 cli-Id -> 어떤 어플리케이션에서 요청했는지 식별하기 위해 사용
+ "&redirect_uri=" + BAND_REDIRECT_URI // 로그인 화면에서 인증 완료 후 인증코드를 전달받을 콜백 uri -> 개발자센터에 등록한 redirection uri 와 동일해야 한다
+ "&state=" + state; // CSRF 공격을 방지하기 위해 생성된 난수 코드.
return new RedirectView(bandOauthUrl);
}
public RedirectView redirectLoginView(){
String bandOauthUrl = "https://auth.band.us/oauth2/authorize" // Oauth2 인증 요청을 위해 기본적으로 리디렉션하는 url -> 프로바이더의 개발자센터에 명시되어 있다
+ "?response_type=code" // Oauth2 인증 플로우 중 Authorization code flow 를 사용하겠다는 명시 -> 응답으로 code 반환
+ "&client_id=" + BAND_CLIENT_ID // 개발자 센터에 등록한 서비스의 cli-Id -> 어떤 어플리케이션에서 요청했는지 식별하기 위해 사용
+ "&redirect_uri=" + BAND_REDIRECT_URI; // 로그인 화면에서 인증 완료 후 인증코드를 전달받을 콜백 uri -> 개발자센터에 등록한 redirection uri 와 동일해야 한다
return new RedirectView(bandOauthUrl);
}
public Map requestAccessToken(String code, String sessionState){
String tokenRequestUrl = "https://auth.band.us/oauth2/token";
RestTemplate restTemplate = new RestTemplate();
// 헤더 설정 -> 요청 본문의 데이터 형식을 지정하는 코드
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
// 토큰 요청 실패 시 헤더에 인코딩 값 추가
String credentials = BAND_CLIENT_ID + ":" + BAND_CLIENT_SECRET;
String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes());
headers.set("Authorization", "Basic " + encodedCredentials);
// 요청 바디
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code");
body.add("code", code);
body.add("client_id", BAND_CLIENT_ID);
body.add("client_secret", BAND_CLIENT_SECRET);
body.add("state", sessionState);
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(body, headers);
ResponseEntity<Map> response = restTemplate.exchange(
tokenRequestUrl,
HttpMethod.POST,
entity,
Map.class
);
accessToken = (String)response.getBody().get("access_token");
return response.getBody();
}
BandOauth2Client 객체는 여전히 보기에 조금 장황한 느낌이 있긴 한 것 같습니다만, 가독성만을 위해 이 이상으로 추출하는 것은 불필요하다고 판단됩니다. 주석은 나중에 지워야 하겠으나 작업하는 동안은 이해를 돕기 위해 놔두었습니다.
다음은 클라이언트 쪽 코드입니다. 객체를 분리함으로써 훨씬 깔끔해진 BandLoginController 입니다.
@RestController
@RequestMapping("/oauth2")
public class BandLoginController {
private final BandOauth2Client bandOauth2Client;
private final SessionStateManager sessionStateManager;
public BandLoginController(BandOauth2Client bandOauth2Client, SessionStateManager sessionStateManager) {
this.bandOauth2Client = bandOauth2Client;
this.sessionStateManager = sessionStateManager;
}
@GetMapping("/naverband/login")
public RedirectView naverBandLogin (HttpServletRequest request){
String state = sessionStateManager.setSessionState(request);
return bandOauth2Client.redirectLoginView(state);
}
@GetMapping("/naverband/callback")
public Map naverBandCallback (@RequestParam String code, @RequestParam String state, HttpServletRequest request){
sessionStateManager.sessionValidate(state, request);
return bandOauth2Client.requestAccessToken(code, (String)sessionStateManager.getSessionState(request));
}
}
가독성 측면에서, 각 메서드가 어떤 일을 하는지 한 눈에 파악할 수 있게 되었습니다. 굳이 눌러서 들어가보지 않아도 알 수 있도록, 책임을 적절한 객체로 분배하고 클래스와 메서드가 이름으로서 의도를 드러내도록 고민한 결과입니다.
그러나 객체를 분리함으로써 얻는 진정한 장점은 가독성이 다가 아닙니다.
요청을 매핑하여 받아들이고 결과값을 리턴하는 컨트롤러와 "프로바이더에게 토큰을 요청한다"는 책임을 가진 객체를 분리한다는 것, 그 자체가 큰 의미를 가지고 있습니다. 가령 프로바이더와 접촉하는 객체의 내부 구현이 변한다거나 프로바이더 자체가 바뀐다는 요구사항이 있을 때, 그 책임을 가진 쪽의 객체만 수정함으로써 변경에 드는 비용을 줄일 수 있습니다.
여기서도 끝이 아닌 객체지향의 진정한 가치를 더 끌어내려면 의존 객체(BandOauth2Client) 를 추상화하고 인터페이스로 분리하는 작업을 거쳐야 합니다. 여기서 인터페이스를 도입할지에 대해 고민이 생겼습니다. 인터페이스를 추출하는 것에 대한 장점은 분명하지만, 이 "밴드 스크래퍼" 어플리케이션은 네이버 밴드라는 프로바이더에 강하게 종속되어 있으며 변경의 여지가 없기 때문입니다.
인터페이스 도입에 대한 고민
인터페이스는 객체지향을 객체지향답게 만들어 주는 강력한 도구라고 생각합니다. 인터페이스에 대한 장점은 명확합니다.
의존 대상에 대한 결합도가 낮아진다.
추상적인 말이지만 여기에서 여러 장점이 파생됩니다.
1. 의존 대상의 변경을 전파받지 않는다.
2. 유연하고 확장성 있는 설계가 가능하다.
3. 테스트하기 좋은 코드가 된다.
인터페이스로서 얻을 수 있는 가치는 정말 다양한 얘기들이 있지만, 결국 위의 특성들로부터 파생되며 돌고 도는 이야기라고 생각합니다. 유연하고 확장성 있는 설계가 가능한 건 의존 대상의 변경을 전파받지 않기 때문이고, 이것은 의존 대상에 대한 낮은 결합도를 유지하고 있기 때문인 것처럼 말입니다.
위에서 나열한 장점처럼 인터페이스를 도입하여 얻을 수 있는 장점은 다양합니다. 콕 짚어서 "구현체로부터 자유롭다" 라는 것이 인터페이스 도입의 장점이자 이유가 되겠습니다.
그러나 현재 개발중인 "밴드 스크래퍼" 어플리케이션은 구현체로부터 자유롭지 못합니다. 인터페이스를 도입한다고 해도 그렇습니다. 프로젝트 자체가 네이버 밴드라는 프로바이더에 강하게 종속되어 있고, 다른 구현체로 변경될 일도 없습니다. 어차피 하나의 구현체만 사용한다는 것입니다. 따라서 구현체로부터 자유로워짐으로써 얻는 1, 2번 장점의 의미가 크게 퇴색됩니다.
그러나 테스트가 용이해진다는 장점은 유효합니다. 운영이나 개발환경에서 정해진 구현체만 사용하더라도, 테스트 환경에서 대역으로 대체하려면 인터페이스가 필요합니다. 확장성에 대한 장점은 미미할지라도 리팩토링과 테스트는 계속 진행할 것이기 때문에, BandOauth2Client 객체의 인터페이스를 작성하기로 했습니다.
다음은 인터페이스를 의존하는 BandLoginController 입니다.
@RestController
@RequestMapping("/oauth2")
public class BandLoginController {
private final Oauth2Client oauth2Client;
private final SessionStateManager sessionStateManager;
public BandLoginController(BandOauth2Client bandOauth2Client, SessionStateManager sessionStateManager) {
this.oauth2Client = bandOauth2Client;
this.sessionStateManager = sessionStateManager;
}
@GetMapping("/naverband/login")
public RedirectView naverBandLogin (HttpServletRequest request){
String state = sessionStateManager.setSessionState(request);
return oauth2Client.redirectLoginView(state);
}
@GetMapping("/naverband/callback")
public Map naverBandCallback (@RequestParam String code, @RequestParam String state, HttpServletRequest request){
sessionStateManager.sessionValidate(state, request);
return oauth2Client.requestAccessToken(code, sessionStateManager.getSessionState(request));
}
}
해서 객체 분리와 약간의 리팩토링을 거친 코드입니다.
@RestController
@RequestMapping("/oauth2")
public class BandLoginController {
private final Oauth2Client oauth2Client;
private final SessionStateManager sessionStateManager;
public BandLoginController(BandOauth2Client bandOauth2Client, SessionStateManager sessionStateManager) {
this.oauth2Client = bandOauth2Client;
this.sessionStateManager = sessionStateManager;
}
@GetMapping("/naverband/login")
public RedirectView naverBandLogin (HttpServletRequest request){
String state = sessionStateManager.setSessionState(request);
return oauth2Client.redirectLoginView(state);
}
@GetMapping("/naverband/callback")
public Map naverBandCallback (@RequestParam String code, @RequestParam String state, HttpServletRequest request){
sessionStateManager.sessionValidate(state, request);
return oauth2Client.requestAccessToken(code, sessionStateManager.getSessionState(request));
}
}
public class BandOauth2Client implements Oauth2Client{
@Value("${band.client-id}")
private String BAND_CLIENT_ID;
@Value("${band.client-secret}")
private String BAND_CLIENT_SECRET;
@Value("${band.redirect-uri}")
private String BAND_REDIRECT_URI;
private String accessToken;
public String getAccessToken() {
return accessToken;
}
public RedirectView redirectLoginView(String state){
String bandOauthUrl = "https://auth.band.us/oauth2/authorize" // Oauth2 인증 요청을 위해 기본적으로 리디렉션하는 url -> 프로바이더의 개발자센터에 명시되어 있다
+ "?response_type=code" // Oauth2 인증 플로우 중 Authorization code flow 를 사용하겠다는 명시 -> 응답으로 code 반환
+ "&client_id=" + BAND_CLIENT_ID // 개발자 센터에 등록한 서비스의 cli-Id -> 어떤 어플리케이션에서 요청했는지 식별하기 위해 사용
+ "&redirect_uri=" + BAND_REDIRECT_URI // 로그인 화면에서 인증 완료 후 인증코드를 전달받을 콜백 uri -> 개발자센터에 등록한 redirection uri 와 동일해야 한다
+ "&state=" + state; // CSRF 공격을 방지하기 위해 생성된 난수 코드.
return new RedirectView(bandOauthUrl);
}
public RedirectView redirectLoginView(){
String bandOauthUrl = "https://auth.band.us/oauth2/authorize" // Oauth2 인증 요청을 위해 기본적으로 리디렉션하는 url -> 프로바이더의 개발자센터에 명시되어 있다
+ "?response_type=code" // Oauth2 인증 플로우 중 Authorization code flow 를 사용하겠다는 명시 -> 응답으로 code 반환
+ "&client_id=" + BAND_CLIENT_ID // 개발자 센터에 등록한 서비스의 cli-Id -> 어떤 어플리케이션에서 요청했는지 식별하기 위해 사용
+ "&redirect_uri=" + BAND_REDIRECT_URI; // 로그인 화면에서 인증 완료 후 인증코드를 전달받을 콜백 uri -> 개발자센터에 등록한 redirection uri 와 동일해야 한다
return new RedirectView(bandOauthUrl);
}
public Map requestAccessToken(String code, String sessionState){
String tokenRequestUrl = "https://auth.band.us/oauth2/token";
RestTemplate restTemplate = new RestTemplate();
// 헤더 설정 -> 요청 본문의 데이터 형식을 지정하는 코드
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
// 토큰 요청 실패 시 헤더에 인코딩 값 추가
String credentials = BAND_CLIENT_ID + ":" + BAND_CLIENT_SECRET;
String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes());
headers.set("Authorization", "Basic " + encodedCredentials);
// 요청 바디
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code");
body.add("code", code);
body.add("client_id", BAND_CLIENT_ID);
body.add("client_secret", BAND_CLIENT_SECRET);
body.add("state", sessionState);
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(body, headers);
ResponseEntity<Map> response = restTemplate.exchange(
tokenRequestUrl,
HttpMethod.POST,
entity,
Map.class
);
accessToken = (String)response.getBody().get("access_token");
return response.getBody();
}
public class SessionStateManagerImpl implements SessionStateManager{
private static final String SESSION_STATE_KEY = "session_state";
public String setSessionState(HttpServletRequest request){
String state = UUID.randomUUID().toString();
request.getSession().setAttribute(SESSION_STATE_KEY, state);
return state;
}
public void sessionValidate(String state, HttpServletRequest request){
String sessionState = (String)request.getSession().getAttribute(SESSION_STATE_KEY);
if(sessionState==null || !sessionState.equals(state)){
throw new IllegalStateException("Invalid Session State");
}
}
public Object getSessionState(HttpServletRequest request){
return request.getSession().getAttribute(SESSION_STATE_KEY);
}
}
3. 인증 코드를 가지고 프로바이더 인증 서버에 Access token 요청
public class BandOauth2Client implements Oauth2Client{
@Value("${band.client-id}")
private String BAND_CLIENT_ID;
@Value("${band.client-secret}")
private String BAND_CLIENT_SECRET;
@Value("${band.redirect-uri}")
private String BAND_REDIRECT_URI;
private String accessToken;
public String getAccessToken() {
return accessToken;
}
public Map requestAccessToken(String code, String sessionState){
String tokenRequestUrl = "https://auth.band.us/oauth2/token";
RestTemplate restTemplate = new RestTemplate();
// 헤더 설정 -> 요청 본문의 데이터 형식을 지정하는 코드
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
// 토큰 요청 실패 시 헤더에 인코딩 값 추가
String credentials = BAND_CLIENT_ID + ":" + BAND_CLIENT_SECRET;
String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes());
headers.set("Authorization", "Basic " + encodedCredentials);
// 요청 바디
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code");
body.add("code", code);
body.add("client_id", BAND_CLIENT_ID);
body.add("client_secret", BAND_CLIENT_SECRET);
body.add("state", sessionState);
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(body, headers);
ResponseEntity<Map> response = restTemplate.exchange(
tokenRequestUrl,
HttpMethod.POST,
entity,
Map.class
);
accessToken = (String)response.getBody().get("access_token");
return response.getBody();
}
}
객체로 분리된 BandOauth2Client, 그 중에서도 인증 서버에 Access Token 을 요청하는 코드를 다시 가져왔습니다. 서버로의 요청은 RestTemplate 을 사용하였고, 그 외에는 헤더, 바디로 요청을 구성하는 코드입니다.
헤더 구성 부분을 보면, 아래와 같이 content type 을 set 하는 코드가 있습니다.
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
위 코드는 본문 형식을 x-www-form-urlencoded 타입으로 설정하는 코드입니다.
이게 무슨 의미이며 헤더에 이런 설정이 왜 필요할까요?
요청을 보낼 때, 본문 형식을 다양하게 지정할 수 있습니다. http 요청에서 많이들 사용하는 json 형식은 아래와 같이 본문이 구성됩니다.
{
"key1" : "value1",
"key2" : "value2"
}
본문에 담을 데이터가 복잡하거나 크기가 클 경우, 위처럼 json 이나 mulpipart/form-data 등의 형식으로 전송하게 됩니다.
그러나 OAuth2.0 의 표준 스펙으로 AccessToken 을 받는 요청에는 본문 형식을 x-www-form-urlencoded 타입으로 지정해야 합니다. x-www-form-urlencoded 타입은 이름에서 알 수 있는 것처럼 본문의 데이터를 url 인코딩 후 웹 서버에 전송하는 방식입니다. 인코딩이 들어가기 때문에 대량의 데이터를 전송하기엔 적합하지 않은 방식이며,
key1=value1& key2=value2
타입으로 데이터가 넘어갑니다. OAuth2.0 에서는 AccessToken 을 요청할 때 위 포맷으로 요청할 것을 명시하고 있기 때문에 이러한 본문 형식 지정이 필요합니다.
다음은 요청 본문을 구성하는 코드입니다.
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code");
body.add("code", code);
body.add("client_id", BAND_CLIENT_ID);
body.add("client_secret", BAND_CLIENT_SECRET);
body.add("state", sessionState);
본문의 구성 요소를 하나하나 살펴보겠습니다.
- grant_type, code
요청 본문을 구성할 때는 grant_type 과 code 를 반드시 포함시켜 줍니다. grant_type 은 "authorization_code" 로 명시해 줍니다. 로그인 후 발급받은 인증 코드를 사용하여 Access token 을 요청하겠다는 것을 의미합니다. 또한 code 를 주고 token 을 받아오는 것이기때문에, token 발급을 위해 code 도 마찬가지로 본문에 포함시킵니다. - client_id, client_secret
client_id 와 client_secret 은 선택 사항입니다. 보통 본문에 포함시켜서 요청하기도 하지만, band 는 별도로 헤더에 포함시켜야 합니다(이것때문에 삽질을 좀 했습니다..). 다른 프로바이더를 사용할 경우에는, header 에 포함되어 있지 않다면 본문에는 반드시 포함시켜야 합니다. - state
state 는 CSRF 공격 방지를 위해 사용한다고 했습니다. 선택 사항입니다.
요청을 보낼 때는 RequestEntity 타입의 요청 객체를 만들고, restTemplate 를 사용하여 Http 요청을 전송합니다. 받아온 Access token 은 다시 사용하기 위해 필드에 저장합니다.
Access token 발급 요청에서 문제 발생
구현 후 AccessToken 을 얻을 수 있는지 테스트로 요청을 보내보다가 작은 문제가 발생하였습니다.
문제 상황은 다음과 같습니다.
1. 로그인 요청을 보내면 밴드 로그인 화면으로 리디렉션돼야 하는데 리디렉션 없이 화이트라벨 에러 발생
2. 정상적으로 로그인 후에도 콜백함수 동작 부분에서 화이트라벨 에러 발생
1. 로그인 요청을 보내면 밴드 로그인 화면으로 리디렉션돼야 하는데 리디렉션 없이 화이트라벨 에러 발생
localhost 주소로 밴드 로그인 요청을 보냈을 때, 정상적인 동작이라면 네이버 밴드 로그인화면으로 리다이렉션 되어야 합니다. 그러나 그런 동작 없이 화이트라벨 에러가 발생했습니다.
원인을 찾아보면서 화이트라벨 에러 화면의 로그를 확인하던 중, 리다이렉션 다음 플로우인 callback 함수가 호출된 것을 확인했습니다. 응답을 분석해 보니, code 에 값이 들어가 있는 것을 발견했습니다.
따라서 로그인 페이지로 리다이렉션 되기 전에 이미 인증(네이버 계정으로 로그인) 이 되어 있어, 로그인 세션이 남아 있는 것이 문제임을 알 수 있었습니다. 이를 확인하기 위해 시크릿모드에서 요청을 시도하였고, 정상적으로 밴드 로그인 페이지로 리디렉션됨을 확인하였습니다.
2. 정상적으로 로그인 후에도 콜백함수 동작 부분에서 화이트라벨 에러 발생
그러나 정상적으로 로그인 후, 새로운 인증코드를 발급받았음에도 동일한 화이트라벨 에러가 발생하였습니다. 문제는 client ID 와 client secret 이 정상적으로 전달되지 않은 것이었는데요,
BandOAuth2Client 에는 이미 client ID와 client secret 을 본문으로 요청하는 코드가 작성되어 있었습니다.
그러나 네이버 밴드 개발자센터에는, client ID : client secret 값을 base64로 인코딩한 값을 반드시 헤더에 포함시켜야 한다고 명시되어 있습니다. 본문으로 요청하는 것은 의미가 없었던 것입니다.
String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes());
headers.set("Authorization", "Basic " + encodedCredentials);
따라서 위와 같이 헤더에 client ID 값와 client secret 값을 인코딩하여 포함시켜 주었습니다. 이후 토큰 요청을 보내 보니, 아래와 같이 정상적으로 토큰이 발급됨을 확인할 수 있었습니다.
대부분의 어플리케이션에서는 이렇게 발급받은 토큰을 사용하여 리소스 서버에 사용자 정보를 요청하고, 이를 저장하여 회원가입을 하는 것까지 소셜 로그인의 범위입니다. 그러나 현재 서비스의 요구사항에서 회원가입까지는 필요하지 않으므로, 여기에서 소셜로그인 구현에 대한 기록을 마치려고 합니다.
Oauth2 를 사용하여 소셜로그인을 구현한 경험이 이미 있었지만, 다시 하려고 했을 때 어떤 흐름으로 무엇부터 해야 하는지 몰랐고 돌아보니 껍데기 뿐인 경험이라고 느꼈습니다. 그래서 소셜로그인이라는 작은 부분이지만 플로우를 완전히 이해하고 내 것으로 만들기 위해 노력했고, 한줄 한줄이 어떤 의미를 가지는지 알기 위해 조금 더 파고들었습니다. 그리고 이 모든 것을 기록하는 과정에서 한번 더 제 것이 되었다고 느꼈습니다. 코드가 동작하는 것으로 만족한다면 절대 제 것이 될 수 없는, 껍데기뿐인 경험이라는 것을 다시 되새깁니다.