[Spring] 네이버 로그인 Open API
안녕하세요 코북입니다. 이번 프로젝트에서는 기존 홈페이지 DB를 통한 로그인 이외에 추가로 소셜 계정 로그인을 구현할 계획입니다. 목표는 네이버, 구글, 카카오 로그인이고 먼저 네아로 오픈 API를 사용해 네이버 로그인을 연동해봤습니다. API 흐름은 다음과 같습니다.
1. Resource Owner가 네이버 로그인 버튼을 클릭합니다.
2. 버튼 클릭 시 Service Provider에서 Client에 발급해준 웹 애플리케이션 Client ID, Client Secret를 통해 네이버 로그인 API를 호출하면 네이버 로그인 폼으로 이동합니다.
3. Resource Owner가 Service Provider에 id, pw를 입력합니다. ( Client에 입력 X )
4. 계정 액세스 권한 요청 동의를 거친 후에 Access Token이 발급되며, 바로 정보를 꺼내 주는 게 아니라, Service Provider가 Access Token이 저장된 DB에 비교하여 지정된 Scope에 접근하는 게 맞는지 확인 후에 돌려줍니다.
5. Client가 Scope에 접근하면서 로그인이 성공하게 됩니다.
용어 정리를 간단하게 해보자면 Service Provider는 Resource를 저장하고 있는 제3의 주체입니다. Client가 여기에 저장된 정보에 접근하고자 합니다. Client는 Resource에 접근하려고하는 주체를 말합니다. Service Provider에 있는 특정 정보에 접근하여 사용하고자 합니다. Resource는 Service Provider에 저장된 정보입니다. Resource Owner는 Client에 제공하는 정보인 Resource를 가지고 있는 사람입니다. 작업에 들어가기에 앞서 API 흐름과 용어에 대해 간략하게 정리해봤습니다. OAuth에 대한 더 자세한 흐름은 생략했습니다. 다음은 작업 진행순서입니다.
1. 네이버 Open API 이용신청
2. OAuth2.0 Java OpenSource Library 설정
3. Scribe Library를 이용하여 네아로 인증 버튼 적용
4. Controller
5. jsp
1. 네이버 Open API 이용신청
먼저 네이버 Open API 이용하기 위해 네이버 애플리케이션 등록을 해야 합니다.
https://developers.naver.com/docs/login/overview/overview.md
위 사이트에 들어가서 애플리케이션 이름을 설정하고 사용할 API를 선택합니다. 제공 정보를 선택해 scope를 지정해주시면 됩니다. 다음은 서비스 환경설정입니다. 서비스 URL은 네이버 아이디로 로그인 뱃지가 노출되는 화면입니다. 애플리케이션 등록 시 서비스 URL과 네이버 검색에 노출되는 사이트 URL이 동일하면 네이버 검색 결과에서 네이버 로그인 뱃지가 노출됩니다. Callback URL은 로그인 성공 시 요청되는 URL입니다.
애플리케이션을 등록하면 Client ID와 Client Secret(PW)을 발급해줍니다.
2. OAuth 2.0 Java OpenSource Library 설정
등록을 마치면 네이버 아이디로 로그인을 간편하게 적용하기 위해 OAuth2.0 프로토콜을 지원하는 Scribe OpenSource OAuth Java Library를 적용합니다. 나중에 Controller에서 Json 데이터 형식인 사용자 정보를 처리하기 위해 Json Parsing Library도 추가했습니다.
■ pom.xml에 dependency 추가
<!-- OAuth2.0 -->
<dependency>
<groupId>com.github.scribejava</groupId>
<artifactId>scribejava-core</artifactId>
<version>2.8.1</version>
</dependency>
<!-- 제이슨 파싱 -->
<dependency>
<groupId>com.googlecode.json-simple</groupId>
<artifactId>json-simple</artifactId>
<version>1.1.1</version>
</dependency>
■ Scribe library용 Naver Login 구현체 추가
package city.turtle.mapper;
import com.github.scribejava.core.builder.api.DefaultApi20;
public class NaverOAuthApi extends DefaultApi20{
protected NaverOAuthApi(){
}
private static class InstanceHolder{
private static final NaverOAuthApi INSTANCE = new NaverOAuthApi();
}
public static NaverOAuthApi instance(){
return InstanceHolder.INSTANCE;
}
@Override
public String getAccessTokenEndpoint() {
return "https://nid.naver.com/oauth2.0/token?grant_type=authorization_code";
}
@Override
protected String getAuthorizationBaseUrl() {
return "https://nid.naver.com/oauth2.0/authorize";
}
}
3. Scribe Library를 이용하여 네아로 인증 버튼 적용
네아로 연동 Business logic을 처리하기 위한 BO Class 생성하여 인증 요청문을 구성해 줍니다.
■ NaverLoginBO
package city.turtle.mapper;
import java.io.IOException;
import java.util.UUID;
import javax.servlet.http.HttpSession;
import org.springframework.util.StringUtils;
import com.github.scribejava.core.builder.ServiceBuilder;
import com.github.scribejava.core.model.OAuth2AccessToken;
import com.github.scribejava.core.model.OAuthRequest;
import com.github.scribejava.core.model.Response;
import com.github.scribejava.core.model.Verb;
import com.github.scribejava.core.oauth.OAuth20Service;
public class NaverLoginBO {
/* 인증 요청문을 구성하는 파라미터 */
//client_id: 애플리케이션 등록 후 발급받은 클라이언트 아이디
//response_type: 인증 과정에 대한 구분값, code로 값이 고정
//redirect_uri: 네이버 로그인 인증의 결과를 전달받을 콜백 URL(URL 인코딩) 애플리케이션을 등록할 때 Callback URL에 설정한 정보
//state: 애플리케이션이 생성한 상태 토큰
private final static String CLIENT_ID = ""; // 클라이언트 아이디
private final static String CLIENT_SECRET = ""; // 클라이언트 시크릿
private final static String REDIRECT_URI = "http://localhost:8081/web/callbackNaver.do";
private final static String SESSION_STATE = "oauth_state";
/* 프로필 조회 API URL */
private final static String PROFILE_API_URL = "https://openapi.naver.com/v1/nid/me";
/* 네이버 아이디로 인증 URL 생성 Method */
public String getAuthorizationUrl(HttpSession session) {
/* 세션 유효성 검증을 위하여 난수를 생성 */
String state = generateRandomString();
/* 생성한 난수 값을 session에 저장 */
setSession(session,state);
/* Scribe에서 제공하는 인증 URL 생성 기능을 이용하여 네아로 인증 URL 생성 */
OAuth20Service oauthService = new ServiceBuilder()
.apiKey(CLIENT_ID)
.apiSecret(CLIENT_SECRET)
.callback(REDIRECT_URI)
.state(state) //앞서 생성한 난수값을 인증 URL생성시 사용함
.build(NaverOAuthApi.instance());
return oauthService.getAuthorizationUrl();
}
/* 네이버아이디로 Callback 처리 및 AccessToken 획득 Method */
public OAuth2AccessToken getAccessToken(HttpSession session, String code, String state) throws IOException{
/* Callback으로 전달받은 세선검증용 난수값과 세션에 저장되어있는 값이 일치하는지 확인 */
String sessionState = getSession(session);
if(StringUtils.pathEquals(sessionState, state)){
OAuth20Service oauthService = new ServiceBuilder()
.apiKey(CLIENT_ID)
.apiSecret(CLIENT_SECRET)
.callback(REDIRECT_URI)
.state(state)
.build(NaverOAuthApi.instance());
/* Scribe에서 제공하는 AccessToken 획득 기능으로 네아로 Access Token을 획득 */
OAuth2AccessToken accessToken = oauthService.getAccessToken(code);
return accessToken;
}
return null;
}
/* 세션 유효성 검증을 위한 난수 생성기 */
private String generateRandomString() {
return UUID.randomUUID().toString();
}
/* http session에 데이터 저장 */
private void setSession(HttpSession session,String state){
session.setAttribute(SESSION_STATE, state);
}
/* http session에서 데이터 가져오기 */
private String getSession(HttpSession session){
return (String) session.getAttribute(SESSION_STATE);
}
/* Access Token을 이용하여 네이버 사용자 프로필 API를 호출 */
public String getUserProfile(OAuth2AccessToken oauthToken) throws IOException{
OAuth20Service oauthService =new ServiceBuilder()
.apiKey(CLIENT_ID)
.apiSecret(CLIENT_SECRET)
.callback(REDIRECT_URI).build(NaverOAuthApi.instance());
OAuthRequest request = new OAuthRequest(Verb.GET, PROFILE_API_URL, oauthService);
oauthService.signRequest(oauthToken, request);
Response response = request.send();
return response.getBody();
}
}
■ Controller Class에서 BO Class를 이용할 수 있도록 servlet-context.xml에 Bean으로 등록
<!-- NaverLoginBO Class에 대한 Bean설정 추가 -->
<beans:bean id="naverLoginBO" class="city.turtle.mapper.NaverLoginBO" />
4. Controller
이제 로그인 화면이 나타날 때 네이버 로그인 버튼에 네이버 아이디로 인증 URL을 View로 보내주기 위해 Controller를 설정해줍니다. 기존에 controller에 있던 login 메소드는 사이트로 이동만을 나타내주는 형식이었습니다.
@RequestMapping("/login.do")
public String login() {
return "login";
package city.turtle.web;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import city.turtle.mapper.MembersMapper;
import city.turtle.mapper.MembersVO;
import city.turtle.mapper.NaverLoginBO;
import com.github.scribejava.core.model.OAuth2AccessToken;
@Controller
public class CityTurtleController {
/* NaverLoginBO */
private NaverLoginBO naverLoginBO;
private String apiResult = null;
@Autowired
private void setNaverLoginBO(NaverLoginBO naverLoginBO) {
this.naverLoginBO = naverLoginBO;
}
// 로그인페이지
//로그인 첫 화면 요청 메소드
@RequestMapping(value = "/login.do", method = { RequestMethod.GET, RequestMethod.POST })
public String login(Model model, HttpSession session) {
/* 네아로 인증 URL을 생성하기 위하여 naverLoginBO클래스의 getAuthorizationUrl메소드 호출 */
String naverAuthUrl = naverLoginBO.getAuthorizationUrl(session);
/* 인증요청문 확인 */
System.out.println("네이버:" + naverAuthUrl);
/* 객체 바인딩 */
model.addAttribute("urlNaver", naverAuthUrl);
/* 생성한 인증 URL을 View로 전달 */
return "login";
}
//네이버 로그인 성공시 callback호출 메소드
@RequestMapping(value = "/callbackNaver.do", method = { RequestMethod.GET, RequestMethod.POST })
public String callbackNaver(Model model, @RequestParam String code, @RequestParam String state, HttpSession session)
throws Exception {
System.out.println("로그인 성공 callbackNaver");
OAuth2AccessToken oauthToken;
oauthToken = naverLoginBO.getAccessToken(session, code, state);
//로그인 사용자 정보를 읽어온다.
apiResult = naverLoginBO.getUserProfile(oauthToken);
JSONParser jsonParser = new JSONParser();
JSONObject jsonObj;
jsonObj = (JSONObject) jsonParser.parse(apiResult);
JSONObject response_obj = (JSONObject) jsonObj.get("response");
// 프로필 조회
String email = (String) response_obj.get("email");
String name = (String) response_obj.get("name");
// 세션에 사용자 정보 등록
// session.setAttribute("islogin_r", "Y");
session.setAttribute("signIn", apiResult);
session.setAttribute("email", email);
session.setAttribute("name", name);
/* 네이버 로그인 성공 페이지 View 호출 */
return "redirect:/loginSuccess.do";
}
// 소셜 로그인 성공 페이지
@RequestMapping("/loginSuccess.do")
public String loginSuccess() {
return "loginSuccess";
}
callback호출 메소드에서 Json Parsing을 하기 위해 throws Exception으로 예외처리를 해줍니다. getAccessToken 메소드를 사용해 토큰을 가져와서 Naver에 사용자 정보를 요청합니다. 받은 정보는 Json Parsing 처리하여 세션으로 로그인 성공 페이지에 넘겨줍니다. 네이버에서 보낸 Json 파일의 형식은
{"resultcode":"00","message":"success"
,"response":{"id":"네이버 아이디"
,"nickname":"닉네임"
,"email":"이메일"
,"name":"이름"}}
이었는데, response의 value에 접근해야 했기 때문에 response_obj에 한 번 더 들어간 후 다시 email과 name에 접근했습니다. apiResult는 메인페이지에 로그인 session으로 보냈습니다. return값은 forward가 아닌 redirect하도록 설정했습니다. forward 방식을 사용할 경우 현재 실행된 페이지와 forward에 의해 호출될 페이지는 request, response객체를 공유하게 됩니다. 사용자의 최초 요청정보가 다음 URL에서도 유효하기 때문에 사용자가 로그인 후 새로고침을 하면 동일한 요청이 여러번 전달될 수 있고 이를 처리하는 과정에서 오류가 발생할 수 있습니다.
5. jsp
■ 로그인 페이지 jsp
<script type="text/javascript" src="https://static.nid.naver.com/js/naverLogin_implicit-1.0.2.js" charset="utf-8"></script>
<script type="text/javascript" src="http://code.jquery.com/jquery-1.11.3.min.js"></script>
<section class="bg-light">
<div class="container py-4">
<div class="row align-items-center justify-content-between">
<a class="navbar-brand h1 text-center" href="index.do">
<span class="text-dark h4">도시</span> <span class="text-primary h4">거북</span>
</a>
</div>
<c:if test="${signIn == null}">
<form action="${cpath}/signIn.do" method="post">
<div class="form-group">
<input type="text" class="form-control" name="mb_id" placeholder="아이디">
</div>
<div class="form-group">
<input type="password" class="form-control" name="mb_pwd" placeholder="비밀번호">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="" id="flexCheckDefault">
<label class="form-check-label text-secondary" for="flexCheckDefault">
로그인 상태 유지
</label>
</div>
<div class="d-grid gap-2">
<button class="btn btn-primary btn-lg" type="submit">로그인</button>
</div>
</form>
<div class="otherButton text-center">
<span class="text-secondary">다른 계정으로 로그인</span>
<button type ="button" class = "btn" onclick="location.href='${urlNaver}'"><img src='./resources/img/logoNaver5.png'></button>
<button type ="button" class = "btn"><img src='./resources/img/logoKakao4.png'></button>
<button type ="button" class = "btn"><img src='./resources/img/logoGoogle4.png'></button>
</div>
<div class="row">
<div class="col-lg-6 col-sm-12 text-lg-start text-center">
<button type="button" class="btn text-secondary" onclick="location.href='signUp.do'">회원가입</button>
</div>
<div class="col-lg-6 col-sm-12 text-lg-end text-center">
<button type="button" class="btn text-secondary">아이디/비밀번호 찾기</button>
</div>
</div>
</c:if>
</div>
</section>
■ 로그인 성공 페이지 jsp - jquery
<script type="text/javascript" src="http://code.jquery.com/jquery-1.11.3.min.js"></script>
<script type="text/javascript">
$(document).ready(function() {
var name = "${name}";
var email = "${email}";
$("#name").html(name);
$("#email").html(email);
});
</script>
<section class="bg-light">
<div class="container py-4">
<div class="row align-items-center justify-content-between">
<a class="navbar-brand h1 text-center" href="index.do">
<span class="text-dark h4">도시</span> <span class="text-primary h4">거북</span>
</a>
</div>
<div>
<h1 class ="text-dark text-center">환영합니다!</h1>
<p class="text-center">
<span id="name"></span>님의 로그인 성공<br> 이메일 주소는 <strong id="email"></strong>입니다.
</p>
</div>
<div class="d-grid gap-2">
<button type="button" class="btn btn-primary btn-lg" onclick="location.href='index.do'">시작하기</button>
</div>
</div>
</section>
■ 로그인 성공 페이지 jsp - EL
<section class="bg-light">
<div class="container py-4">
<div class="row align-items-center justify-content-between">
<a class="navbar-brand h1 text-center" href="index.do">
<span class="text-dark h4">도시</span> <span class="text-primary h4">거북</span>
</a>
</div>
<div>
<h1 class ="text-dark text-center">환영합니다!</h1>
<p class="text-center">
<span>${name}</span>님의 로그인 성공<br> 이메일 주소는 <strong>${email}</strong>입니다.
</p>
</div>
<div class="d-grid gap-2">
<button type="button" class="btn btn-primary btn-lg" onclick="location.href='index.do'">시작하기</button>
</div>
</div>
</section>
구현 화면
구현 화면입니다. 네이버 네아로 개발 가이드에서는 로그인 버튼 사용 가이드를 따르는 것을 권장합니다.
https://developers.naver.com/docs/login/bi/bi.md
버튼 이미지는 위 사이트에서 간단하게 다운로드하여 사용하실 수 있습니다. 가이드가 상세히 나와있으므로 따라서 하시면 됩니다. 지금까지 네아로 Open API 사용법을 공부해봤습니다. 오늘은 더 공부하고 싶지 않은 날이네요... 그래도 배운 점은 적어야겠죠..
사용 기술 및 언어
Spring MVC, JAVA, HTML, CSS, Bootstrap, JavaScript, JQuery, JSP, EL
배운 점
API를 처음 사용해봤는데, 외부 소스를 활용하는 것이 결코 쉽지 않은 일이라는 것을 느낄 수 있었습니다. 외부 소스를 활용해 기능 목표를 달성하기 위해서는 코드의 구조, 적용 risk, customizing요소에 대한 고려가 필요하다고 생각했습니다. 특히, 기초 개념의 중요성을 느낄 수 있었습니다. 처음에 빠르게 작업을 처리하고 싶은 마음에 무작정 API를 등록하고 사용하려고 할 때는 왜 callback url을 설정하는지 client id와 secret이 필요한지 전혀 이해하지 못한 상태였습니다. 이외에도 여러 가지 요인들이 모여 API를 적용하는 것에 계속해서 실패했었습니다. 하지만 OAuth의 개념과 흐름을 공부하고 코드들을 직접 디버깅해보면서 적용에 성공할 수 있었습니다. Open API를 사용하는 것이 누군가에게는 쉬운 작업일 수도 있지만 저에게는 문제를 해결해나가며 부족한 점을 채울 수 있는 생산적인 시간이었습니다.
+
Json Parsing을 하는 경우 다운 캐스팅을 이용해야합니다.
try catch문가 아닌 throws를 사용해 예외처리를 할 수 있었습다.
javascript 사용할 때 var name = ${name}; 이라고 선언했더니 name으로 넘어온 값을 변수로 인식하여 javascript가 멈췄습니다. 후에 "${name}"; 과 같이 따옴표로 묶어줬더니 문자열로 인식해서 값을 잘 처리하였습니다.
+
jquery로 구현했던 loginSuccess페이지를 EL식을 통해 더 짧게 구현할 수 있었습니다.
forward와 redirect의 차이점에 대해서 알 수 있었습니다.
본 글은 아래 링크의 내용을 참고하여 학습한 내용을 나름대로 정리한 글임을 밝힙니다.
OAuth
https://gdtbgl93.tistory.com/180?category=905944
네아로 개발 가이드
https://developers.naver.com/docs/login/devguide/devguide.md
https://github.com/Blackseed/NaverLoginTutorial/wiki
https://dalili.tistory.com/171
json 파싱
https://calyfactory.github.io/%EC%A0%9C%EC%9D%B4%EC%8A%A8%ED%8C%8C%EC%8B%B1/
https://ktko.tistory.com/entry/JAVA%EC%97%90%EC%84%9C-JSON-%ED%8C%8C%EC%8B%B1%ED%95%98%EA%B8%B0
캐스팅
https://computer-science-student.tistory.com/335
예외처리
https://imasoftwareengineer.tistory.com/89
forwar vs redirect