[Spring] V1. OAuth2.0으로 소셜로그인 구현하기
소셜 로그인에서 사용되는 프로토콜인 OAuth에 대해 학습하고 Spring에서 구현해보자 ❕
1. OAuth (Open Authorization) ?
많은 사이트에서 소셜 계정을 기반으로 회원가입 & 로그인을 할 수 있는 기능이 있다. 소셜 로그인을 이용하면 번거롭게 개인 정보를 입력하고 회원가입을 할 필요가 없기 때문에 간편하게 로그인할 수 있다. 게다가 연동되는 웹 어플리케이션에서 제공하는 기능을 사용할 수도 있다.
이 때 사용되는 프로콜이 OAuth이다. OAuth는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 어플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는 접근 위임(Delegated Authorization)을 위한 표준 프로토콜이다.
OAuth 2.0은 OAuth 1.0의 구현이 복잡하다는 단점이 보완된 버전이며, 요즘 대부분의 서비스에서는 OAuth 2.0을 사용한다.
둘의 차이는 다음과 같다.
- 보안 방식
- OAuth 1.0은 서명된 요청을 사용해 보안 인증하고
- OAuth 2.0은 HTTPS를 통해 통신하며 Access Token을 이용해 보안 인증한다.
- 토큰 종류
- OAuth 1.0은 Request Token & Access Token을 사용하고
- OAuth 2.0은 Access Token & Refresh Token 을 사용한다.
- 토큰 갱신
- OAuth 1.0은 Access Token의 유효기간이 없고 갱신하는 것이 복잡했는데
- OAuth 2.0은 Refresh Token을 이용해 Access Token을 쉽게 갱신할 수 있다.
2. OAuth 2.0 주체
- Authorization Server (인가 서버)
- 권한을 부여해주는 서버
- Resource Owner는 이 서버로 id, pw를 넘겨 Authorization Code를 발급 받는다.
- Client는 이 서버로 Authorization Code를 넘겨 Token을 발급 받는다.
- Resource Server (API 서버)
- 사용자의 개인정보를 가지고 있는 애플리케이션(naver, kakao, google 등)의 서버
- Client는 Authorization Server에서 발급받은 Token을 이 서버로 넘겨 사용자 정보를 응답 받을 수 있다.
- Resource Owner (사용자)
- 웹 서비스를 이용하려는 유저, 사용자
- Client (서비스 서버)
- 내가 구현하는 애플리케이션 서버 (이 서버가 Resource Server 입장에서는 Client이므로)
3. 흐름
인증 방식에는 Password Grant, Client Credentials Grant, Authorization Code Grant, Implicit Grant 등 다양한 방법이 있다. 가장 대중적인 Authorization Code Grant 방식에 대해 정리해보려고 한다.
- 사용자(Resource Owner)가 서비스(Client)의 로그인 페이지에 접근해 소셜 로그인 버튼을 누르면 해당 플랫폼의 인가 서버(Authorization Server)에게 로그인 페이지를 요청한다.
- 인가 서버에서 사용자의 화면을 로그인 페이지로 redirect시킨다.
- 사용자가 id, pw를 입력해 로그인한다.
- 로그인 정보가 올바르다면 인가 서버는 인가 코드(Authorization code)를 Callback URL을 통해 사용자에게 전달한다.
- 사용자는 발급된 인가 코드를 그대로 Client에게 보낸다.
- Client는 인가 서버에게 인가 코드와 Client정보를 함께 보낸다.
- 인가 서버는 Client가 보낸 정보를 확인하고, 토큰(Access Token)을 발급한다.
- 토큰을 받은 Client는 리소스 서버(Resource Server)를 통해 사용자 정보를 조회하고, 조회된 결과를 이용해 Client 서버에서 회원가입 처리(DB에 저장) 후 로그인이 완료되었다고 응답한다.
(이 때 Client DB에 저장되는 사용자의 정보는 pw를 포함하지 않으며 권한 설정으로 허락한 정보를 저장한다)
(4.에서 바로 Callback URL을 Client(내 서버)주소로 설정할 수 있는데, 이번에는 사용자에게 전달한 후 인가 코드를 Client에게 전달하는 방식으로 구현했다)
(Callback URL을 Client로 설정한 구현 방법은 다음 게시물에 정리했다)
V2. OAuth2.0으로 소셜로그인 구현하기 (spring-boot-starter-oauth2-client 사용)
OAuth 2.0으로 소셜로그인 구현하기 Version2 ❕ 소셜 로그인을 구현하기 위해 크게 1. 인가 요청 2. 토큰 요청 3. 사용자 정보 요청을 하는 과정이 필요하다. 지난번 소셜로그인을 구현했을 때는 인가
chchaego.tistory.com
4. 구현 - 네이버
네이버 로그인 API 명세 : https://developers.naver.com/products/login/api/api.md
애플리케이션 등록
먼저 소셜로그인 기능을 사용하려면 해당 서비스에서 내 애플리케이션을 등록해줘야 한다. 권한(scope) 설정과 서비스 URL, Callback URL을 설정해준다.
등록이 완료되면 Client ID와 Client Secret이 발급된다.
^Callback URL은 Client와 Resource Server 사이에 Authorization code를 전달하는 통로다.^ Resource Server는 Callback URL이 아닌 다른 URL에서 온 Authorization code는 믿지 않는다.
네이버 로그인 인증 요청 및 접근 토큰 발급 요청 시 필수 파라미터 값인 response_type
, client_id
, redirect_uri
, state
를 포함해 요청해야 한다.
Client 구현
1. 사용자(Resource Owner)가 서비스(Client)의 로그인 페이지에 접근해 소셜 로그인 버튼을 누르면 해당 플랫폼의 인가 서버(Authorization Server)에게 로그인 페이지를 요청한다.
NAVER_AUTH_PARAMS
에 필수 파라미터 값들을 포함시켜 네이버 로그인 페이지를 요청했다.- redirect uri에
registrationId=naver
을 파라미터로 넣어준 이유는 다른 소셜 로그인과 구분하기 위한 것이다.
/**
* NAVER Social Login
*
* Required Variable : response_type, client_id, redirect_uri, state
*/
const NAVER_CLIENT_ID = {CLIENT_ID};
const NAVER_AUTH_URL = "https://nid.naver.com/oauth2.0/authorize";
const NAVER_AUTH_PARAMS = `?client_id=${NAVER_CLIENT_ID}
&redirect_uri=http://localhost:3000/home?registrationId=naver
&response_type=code
&state=hihi`;
const SocialLoginComponent = (props) => {
// 네이버 로그인 버튼
return (
<div className='social-button'>
<a href={NAVER_AUTH_URL + NAVER_AUTH_PARAMS} target='_blank'>
<img src="/assets/naver_login.png" width='40' />
</a>
</div>
)
}
2. 인가 서버(Authorization Server)에서 사용자의 화면을 로그인 페이지로 redirect시킨다.
3. 사용자가 id, pw를 입력해 로그인한다.
4. 로그인 정보가 올바르다면 인가 서버는 인가 코드(Authorization code)를 Callback URL을 통해 사용자에게 전달한다.
- 로그인이 완료되면 파라미터에 인가코드(code)가 담겨 Callback URL로 이동된다.
5. 사용자는 발급된 인가 코드를 그대로 Client에게 보낸다
__reqSocialLogin()
를 실행해 Client에게 `registrationId`(제공 업체)와 `code`(인가 코드)를 전달한다.
/**
* NAVER Social Login
*
* Required Variable : response_type, client_id, redirect_uri, state
*/
const NAVER_CLIENT_ID = {CLIENT_ID};
const NAVER_AUTH_URL = "https://nid.naver.com/oauth2.0/authorize";
const NAVER_AUTH_PARAMS = `?client_id=${NAVER_CLIENT_ID}
&redirect_uri=http://localhost:3000/home?registrationId=naver
&response_type=code
&state=hihi`;
const SocialLoginComponent = (props) => {
const navigate = useNavigate();
const location = useLocation();
const query = qs.parse(location.search);
// 인가 코드를 그대로 Client에게 보낸다
useEffect(() => {
async function socialLoginAuth(registrationId, code) {
navigate(qs.stringifyUrl({
url: location.pathname,
query: ''
}),{
replace: true
});
await __reqSocialLogin(registrationId, code);
}
if(!query || !query.registrationId || !query.code) {
return;
}
socialLoginAuth(query.registrationId, query.code)
}, []);
// 네이버 로그인 버튼
return (
<div className='social-button'>
<a href={NAVER_AUTH_URL + NAVER_AUTH_PARAMS} target='_blank'>
<img src="/assets/naver_login.png" width='40' />
</a>
</div>
)
}
이제 Client(내가 구현하는 애플리케이션 서버)에서 구현하는 일만 남았다.
Server 구현
먼저 의존성을 추가해준다.
implementation 'org.springframework.boot:spring-security-oauth2-client'
spring-security-ouath2-client
는 Spring Security에서 OAuth2.0 프로토콜을 사용해 인증처리를 하는 Client에게 편리한 기능을 제공한다.
(oauth2-client는 기본적으로 인가 요청, 토큰 요청, 사용자 정보 요청을 모두 Client가 담당하게 할 수 있다. 하지만 이번에는 Callback URL을 사용자에게 전달한 후 인가 요청의 결과로 받은 인가 코드를 Client에게 전달하는 방식으로 구현했다)
properties파일에 환경변수를 설정해준다.
# NAVER SOCIAL LOGIN SETTING
spring.security.oauth2.client.registration.naver.client-id={NAVER_CLIENT_ID}
spring.security.oauth2.client.registration.naver.client-secret={NAVER_CLIENT_SECRET}
spring.security.oauth2.client.registration.naver.scope=name,email,profile_image
spring.security.oauth2.client.registration.naver.client-name=Naver
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.redirect-uri=http://localhost:3000/home
spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user-name-attribute=response
속성 | 설명 |
spring.security.oauth2.client.registration.{registrationId}.client-id | 등록된 Client의 Client ID |
spring.security.oauth2.client.registration.{registrationId}.client-secret | 등록된 Client의 Client Secret |
spring.security.oauth2.client.registration.{registrationId}.scope | Client가 요청하는 권한 범위 |
spring.security.oauth2.client.registration.{registrationId}.authorization-grant-type | 인증 방식 |
spring.security.oauth2.client.registration.{registrationId}.redirect-uri | Redirect URI |
spring.security.oauth2.client.provider.{registrationId}.authorization-uri | 인증 요청을 위한 URI |
spring.security.oauth2.client.provider.{registrationId}.token-uri | 토큰 요청을 위한 URI |
spring.security.oauth2.client.provider.{registrationId}.user-info-uri | 사용자 정보 요청을 위한 URI |
spring.security.oauth2.client.provider.{registrationId}.user-name-attribute | 사용자 정보 요청 결과의 key값 |
이 외에도 더 많은 속성들을 설정해두고 사용할 수 있다.
6. Client는 인가 서버(Authorization Server)에게 인가 코드와 Client정보를 함께 보낸다.
7. 인가 서버는 Client가 보낸 정보를 확인하고, 토큰(Access Token)을 발급한다.
@RestController
@RequestMapping("/api/social-login")
@RequiredArgsConstructor
public class SocialLoginController {
private final SocialLoginService socialLoginService;
@PostMapping("/{registrationId}")
public ResponseEntity<?> socialLogin(HttpServletResponse response, @PathVariable(name = "registrationId") String registrationId, @RequestParam String code) {
Message message = new Message();
try {
socialLoginService.socialLogin(response, registrationId, code);
message.setStatus(HttpStatus.OK);
message.setMessage("success");
} catch (Exception e) {
message.setStatus(HttpStatus.BAD_REQUEST);
message.setMessage("error");
message.setMemo(e.getMessage());
}
return new ResponseEntity<>(message, message.getStatus());
}
}
@Service
@RequiredArgsConstructor
public class SocialLoginService {
private final ClientRegistrationRepository clientRegistrationRepository;
public void socialLogin(HttpServletResponse response, String registrationId, String code) throws IOException {
// 넘어온 registrationId 값을 이용해 Provider 조회
ClientRegistration clientRegistration = clientRegistrationRepository.findByRegistrationId(registrationId);
// 해당 provider의 token 요청 uri 조회
String tokenRequestUri = clientRegistration.getProviderDetails().getTokenUri();
// grant_type, client_id, client_secret, code, state 파라미터 설정
MultiValueMap<String, String> parameter = new LinkedMultiValueMap<>();
parameter.add("grant_type", "authorization_code");
parameter.add("client_id", clientRegistration.getClientId());
parameter.add("client_secret", clientRegistration.getClientSecret());
parameter.add("code", code);
parameter.add("state", "1234");
// token 요청
OAuthResponse.RequestToken tokenResponse = WebClient.create()
.post()
.uri(tokenRequestUri)
.headers(header -> {
header.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
header.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8));
})
.bodyValue(parameter)
.retrieve()
.bodyToMono(OAuthResponse.RequestToken.class)
.block();
}
...
}
}
- token 요청의 결과로 다음과 같은 응답이 전달되는데, 여기서 사용할 token은 `access_token`이다. 나는
OAuthResponse.RequestToken
객체를 생성해 받아줬다.
public class OAuthResponse {
@Getter
@ToString
@AllArgsConstructor
@NoArgsConstructor
public static class RequestToken {
String access_token;
}
}
8. 토큰을 받은 Client는 리소스 서버(Resource Server)를 통해 사용자 정보를 조회하고, 조회된 결과를 이용해 Client 서버에서 회원가입 처리(DB에 저장) 후 로그인이 완료되었다고 응답한다.
(이 때 Client DB에 저장되는 사용자의 정보는 pw를 포함하지 않으며 권한 설정으로 허락한 정보를 저장한다)
- 토큰 요청으로 AccessToken을 받았다면 인증이 완료된 것이다.
- 사용자 정보를 조회하는 API를 요청해 유저 정보를 조회하고 회원가입을 진행한다.
public class SocialLoginService {
private final ClientRegistrationRepository clientRegistrationRepository;
public void socialLogin(HttpServletResponse response, String registrationId, String code) throws IOException {
// token 요청
...
// 토큰을 발급받은 후 프로필 조회 api 요청
String userInfoRequestUri = clientRegistration.getProviderDetails().getUserInfoEndpoint().getUri();
OAuthUserAttributes userAttributes = OAuthUserAttributes.of(registrationId, WebClient.create()
.get()
.uri(userInfoRequestUri)
.headers(header -> header.setBearerAuth(tokenResponse.getAccess_token()))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.block());
// 우리 플랫폼 회원가입 로직 실행
...
}
}
- 프로필 조회 API의 결과로 다음과 같은 응답이 전달되는데, 유저 scope로 요청했던 값들을 담기 위해
OAuthUserAttributes
객체를 생성해 받아줬다.
@Getter
@ToString
@AllArgsConstructor
@NoArgsConstructor
public class OAuthUserAttributes {
private String responseId;
private String profileName;
private String email;
private String profileImagePath;
public static OAuthUserAttributes of(String provider, Map<String, Object> attributes) {
if (provider.equals("naver")) {
return ofNaver(attributes);
}
return null;
}
private static OAuthUserAttributes ofNaver(Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return new OAuthUserAttributes(
(String) response.get("id"),
(String) response.get("name"),
(String) response.get("email"),
(String) response.get("profile_image"));
}
}
그 다음에는 우리 플랫폼 회원가입 로직을 실행해 유저를 저장하고 AccessToken을 발급해 전달하면 된다.
5. 마무리
처음 소셜 로그인을 구현했을 때는 `spring-security-oauth2-client` 기능을 대부분 사용하지 않고 구현했었다. 스터디에서 소셜 로그인 기능을 구현하게 되면서 oauth2-client에서 필요한 기능들을 사용해 구현해봤다. 이전보다 코드도 간결해지고 어떤 요청을 하는지 눈에 잘보이게 됐다.
이번에는 인가 요청의 결과로 받은 인가 코드를 사용자단에서 받아 Client에게 요청하는 방식으로 구현했는데, oauth2-client는 기본적으로 인가 요청도 Client에서 담당하도록 할 수 있다. 다음에는 인가요청을 Client에게 넘겨서 구현해봐야겠다.
참고자료 😃
https://ko.wikipedia.org/wiki/OAuth
https://inpa.tistory.com/entry/WEB-📚-OAuth-20-개념-💯-정리
https://adjh54.tistory.com/221
https://docs.spring.io/spring-security/reference/reactive/oauth2/client/index.html