대표적인 소셜로그인이라면 구글, 페이스북, 카카오, 네이버 정도가 있겠다. 이 4개의 소셜로그인 구현은 대부분 비슷한 프로세스를 가진다.
- 클라이언트단에서 사용자가 로그인을 성공하면 인가코드를 받아온다.
- 해당 코드를 통해 소셜로그인 api 서버에 토큰을 요청한다.
- 토큰을 받아오면 해당 토큰으로 소셜로그인 resource 서버에 정보를 요청한다.
그렇다면 공통되는 저 세가지의 큰 프로세스를 템플릿으로 나눌 수 없을까? 하는 생각에서 구현을 했다.
protected abstract String getAccessToken(String code);
--- 1
protected abstract HashMap<String, String> getUserInfoFromKakaoResource(String accessToken);
--- 2
protected abstract HashMap<String, String> setUserInfo(JsonElement userInfoJson);
--- 3
protected abstract ModelAndView setLogin(HashMap<String, String> userInfo);
--- 4
- 인가 코드를 이용해서 토큰을 받아오는 메서드
- 해당 토큰으로
response
을 받아오는 메서드- 받아온
response
에서 유저 정보를 가져오는 메서드- 해당 정보로 로그인을 진행하는 메서드
각 소셜로그인 마다 요청의 방법이 조금씩 다르므로 추상메서드로 선언해줫다. 그 후에 이 네가지의 로직을 처리해줄 공통 메서드를 만든다.
public ModelAndView socialLogin(String code) {
String accessToken = getAccessToken(code); --- 1
HashMap<String, String>userInfo = getUserInfoFromKakaoResource(accessToken); --- 2
return setLogin(userInfo); --- 3
}
- 소셜로그인이 성공했다는 가정 하에
getAccessToken(code)
메서드에서 토큰을 받아온다.- 두번째
getUserInfoFromKakaoResource(accessToken)
메서드에서 유저 정보를 맵 형식으로 받아온다.- 마지막
setLogin(userInfo)
에서 로그인 처리를 해준다.
그리고 json 객체를 파싱해줄 메서드를 공통 메서드로 분리했다.
protected String asString(String data,String dataname) {
try{
JsonParser parser = new JsonParser();
JsonElement element = parser.parse(data);
return element.getAsJsonObject().get(dataname).getAsString();
} catch(Exception e) {
log.error("not JsonObject");
}
return "not JsonObject";
}
@Slf4j
public abstract class SocialLoginServiceTemplate {
@Autowired
protected BCryptPasswordEncoder bCryptPasswordEncoder;
@Autowired
protected LoginDAO loginDAO;
public ModelAndView socialLogin(String code) {
String accessToken = getAccessToken(code);
HashMap<String, String>userInfo = getUserInfoFromKakaoResource(accessToken);
return setLogin(userInfo);
}
protected abstract String getAccessToken(String code);
protected abstract HashMap<String, String> getUserInfoFromKakaoResource(String accessToken);
protected abstract HashMap<String, String> setUserInfo(JsonElement userInfoJson);
protected abstract ModelAndView setLogin(HashMap<String, String> userInfo);
protected String asString(String data,String dataname) {
try{
JsonParser parser = new JsonParser();
JsonElement element = parser.parse(data);
return element.getAsJsonObject().get(dataname).getAsString();
} catch(Exception e) {
log.error("not JsonObject");
}
return "not JsonObject";
}
}
DAO객체 또한 공통으로 사용해야 하므로 Template단에서 선언해줫다.
각각 위에서 만든 SocialLoginServiceTemplate
을 상속받는 클래스를 만들어준다.
<KAKAO>
@Service
public class KakaoLoginService extends SocialLoginServiceTemplate
<NAVER>
@Service
public class NaverLoginService extends SocialLoginServiceTemplate
<KAKAO>
@Override
protected String getAccessToken(String code) {
HttpHeaders headers = new HttpHeaders();
headers.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
MultiValueMap<String, String> accessTokenBodyInfo = new LinkedMultiValueMap<String, String>();
accessTokenBodyInfo.add("grant_type", "authorization_code");
accessTokenBodyInfo.add("client_id", "${REST_API_KEY}");
accessTokenBodyInfo.add("redirect_uri", "http://localhost:8080/kakao.lo");
accessTokenBodyInfo.add("code", code);
ResponseEntity<String> response = new RestTemplate().exchange(
"https://kauth.kakao.com/oauth/token",
HttpMethod.POST,
new HttpEntity<MultiValueMap<String, String>>(accessTokenBodyInfo, headers),
String.class
);
return asString(response.getBody(),"access_token");
}
----------------------------------------------------------------------------------
<NAVER>
@Override
protected String getAccessToken(String code) {
HttpHeaders headers = new HttpHeaders();
headers.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
MultiValueMap<String, String> accessTokenBodyInfo = new LinkedMultiValueMap<String, String>();
accessTokenBodyInfo.add("grant_type", "authorization_code");
accessTokenBodyInfo.add("client_id", "${client_id}");
accessTokenBodyInfo.add("client_secret", "${client_secret}");
accessTokenBodyInfo.add("code", code);
accessTokenBodyInfo.add("state","STATE_STRING");
ResponseEntity<String> response = new RestTemplate().exchange(
"https://nid.naver.com/oauth2.0/token",
HttpMethod.POST,
new HttpEntity<MultiValueMap<String, String>>(accessTokenBodyInfo, headers),
String.class
);
return asString(response.getBody(),"access_token");
}
카카오, 네이버 두 개의 소셜로그인의 토큰을 받아오는 메서드이다. 세부 구현은 약간씩 다르지만 큰 틀은 비슷한 것을 볼 수 있다.
<KAKAO>
@Override
protected HashMap<String, String> getUserInfoFromKakaoResource(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer "+accessToken);
headers.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
ResponseEntity<String> response = new RestTemplate().exchange(
"https://kapi.kakao.com/v2/user/me",
HttpMethod.POST,
new HttpEntity<String>(headers),
String.class
);
return setUserInfo(new JsonParser().parse(response.getBody()));
}
-----------------------------------------------------------------------------------------------
<NAVER>
@Override
protected HashMap<String, String> getUserInfoFromKakaoResource(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer "+accessToken);
headers.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
ResponseEntity<String> response = new RestTemplate().exchange(
"https://openapi.naver.com/v1/nid/me",
HttpMethod.POST,
new HttpEntity<String>(headers),
String.class
);
return setUserInfo(new JsonParser().parse(response.getBody()));
}
두 소셜로그인 모두 세부적인 구현만 다르고 큰 틀은 같은것을 볼 수 있다. 마지막에 공통메서드인
setUserInfo()
를 통해 맵을 반환하는 것을 볼 수 있다.
<KAKAO>
@Override
protected HashMap<String, String> setUserInfo(JsonElement userInfoJson) {
HashMap<String, String> userInfo = new HashMap<String, String>();
userInfo.put("provider", "KAKAO");
userInfo.put("providerId", userInfoJson.getAsJsonObject().get("id").getAsString());
userInfo.put("username", "KAKAO"+"_"+userInfoJson.getAsJsonObject().get("id").getAsString());
userInfo.put("password", "null");
return userInfo;
}
-----------------------------------------------------------------------------------------------
<NAVER>
@Override
protected HashMap<String, String> setUserInfo(JsonElement userInfoJson) {
HashMap<String, String> userInfo = new HashMap<String, String>();
userInfo.put("provider", "NAVER");
userInfo.put("providerId", userInfoJson.getAsJsonObject().get("response").getAsJsonObject().get("id").getAsString());
userInfo.put("username", "NAVER"+"_"+userInfoJson.getAsJsonObject().get("response").getAsJsonObject().get("id").getAsString());
userInfo.put("password", "null");
return userInfo;
}
각 소셜로그인의 유저 정보의 json형태는 다르기 때문에 각 소셜로그인에 맞춰서 json객체를 String으로 뽑아준 후 맵키를 동일하게 하고 해당 데이터를 넣어줘서 반환하는 것을 볼 수 있다.
<KAKAO>
@Override
protected ModelAndView setLogin(HashMap<String, String> userInfo) {
Member checkMember = loginDAO.findUser(userInfo.get("username"));
if(checkMember==null) {
checkMember = Member.builder()
.username(userInfo.get("username"))
.password(bCryptPasswordEncoder.encode(userInfo.get("password")))
.nickName("null")
.email("null")
.status("Y")
.orange(0L)
.role("ROLE_USER")
.provider(userInfo.get("provider"))
.providerId(userInfo.get("providerId"))
.build();
loginDAO.registerMember(checkMember);
}
Member member = loginDAO.findUser(checkMember.getUsername());
ModelAndView mv = new ModelAndView();
mv.addObject("username", member.getUsername());
mv.addObject("password", member.getPassword());
mv.setViewName("/login/social");
return mv;
}
-------------------------------------------------------------------------------------------------
<NAVER>
@Override
protected ModelAndView setLogin(HashMap<String, String> userInfo) {
Member checkMember = loginDAO.findUser(userInfo.get("username"));
if(checkMember==null) {
checkMember = Member.builder()
.username(userInfo.get("username"))
.password(bCryptPasswordEncoder.encode(userInfo.get("password")))
.nickName("null")
.email("null")
.status("Y")
.orange(0L)
.role("ROLE_USER")
.provider(userInfo.get("provider"))
.providerId(userInfo.get("providerId"))
.build();
loginDAO.registerMember(checkMember);
}
Member member = loginDAO.findUser(checkMember.getUsername());
ModelAndView mv = new ModelAndView();
mv.addObject("username", member.getUsername());
mv.addObject("password", member.getPassword());
mv.setViewName("/login/social");
return mv;
}
두 메서드 동일하게 기존의 유저가 없으면 강제로 회원가입을 진행 후에 model 객체로 담아 주었다.
@Service
@Slf4j
public class KakaoLoginService extends SocialLoginServiceTemplate{
@Override
protected String getAccessToken(String code) {
HttpHeaders headers = new HttpHeaders();
headers.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
MultiValueMap<String, String> accessTokenBodyInfo = new LinkedMultiValueMap<String, String>();
accessTokenBodyInfo.add("grant_type", "authorization_code");
accessTokenBodyInfo.add("client_id", "${REST_API_KEY}");
accessTokenBodyInfo.add("redirect_uri", "http://localhost:8080/kakao.lo");
accessTokenBodyInfo.add("code", code);
ResponseEntity<String> response = new RestTemplate().exchange(
"https://kauth.kakao.com/oauth/token",
HttpMethod.POST,
new HttpEntity<MultiValueMap<String, String>>(accessTokenBodyInfo, headers),
String.class
);
return asString(response.getBody(),"access_token");
}
@Override
protected HashMap<String, String> getUserInfoFromKakaoResource(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer "+accessToken);
headers.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
ResponseEntity<String> response = new RestTemplate().exchange(
"https://kapi.kakao.com/v2/user/me",
HttpMethod.POST,
new HttpEntity<String>(headers),
String.class
);
return setUserInfo(new JsonParser().parse(response.getBody()));
}
@Override
protected HashMap<String, String> setUserInfo(JsonElement userInfoJson) {
HashMap<String, String> userInfo = new HashMap<String, String>();
userInfo.put("provider", "KAKAO");
userInfo.put("providerId", userInfoJson.getAsJsonObject().get("id").getAsString());
userInfo.put("username", "KAKAO"+"_"+userInfoJson.getAsJsonObject().get("id").getAsString());
userInfo.put("password", "null");
return userInfo;
}
@Override
protected ModelAndView setLogin(HashMap<String, String> userInfo) {
Member checkMember = loginDAO.findUser(userInfo.get("username"));
if(checkMember==null) {
checkMember = Member.builder()
.username(userInfo.get("username"))
.password(bCryptPasswordEncoder.encode(userInfo.get("password")))
.nickName("null")
.email("null")
.status("Y")
.orange(0L)
.role("ROLE_USER")
.provider(userInfo.get("provider"))
.providerId(userInfo.get("providerId"))
.build();
loginDAO.registerMember(checkMember);
}
Member member = loginDAO.findUser(checkMember.getUsername());
ModelAndView mv = new ModelAndView();
mv.addObject("username", member.getUsername());
mv.addObject("password", member.getPassword());
mv.setViewName("/login/social");
return mv;
}
}
@Service
public class NaverLoginService extends SocialLoginServiceTemplate{
@Override
protected String getAccessToken(String code) {
HttpHeaders headers = new HttpHeaders();
headers.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
MultiValueMap<String, String> accessTokenBodyInfo = new LinkedMultiValueMap<String, String>();
accessTokenBodyInfo.add("grant_type", "authorization_code");
accessTokenBodyInfo.add("client_id", "${client_id}");
accessTokenBodyInfo.add("client_secret", "${client_secret}");
accessTokenBodyInfo.add("code", code);
accessTokenBodyInfo.add("state","STATE_STRING");
ResponseEntity<String> response = new RestTemplate().exchange(
"https://nid.naver.com/oauth2.0/token",
HttpMethod.POST,
new HttpEntity<MultiValueMap<String, String>>(accessTokenBodyInfo, headers),
String.class
);
return asString(response.getBody(),"access_token");
}
@Override
protected HashMap<String, String> getUserInfoFromKakaoResource(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer "+accessToken);
headers.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
ResponseEntity<String> response = new RestTemplate().exchange(
"https://openapi.naver.com/v1/nid/me",
HttpMethod.POST,
new HttpEntity<String>(headers),
String.class
);
System.out.println(response.getBody());
return setUserInfo(new JsonParser().parse(response.getBody()));
}
@Override
protected HashMap<String, String> setUserInfo(JsonElement userInfoJson) {
HashMap<String, String> userInfo = new HashMap<String, String>();
userInfo.put("provider", "NAVER");
userInfo.put("providerId", userInfoJson.getAsJsonObject().get("response").getAsJsonObject().get("id").getAsString());
userInfo.put("username", "NAVER"+"_"+userInfoJson.getAsJsonObject().get("response").getAsJsonObject().get("id").getAsString());
userInfo.put("password", "null");
return userInfo;
}
@Override
protected HashMap<String, String> setUserInfo(JsonElement userInfoJson) {
HashMap<String, String> userInfo = new HashMap<String, String>();
userInfo.put("provider", "KAKAO");
userInfo.put("providerId", userInfoJson.getAsJsonObject().get("id").getAsString());
userInfo.put("username", "KAKAO"+"_"+userInfoJson.getAsJsonObject().get("id").getAsString());
userInfo.put("password", "null");
return userInfo;
}
@Override
protected ModelAndView setLogin(HashMap<String, String> userInfo) {
Member checkMember = loginDAO.findUser(userInfo.get("username"));
if(checkMember==null) {
checkMember = Member.builder()
.username(userInfo.get("username"))
.password(bCryptPasswordEncoder.encode(userInfo.get("password")))
.nickName("null")
.email("null")
.status("Y")
.orange(0L)
.role("ROLE_USER")
.provider(userInfo.get("provider"))
.providerId(userInfo.get("providerId"))
.build();
loginDAO.registerMember(checkMember);
}
Member member = loginDAO.findUser(checkMember.getUsername());
ModelAndView mv = new ModelAndView();
mv.addObject("username", member.getUsername());
mv.addObject("password", member.getPassword());
mv.setViewName("/login/social");
return mv;
}
}
두개의 소셜로그인을 공통 템플릿을 하나 만들어서 각각 구현했는데 확장성이나 유연성에서 훨씬 편한느낌이었다. 추후에 구글이나 페이스북 로그인을 추가한다면 해당 템플릿 틀에서 어렵지 않게 구현이 가능해졌다.