웹사이트, 애플리케이션에 접속을 하면 해당 서버에서 자체적인 로그인 서비스만을 제공하는 것 보다 카카오, 네이버 , 구글 등의 외부 소셜 로그인을 함께 제공하는 서비스가 증가하고 있는데요.
사용자 관점에서 회원가입이라는 번거로운 절차를 거치지 않아 편리하고 서버로서도 사용자 인증 관련 부분에 대한 부담을 덜 수 있으니 그만큼 이용 빈도수가 증가하는 게 아닐까 생각됩니다.
그래서 저 또한 프로젝트에서 OAuth 2.0
를 활용하여 소셜 로그인을 구현해 보려 하는데요. 저는 카카오와 네이버 로그인 서비스를 구현하려 합니다.
프로젝트에서 OAuth
를 어떻게 다뤘고 왜 이러한 방식을 사용했는지에 대한 것에 초점을 맞추려 합니다.
그래서 전체적인 흐름에서 중요하다고 생각되어진 부분들에 대해서만 간략하게 정리 하려 합니다.
사용자(Resource Owner) 가 리소스 서버에서 제공하는 자원에 대한 접근 권한을 다른 애플리케이션에게 부여할 수 있도록 만들어 주는 개방형 표준 프로토콜 입니다.
이로서 외부 소셜 로그인(카카오, 네이버, 구글)을 통한 사용자 인증을 수행할 수 있습니다.
하지만 인가(Authorization)에 대한 부분은 여전히 서버에서 담당해야 합니다.
Resource Owner
입니다.Resource Server
가 됩니다.Authorization Server
Authorization Server
에 AccessToken
을 요청할 수 있습니다.Client
AccessToken
을 통해 Resource Server
로 부터 자원을 요청할 수 있습니다.Resource Server
로 부터 자원을 요청하기 때문에 client
라는 명칭이 사용됩니다.Scope
OAuth 2.0
에서 클라이언트는 4가지 인증 방식으로 토큰을 요청할 수 있습니다.
그 중에서 저는 가장 보편적인 방식인 Authorization Code Grant 으로 토큰을 요청하려 합니다.
Authorization Code Grant 방식
사용자에게 명확하게 권한 부여를 요청하는 페이지를 제공하고 인가 코드로 서 Access token을 api를 통해 발급받을 수 있기 때문에 토큰 탈취 위험으로부터 어느 정도 벗어날 수 있습니다.
이제 OAuth 2.0 을 활용한 소셜 로그인을 구현하기 위한 기본값들을 세팅하겠습니다.
(client id, secret 을 받는 과정은 생략했습니다.)
// OAuth 2.0
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
다음과 같이 의존성을 주입해 주었습니다.
yaml
security:
oauth2:
client:
registration:
kakao:
client-id:
client-secret:
authorization-grant-type: authorization_code
redirect-uri:
client-authentication-method: POST
naver:
client-id:
client-secret:
authorization-grant-type: authorization_code
redirect-uri:
client-name: Naver
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
naver:
authorization-uri: https://nid.naver.com/oauth2.0/authorize
token-uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openapi.naver.com/v1/nid/me
user-name-attribute: response
Client registration
client-id
와 client-secret
값을 등록해줍니다.여기서 주의해야 할 점은 절대로
cleint-id
와client - secret
이 외부에 노출되어서는 안됩니다.
authorization-grant-type
OAuth 2.0
인증 방식중 Authorization Code Grant
방식을 사용한다는 것을 명시합니다.Authorization Code Grant
방식은 인가 코드를 uri를 통해서 발급 받기 때문에 인가 코드를 발급 받을 redirect-uri
를 설정합니다.provider
provider 정보를 포함한 소셜로그인에 대한 상세 가이드는 해당 서비스의 Develop 문서에서 찾으실 수 있습니다.
이제 본격적으로 소셜 로그인을 구현해보겠습니다.
현재 저는 rest api 프로젝트를 개발하고 있기에 그에 맞게 코드로 구현하려 합니다.
OAuth2Provider
public enum OAuth2Provider {
KAKAO, NAVER
}
OAuth2LoginParams
public interface OAuth2LoginParams {
OAuth2Provider oAuth2Provider();
MultiValueMap<String, String> authorizationBody();
}
redirect uri로 발급받은 인가 코드를 Authorization server에 요청하기 위해서 OAuth2LoginParams
를 생성해 주었습니다.
(HTTP 요청이나 응답에서는 종종 같은 이름의 파라미터나 헤더를 여러 개 가질 수 있기때문에 Spring Framework 에서 제공하는 MultiValueMap
사용했습니다.)
이제 restTemplate
으로 본격적인 통신을 할 수 있는 api client를 추상화해주고 그에 맞게 각각의 구현체들(카카오, 네이버)을 생성해 주었습니다.
OAuth2ApiClient
public interface OAuth2ApiClient {
OAuth2Provider oAuthProvider();
String requestAccessToken(OAuth2LoginParams params) throws Exception;
OAuth2UserInfo requestUserInfo(String accessToken);
}
KakaoApiClient
@Component
@RequiredArgsConstructor
@Slf4j
public class KakaoApiClient implements OAuth2ApiClient {
/**
* @param params params.authorizationBody(): has authorization-code for issued access token
* params.oAuth2Provider: inform provider
* @return access token
* @throws OAuth2RestClientException
*/
@Override
public String requestAccessToken(OAuth2LoginParams params) throws OAuth2RestClientException {
// header
HttpHeaders httpHeaders = createUrlEncodedHttpHeaders();
// body
MultiValueMap<String, String> authorizationBody = params.authorizationBody();
authorizationBody.add("grant_type", GRANT_TYPE);
authorizationBody.add("client_id", clientId);
//request
HttpEntity<?> request = new HttpEntity<>(authorizationBody, httpHeaders);
//kakaoTokens
KakaoTokens kakaoTokens = restTemplate.postForObject(tokenUri, request, KakaoTokens.class);
if (kakaoTokens == null) {
throw new OAuth2RestClientException(
"[KakaoApiClient] Failed to retrieve access token.");
}
log.info("[KakaoApiClient] KakaoTokens is successfully issued");
return kakaoTokens.getAccessToken();
}
NaverApiClient
@Component
@RequiredArgsConstructor
@Slf4j
public class NaverApiClient implements OAuth2ApiClient {
/**
* @param params params.authorizationBody(): has authorization-code for issued access token
* params.oAuth2Provider: inform provider
* @return access token
* @throws OAuth2RestClientException
*/
@Override
public String requestAccessToken(OAuth2LoginParams params) {
//header
HttpHeaders httpHeaders = createUrlEncodedHttpHeaders();
//body
MultiValueMap<String, String> body = params.authorizationBody();
body.add("grant_type", GRANT_TYPE);
body.add("client_id", clientId);
body.add("client_secret", clientSecret);
//request
HttpEntity<?> request = new HttpEntity<>(body, httpHeaders);
//NaverTokens
NaverTokens naverTokens = restTemplate.postForObject(tokenUri, request, NaverTokens.class);
if (naverTokens == null) {
throw new OAuth2RestClientException(
"[NaverApiClient] Failed to retrieve access token.");
}
log.info("[NaverApiClient] naverTokens is successfully issued");
return naverTokens.getAccessToken();
}
위의 ApiClient들의 역할은 결국 Authorization Server 에 요청할 request를 생성하는 것입니다.
그리고 최종적으로 토큰을 return 받는 것을 확인할 수 있습니다.
이제 인가 코드로 access token
을 발급 받고 이를 통해 유저 데이터를 요청하는 전체적인 흐름이 담긴 서비스 레이어를 살펴보겠습니다.
OAuth2Config
@Configuration
public class OAuth2Config {
@Bean
public Map<OAuth2Provider, OAuth2ApiClient> oAuth2ApiClientMap(List<OAuth2ApiClient> clients) {
return clients.stream().collect(
Collectors.toUnmodifiableMap(OAuth2ApiClient::oAuthProvider, Function.identity())
);
}
}
인가 코드로 토큰을 요청하기 이전에 먼저 파라미터가 어떤 리소스 서버에 대한 요청인지 구분하기 위해서 위와 같이 oAuth2ApiClientMap
을 스프링 빈으로 등록해 두었습니다.
OAuth2LoginService
@Service
@RequiredArgsConstructor
@Slf4j
public class OAuth2LoginService {
private final Map<OAuth2Provider, OAuth2ApiClient> oAuth2ApiClientMap;
...(생략)
public OAuth2UserInfo requestUserInfo(OAuth2LoginParams params) {
OAuth2ApiClient oAuth2ApiClient = oAuth2ApiClientMap.get(params.oAuth2Provider());
try {
String accessToken = oAuth2ApiClient.requestAccessToken(params);
return oAuth2ApiClient.requestUserInfo(accessToken);
} catch (Exception e) {
throw new OAuth2RestClientException("Failed to retrieve access token.");
}
}
(다음은 OAuth2LoginService
코드의 일부입니다.)
oAuth2ApiClient
를 oAuth2ApiClientMap
통해서 가져옵니다.oAuth2ApiClient.requestAccessToken(params)
부분이 위에서 살펴 보았던 인가 코드를 통해 토큰을 발급받는 부분입니다.oAuth2ApiClient.requestUserInfo(accessToken)
통해서 토큰으로 유저의 데이터를 요청합니다.리소스 서버로부터 받게 될 유저의 데이터를 저장하기 위한 데이터 바인딩 클래스들을 리소스 서버 문서를 참고하며 생성해 주었습니다.
OAuth2UserInfo
public interface OAuth2UserInfo {
String getEmail();
String getNickname();
OAuth2Provider getOAuthProvider();
String getId();
}
KakaoUserInfo
@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
public class KakaoUserInfo implements OAuth2UserInfo {
private Long id;
@JsonProperty("kakao_account")
private KakaoAccount kakaoAccount;
@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
static class KakaoAccount {
private KakaoProfile profile;
private String email;
}
@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
static class KakaoProfile {
private String nickname;
}
@Override
public String getId() {
return String.valueOf(id);
}
@Override
public String getEmail() {
return kakaoAccount.email;
}
@Override
public String getNickname() {
return kakaoAccount.profile.nickname;
}
@Override
public OAuth2Provider getOAuthProvider() {
return OAuth2Provider.KAKAO;
}
}
NaverUserInfo
public class NaverUserInfo implements OAuth2UserInfo {
@JsonProperty("response")
private Response response;
@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
static class Response {
private String id;
private String email;
private String nickname;
}
@Override
public String getEmail() {
return response.email;
}
@Override
public String getNickname() {
return response.nickname;
}
@Override
public OAuth2Provider getOAuthProvider() {
return OAuth2Provider.NAVER;
}
public String getId() {
return response.id;
}
}
이제 토큰과 유저의 데이터를 받을 클래스들까지 생성했으므로,
리소스 서버를 통해 유저의 데이터를 받습니다.
KakaoApiClient
@Component
@RequiredArgsConstructor
@Slf4j
public class KakaoApiClient implements OAuth2ApiClient {
private static final String GRANT_TYPE = "authorization_code";
@Override
public OAuth2UserInfo requestUserInfo(String accessToken) {
// header
HttpHeaders httpHeaders = createUrlEncodedHttpHeaders();
httpHeaders.set("Authorization", "Bearer " + accessToken);
// body
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("property_keys", "[\"kakao_account.email\", \"kakao_account.profile\"]");
//request
HttpEntity<?> request = new HttpEntity<>(body, httpHeaders);
//response
ResponseEntity<String> stringResponseEntity = restTemplate.postForEntity(userInfoUri,
request, String.class);
log.info("API response: stringResponseEntity.getBody(): {}",
stringResponseEntity.getBody());
return restTemplate.postForObject(userInfoUri, request, KakaoUserInfo.class);
}
NaverApiClient
@Component
@RequiredArgsConstructor
@Slf4j
public class NaverApiClient implements OAuth2ApiClient {
@Override
public OAuth2UserInfo requestUserInfo(String accessToken) {
//header
HttpHeaders httpHeaders = createUrlEncodedHttpHeaders();
httpHeaders.set("Authorization", "Bearer " + accessToken);
//body
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
//request
HttpEntity<?> request = new HttpEntity<>(body, httpHeaders);
//response
ResponseEntity<String> stringResponseEntity = restTemplate.postForEntity(userInfoUri,
request, String.class);
log.info("API response: stringResponseEntity.getBody(): {}",
stringResponseEntity.getBody());
return restTemplate.postForObject(userInfoUri, request, NaverUserInfo.class);
}
access token
을 통해 유저 정보에 대한 자원에 접근할 수 있는것을 확인할 수 있습니다.
이후 소셜 로그인 계정 유저를 클라이언트 서버에 저장하는 과정을 살펴보겠습니다.
(이 과정은 프로젝트마다 상이할 수 있습니다.)
OAuth2LoginService
@Service
@RequiredArgsConstructor
@Slf4j
public class OAuth2LoginService {
private final UserRepository userRepository;
private final JwtTokenGenerator jwtTokenGenerator;
private final Map<OAuth2Provider, OAuth2ApiClient> oAuth2ApiClientMap;
private final BCryptPasswordEncoder encoder;
/**
* @param params from OAuth2 resource server(KAKAO, NAVER...)
* @return Access token generated JWT
*/
public AccessToken login(OAuth2LoginParams params) {
OAuth2UserInfo oAuth2UserInfo = requestUserInfo(params);
return jwtTokenGenerator.generateAccessToken(findUser(oAuth2UserInfo));
}
private String findUser(OAuth2UserInfo oAuth2UserInfo) {
String username = getUsernameByUserInfo(oAuth2UserInfo);
log.info("[OAuth2LoginService] Find user by username : {}", username);
return userRepository.findByUsername(username)
.map(User::fromEntity)
.map(User::getUsername)
.orElseGet(
() -> createUserInfo(oAuth2UserInfo, username)
);
}
public OAuth2UserInfo requestUserInfo(OAuth2LoginParams params) {
OAuth2ApiClient oAuth2ApiClient = oAuth2ApiClientMap.get(params.oAuth2Provider());
try {
String accessToken = oAuth2ApiClient.requestAccessToken(params);
return oAuth2ApiClient.requestUserInfo(accessToken);
} catch (Exception e) {
throw new OAuth2RestClientException("Failed to retrieve access token.");
}
}
다음의 코드를 요약한다면 이렇습니다.
이로서 OAuth2
를 이용한 소셜 로그인 서비스를 구현하였습니다.
관련 코드를 포함한 프로젝트의 전체 코드는 [해당 깃 허브] 에서 확인하실 수 있습니다.
긴 글 읽어주셔서 감사합니다 :)