[OAuth] Spring Boot + React + OAuth2.0 이용한 네이버, 카카오 로그인
들어가기 전
토이 프로젝트를 진행하면서 OAuth를 이용한 소셜 로그인을 구현해보았습니다. 프론트는 React를 이용하였고 백엔드는 Spring Boot를 이용하였습니다. 네이버, 카카오 로그인에 대한 코드가 프론트, 백 두 개 다 있는데 프론트에 대한 설명보다 백엔드 위주로 설명하겠습니다.
1. OAuth란?
인터넷 사용자들이 비밀번호를 제공하지 않고 구글, 카카오, 네이버, 페이스북 등에 저장되어있는 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로써 사용되는 접근 위임을 위한 개방형 표준 프로토콜입니다.
※ 참고
개방형 표준 프로토콜 : 스택이 표준화되었거나 공개적으로 사용이 가능한 통신 규약
2. OAuth 로그인을 위한 준비 과정
OAuth 로그인 과정
OAuth를 이용하기 위한 공통 설정
※ build.gradle
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
네이버 로그인
1. 네이버 개발 문서에서 네이버 로그인 후 Application을 클릭 후 애플리케이션 등록을 클릭합니다.
2. 만들 애플리케이션의 이름을 등록하고 어떤 용도로 사용할 것인지 사용 API에서 네이버 로그인 선택해주고 제공정보를 골라서 선택해주고 서비스 환경을 선택해줍니다.
3. 서비스 환경을 PC 웹으로 선택해준 뒤 서비스할 URL과 네이버 아이디로 로그인 인증 결과를 반환받을 URL을 나타내는 Callback URL을 작성해준 뒤 등록을 합니다.
4. 등록을 해주면 Client-id와 Client-secret값이 나옵니다. 로그 이미지에는 서비스에 맞는 이미지를 넣으시면 됩니다.
5. 여러 사용자로 테스트를 하고 싶을 경우 멤버 관리를 클릭 후 테스터 ID 등록을 하여 테스트를 합니다.
6. application.yml 또는 application.yml에 네이버 로그인을 위한 설정을 합니다.
※ application.yml
spring:
security:
oauth2:
client:
registration:
naver:
client-id: client id 값
client-secret: client secret 값
client-name: Naver
authorization-grant-type: authorization_code
redirect-uri: 설정한 Callback Url
provider:
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-id : 애플리케이션 등록한 후 나오는 client id
- client-secret : 애플리케이션 등록 후 나오는 client secret
- redirect-uri : 애플리케이션을 등록할 때 설정한 callback url
- provider : 서비스 제공하는 곳
- authorization-uri : 네이버 로그인 인증을 요청하는 uri
- token_uri : 접근 토큰의 발급, 갱신, 삭제를 요청하는 uri
- user-info-uri : 네이버 회원의 프로필을 조회하는 uri
- user_name_attribute : 네이버로부터 요청이 승인되면 출력 결과가 response의 이름을 가진 JSON으로 반환이 됩니다.
프론트가 네이버 로그인 JDK를 사용할 경우
프론트엔드
1. 네이버 로그인 JDK 사용하는 방법 및 사용 로직
// 네이버 로그인 JDK 사용하는 방법
import LoginNaver from './LoginNaver';
<LoginNaver />
// 네이버 로그인 JDK 사용로직
import React, { useEffect } from 'react';
import { naverClientId, naverRedirectURL, naverSecret } from '../../utils/OAuth';
const { naver } = window;
const LoginNaver = () => {
const initializeNaverLogin = () => {
const naverLogin = new naver.LoginWithNaverId({
clientId: naverClientId,
callbackUrl: naverRedirectURL,
clientSecret: naverSecret,
isPopup: false, // popup 형식으로 띄울것인지 설정
loginButton: { color: 'green', type: 3, height: '60' }, //버튼의 스타일, 타입, 크기를 지정
});
naverLogin.init();
};
useEffect(() => {
initializeNaverLogin();
}, []);
return <div id='naverIdLogin' />;
};
export default LoginNaver;
- 반환 값 : http//리다이렉트 설정 url? access-token = {토큰 값} & state = {스테이트 값} & token-type = {토근 타입} & expire = {만료기간}
백엔드
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
public void naverToken(String code, HttpServletResponse response) throws IOException {
try {
JSONParser jsonParser = new JSONParser();
String header = "Bearer " + code;
Map<String, String> requestHeaders = new HashMap<>();
requestHeaders.put("Authorization", header);
String responseBody = get(NAVER_USER_INFO_URI, requestHeaders);
JSONObject parse = (JSONObject) jsonParser.parse(responseBody);
JSONObject responseParse = (JSONObject) parse.get("response");
String encodeUserName = (String) responseParse.get("name");
String loginId = (String) responseParse.get("id");
String email = (String) responseParse.get("email");
String phoneNumber = (String) responseParse.get("mobile_e164");
String userName = new String(encodeUserName.getBytes(StandardCharsets.UTF_8));
User user = new UserRequest("social_" + loginId, userName, encode.encode("네이버"), email, phoneNumber).naverOAuthToEntity();
if (userRepository.existsByLoginId(user.getLoginId()) == false) {
userRepository.save(user);
}
String access_token = tokenProvider.create(new PrincipalDetails(user));
response.addHeader("Authorization","Bearer " + access_token);
} catch (Exception e) {
e.printStackTrace();
}
}
동작 과정
네이버 JDK로 로그인을 할 경우 Callback url에 넘겨주는 토큰은 일회성 토큰이 아니라 바로 사용할 수 있는 Access Token입니다.
바로 사용할 수 있는 Access Token으로 네이버에서 제공해주는 user-info-uri로 서버에서 요청하여 사용자 프로필 정보를 바로 요청할 수 있습니다.
사용자 프로필 정보를 요청을 한 뒤 해당 사용자가 회원 가입된 사용자일 경우는 서버에서 토큰을 생성을 하여 로그인만 실행이 되고 회원가입이 안된 사용자일 경우 회원가입을 하고 서버에서 토큰을 생성을 하여 로그인이 됩니다.
프론트가 네이버 로그인 JDK를 사용 안 하고 REST API 사용 방법
a태그에 https://nid.naver.com/oauth2.0/authorize?response_type=code&client_id={클라이언트 아이디}&state={임의 값 사용}&redirect_uri={등록한 리다이렉트 URL}를 넣어 네이버 로그인 페이지로 이동시켜줍니다. 그리고 로그인이 완료되면 설정해놓은 리다이렉트 주소로 URL에 code값과 state값이 넘어옵니다.
백엔드
public MultiValueMap<String, String> accessTokenParams(String grantType,String clientSecret, String clientId,String code,String redirect_uri) {
MultiValueMap<String, String> accessTokenParams = new LinkedMultiValueMap<>();
accessTokenParams.add("grant_type", grantType);
accessTokenParams.add("client_id", clientId);
accessTokenParams.add("client_secret", clientSecret);
accessTokenParams.add("code", code); // 응답으로 받은 코드
accessTokenParams.add("redirect_uri", redirect_uri);
return accessTokenParams;
}
public void naverToken(String code, HttpServletResponse response) throws IOException {
RestTemplate rt = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
MultiValueMap<String, String> accessTokenParams = accessTokenParams("authorization_code",NAVER_CLIENT_SECRET,NAVER_CLIENT_ID ,code,NAVER_REDIRECT_URI);
HttpEntity<MultiValueMap<String, String>> accessTokenRequest = new HttpEntity<>(accessTokenParams, headers);
ResponseEntity<String> accessTokenResponse = rt.exchange(
NAVER_TOKEN_URI,
HttpMethod.POST,
accessTokenRequest,
String.class);
try {
JSONParser jsonParser = new JSONParser();
String header = "Bearer " + code;
Map<String, String> requestHeaders = new HashMap<>();
requestHeaders.put("Authorization", header);
String responseBody = get(NAVER_USER_INFO_URI, requestHeaders);
JSONObject parse = (JSONObject) jsonParser.parse(responseBody);
JSONObject responseParse = (JSONObject) parse.get("response");
String encodeUserName = (String) responseParse.get("name");
String loginId = (String) responseParse.get("id");
String email = (String) responseParse.get("email");
String phoneNumber = (String) responseParse.get("mobile_e164");
String userName = new String(encodeUserName.getBytes(StandardCharsets.UTF_8));
User user = new UserRequest("social_" + loginId, userName, encode.encode("네이버"), email, phoneNumber).naverOAuthToEntity();
if (userRepository.existsByLoginId(user.getLoginId()) == false) {
userRepository.save(user);
}
String access_token = tokenProvider.create(new PrincipalDetails(user));
response.addHeader("Authorization","Bearer " + access_token);
} catch (Exception e) {
e.printStackTrace();
}
}
동작 과정
네이버 JDK를 이용한 방법과 달리 클라이언트에서 1회성 토큰을 서버에 보내줍니다. 서버는 클라이언트에서 받은 1회성 토큰을 가지고 네이버에서 제공해주는 token_uri를 이용해서 Access Token을 발급받습니다. 발급을 받은 뒤 네이버 JDK를 이용한 방식과 동일하게 네이버에서 제공해주는 user-info-uri로 서버에서 요청을 하여 사용자 프로필 정보를 요청하고 해당 사용자가 회원 가입된 사용자일 경우 서버에서 토큰을 생성하여 로그인을 하고 그렇지 않을 경우 회원가입이 되면서 서버에서 토큰을 생성하여 로그인을 합니다.
주의사항!!
프론트에서는 네이버 JDK를 이용한 로그인 방식으로 구현을 하고 백엔드는 REST API로 구현할 경우 여러 가지 에러가 뜰 수도 있습니다.
필자 또한 이러한 에러 때문에 5일 동안 에러를 해결하려고 애를 먹었습니다.
에러 내용
{
"error":"invalid_request",
"error_description":"no valid data in session"
}
{
"error":"invalid_request",
"error_description":"grant_type is missing."
}
{
"error":"invalid_request",
"error_description":"code is missing."
}
위와 같은 에러 메시지가 보이면 프론트에서 네이버 JDK를 이용하여 로그인을 구현을 하였는지 안 하였는지 확인을 하고 백엔드에서 네이버 JDK를 이용한 방식으로 구현을 하였는지를 확인을 하시면 해결하실 수 있습니다.
카카오 로그인
1. 카카오 개발 문서에서 로그인을 한 뒤 내 애플리케이션을 클릭하여 들어갑니다.
2. 애플리케이션 추가하기를 클릭합니다.
3. 앱 이름, 사업자 명을 입력해준 뒤 저장을 눌러줍니다.
4. 애플리케이션을 추가를 해주면 애플리케이션이 생깁니다. 그 애플리케이션을 클릭하여 들어갑니다.
5. 네이티브 앱 키, REST API 키, JavaScript 키, Admin키가 있습니다.
6. 사이드에 있는 메뉴에서 카카오 로그인을 클릭하여 들어갑니다.
7. 활설화 설정을 ON으로 하고 Redirect URI 등록을 클릭하여 Callback URL을 입력하시면 됩니다.
8. 사이드 메뉴에 있는 동의 항목을 클릭하여 들어갑니다.
9. 카카오에서 얻고 싶은 사용자의 정보에 대해 설정 버튼을 클릭하여 서비스에 맞게 동의 단계를 설정하시면 됩니다. 동의 목적은 서비스에 맞게 목적을 입력하시면 됩니다.
10. Client Secret값을 생성하고 싶으면 사이드 메뉴에 있는 보안을 클릭합니다. 클릭을 한 뒤 코드 생성을 누르고 Client Secret 값을 생성하시면 됩니다.
11. 로그아웃 리다이렉트 URI를 설정을 하시고 싶으면 사이드 메뉴에 있는 고급을 클릭하여 URI를 설정해주시면 됩니다.
12. 사이드 메뉴에서 허용 IP 주소를 클릭하여 허용 가능한 IP 주소 값을 입력하시면 됩니다. 여기서 허용 가능한 IP 주소는 네이버의 서비스 URL과 유사합니다.
13. 여러 사람들이 로그인이 잘 되는지 테스트를 하고 싶으면 사이드 메뉴에 팀 관리를 선택하고 팀원을 초대하여 권한을 설정해줍니다.
14. application.yml 또는 application.yml에 카카오 로그인을 위한 설정을 합니다.
※ application.yml
spring :
security:
oauth2:
client:
registration:
kakao:
client-id: client id 값
client-secret: client secret 값
client-name: Kakao
authorization-grant-type: authorization_code
redirect-uri: callback uri
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
- client-id : REST API 키 값
- client-secret : Client Secret 값
- redirect-uri : callback uri
- provider : 서비스 제공하는 곳
- authorization-uri : 카카오 로그인 인증을 요청하는 uri
- token_uri : 접근 토큰의 발급, 갱신, 삭제를 요청하는 uri
- user-info-uri : 카카오 회원의 프로필을 조회하는 uri
- user_name_attribute : 카카오로부터 요청이 승인되면 출력 결과가 id의 이름을 가진 JSON으로 반환이 됩니다.
프론트가 카카오 REST API을 사용하여 로그인하는 방법
프론트엔드
카카오 REST API 클라이언트 사용 로직
// 변수처리를 위한 다른 JS 파일
export const KAKAO_AUTH_URL = `https://kauth.kakao.com/oauth/authorize?client_id=${kakaoClientId}&redirect_uri=${kakaoRedirectURL}&response_type=code&prompt=login`;
// 로그인 페이지 해당 코드
<KakaoBtn href={KAKAO_AUTH_URL}>
<img src={`${process.env.PUBLIC_URL}/images/kakao_login_large_narrow.png`} alt='kakaoLogin' />
</KakaoBtn>
- 반환 값 : http//리다이렉트 설정 url? code={일회성 토큰 값}
- 별다른 state값을 로그인 요청 시 url에 넣어주지 않았다면 state값은 선택사항이라 넘어오지 않습니다.
백엔드
카카오 REST API 서버 처리 로직
public MultiValueMap<String, String> accessTokenParams(String grantType, String clientId,String code,String redirect_uri) {
MultiValueMap<String, String> accessTokenParams = new LinkedMultiValueMap<>();
accessTokenParams.add("grant_type", grantType);
accessTokenParams.add("client_id", clientId);
accessTokenParams.add("code", code);
accessTokenParams.add("redirect_uri", redirect_uri);
return accessTokenParams;
}
public void kakaoToken(String code, HttpServletResponse res, HttpSession session) {
RestTemplate rt = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
MultiValueMap<String, String> accessTokenParams = accessTokenParams("authorization_code",KAKAO_CLIENT_ID ,code,KAKAO_REDIRECT_URI);
HttpEntity<MultiValueMap<String, String>> accessTokenRequest = new HttpEntity<>(accessTokenParams, headers);
ResponseEntity<String> accessTokenResponse = rt.exchange(
KAKAO_TOKEN_URI,
HttpMethod.POST,
accessTokenRequest,
String.class);
try {
JSONParser jsonParser = new JSONParser();
JSONObject jsonObject = (JSONObject) jsonParser.parse(accessTokenResponse.getBody());
session.setAttribute("Authorization", jsonObject.get("access_token"));
String header = "Bearer " + jsonObject.get("access_token");
System.out.println("header = " + header);
Map<String, String> requestHeaders = new HashMap<>();
requestHeaders.put("Authorization", header);
String responseBody = get(KAKAO_USER_INFO_URI, requestHeaders);
JSONObject profile = (JSONObject) jsonParser.parse(responseBody);
JSONObject properties = (JSONObject) profile.get("properties");
JSONObject kakao_account = (JSONObject) profile.get("kakao_account");
Long loginId = (Long) profile.get("id");
String email = (String) kakao_account.get("email");
String userName = (String) properties.get("nickname");
User kakaoUser = new UserRequest("social_" + loginId, encode.encode("카카오"), userName, email).kakaoOAuthToEntity();
if (userRepository.existsByLoginId(kakaoUser.getLoginId()) == false) {
userRepository.save(kakaoUser);
}
String access_token = tokenProvider.create(new PrincipalDetails(kakaoUser));
res.setHeader("Authorization", "Bearer "+access_token);
} catch (Exception e) {
e.printStackTrace();
}
}
동작 과정
클라이언트에서 1회성 토큰을 서버로 전달을 해줍니다. 서버는 1회 성토 큰을 이용하여 카카오에서 제공해주는 token_uri을 이용하여 Access Token을 발급받습니다. 발급받은 Access Token을 이용하여 카카오에서 제공해주는 user-info-uri을 사용자 프로필 정보를 받아옵니다. 해당 사용자가 회원 가입된 사용자 일 경우 서버에서 토큰을 생성하여 로그인만 실행해주고 회원가입 안된 사용자일 경우 회원가입을 하고 서버에서 토큰을 생성하여 로그인을 실행합니다.
주의사항!!
header에 Content-type 값을 application/json이나 다른 형식으로 header에 설정하면 아래와 같은 에러가 발생합니다.
org.springframework.web.client.HttpClientErrorException$Unauthorized: 401 Unauthorized: [no body]
카카오 공식 문서에서 제공해주는 application/x-www-form-urlencoded;charset=utf-8을 사용해주면 에러가 나지 않습니다.
3. OAuth 로그인을 사용할 때 공통 주의 사항 및 공통 코드
프론트엔드와 백엔드가 프로젝트에서 각각 애플리케이션을 등록하고 각자의 client-id, client-secret, redirect-uri, 서비스 uri 등등 이런 설정이 달라서 제대로 구현하여도 실행이 안 되는 경우가 있습니다.
그러므로 프론트엔트 백엔드는 설정 값이 동일해야 됩니다.
공통 코드
private static String get(String apiUrl, Map<String, String> requestHeaders) {
HttpURLConnection con = connect(apiUrl);
try {
con.setRequestMethod("GET");
for (Map.Entry<String, String> header : requestHeaders.entrySet()) {
con.setRequestProperty(header.getKey(), header.getValue());
}
int responseCode = con.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) { // 정상 호출
return readBody(con.getInputStream());
} else { // 에러 발생
return readBody(con.getErrorStream());
}
} catch (IOException e) {
throw new RuntimeException("API 요청과 응답 실패", e);
} finally {
con.disconnect();
}
}
private static HttpURLConnection connect(String apiUrl) {
try {
URL url = new URL(apiUrl);
return (HttpURLConnection) url.openConnection();
} catch (MalformedURLException e) {
throw new RuntimeException("API URL이 잘못되었습니다. : " + apiUrl, e);
} catch (IOException e) {
throw new RuntimeException("연결이 실패했습니다. : " + apiUrl, e);
}
}
private static String readBody(InputStream body) {
InputStreamReader streamReader = new InputStreamReader(body);
try (BufferedReader lineReader = new BufferedReader(streamReader)) {
StringBuilder responseBody = new StringBuilder();
String line;
while ((line = lineReader.readLine()) != null) {
responseBody.append(line);
}
return responseBody.toString();
} catch (IOException e) {
throw new RuntimeException("API 응답을 읽는데 실패했습니다.", e);
}
}
Papago번역 구현하는 방법에 대한것은 아래블로그를 참고하시면 됩니다.
https://hoestory.tistory.com/52