사용자들의 회원가입과 로그인을 간편하게 바꿔주는 소셜 로그인입니다.
최근에는 로컬 로그인은 구현하지 않고, 소셜 로그인으로만 구현을 하는 앱들이 늘어나고 있습니다.
Sluv은 3개의 소셜 로그인 서비스를 제공합니다.
[카카오, 구글, 애플] 어떻게 구현했는지 소개해드리겠습니다.
시작하기에 앞서 Sluv은 Client-Side-Rendering도 아니고 Server-Side-Rendering도 아닌, 네이티브(안드로이드, IOS), Client Sever(React)와 Server(Spring)이 공존하는 환경입니다.
Client-Side-Rendering 혹은 Server-Side-Rendering일 경우 처리하는 부분이 달라질 수 있습니다.
1. 로그인 정보 (Front → Kakao)
- 사용자가 로그인 정보와 동의 여부를 입력하면 Front가 Kakao 서버에게 정보들을 전달합니다.
(실제 동작은 (1) 사용자가 로그인 버튼을 누르면 Front가 Kakao에게 로그인 창을 요청해 모달을 띄우고 (2) 사용자가 정보들을 입력하면 Kakao가 바로 전달받고 (3) 인가코드를 지정된 곳(Front)으로 리다이렉트 하는 구조지만, 간략하게 표현하였습니다.)
2. 인가코드 (Kakao → Front)
- Front에게 정보를 전달받은 KaKao 서버는 인가코드를 반환합니다.
3. 인가코드 (Front → Kakao)
- Front가 전달받은 인가코드를 다시 Kakao에게 전달합니다.
4. AccessToken (Kakao → Front)
- 인가코드를 받은 Kakao 서버가 유저정보 서버에 접속할 수 있는 AccessToken을 발급해 줍니다.
5. AccessToken (Front → Back)
- 발급받은 AccessToken을 Back에게 전달해 줍니다.
======= 여기서부터 Backend의 역할이 시작됩니다. =======
6. AccessToken (Back → Kakao)
- AccessToken을 Kakao 서버에게 전달합니다.
7. 유저정보 (Kakao → Back)
- 전달받은 AccessToken을 확인하고, 유저 정보를 전달합니다.
8. App AccessToken 제작 (Back)
- 전달받은 유저 정보를 바탕으로 유저를 확인하고, 신규 회원이라는 DB에 등록합니다.
- 유저 정보를 바탕으로 App AccessToken을 제작합니다.
9. App AccessToken (Back -> Front)
- 제작한 App AccessToken을 Front에게 전달합니다.
카카오 소셜 로그인의 전체적인 플로우를 설명드렸습니다.
Backend 부분의 코드를 살펴보겠습니다.
public AuthResponseDto kakaoLogin(AuthRequestDto request) throws JsonProcessingException {
String accessToken = request.getAccessToken();
// 1. accessToken으로 user 정보 요청
SocialUserInfoDto userInfo = getKakaoUserInfo(accessToken);
// 2. user 정보로 DB 탐색 및 등록
User kakaoUser = registerKakaoUserIfNeed(userInfo);
// 3. userToken 생성
return AuthResponseDto.builder()
.token(createUserToken(kakaoUser))
.build();
}
위에서 말씀드린 흐름대로
1. AccessToken을 가지고 Kakao에게 user 정보를 요청합니다.
2. 받은 user 정보로 기존 유저인지, 신규 유저인지 보고 신규 유저라면 등록해 줍니다.
3. user 정보를 가지고 App AccessToken을 만들어 반환합니다.
6. AccessToken (Back → Kakao)
7. 유저정보 (Kakao → Back)
private SocialUserInfoDto getKakaoUserInfo(String accessToken) throws JsonProcessingException {
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + accessToken);
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
// HTTP 요청 보내기
HttpEntity<MultiValueMap<String, String>> kakaoUserInfoRequest = new HttpEntity<>(headers);
try {
RestTemplate rt = new RestTemplate();
ResponseEntity<String> response = rt.exchange(
"https://kapi.kakao.com/v2/user/me",
HttpMethod.POST,
kakaoUserInfoRequest,
String.class
);
return convertResponseToSocialUserInfoDto(response);
}catch (Exception e){
throw new InvalidateTokenException();
}
}
1. Headr에 Authorization이라는 Key에 Bearer 타입으로 AccessToken을 추가해 줍니다.
2. 유저 정보를 가지고 있는 Kakao API에게 POST로 요청합니다.
private static SocialUserInfoDto convertResponseToSocialUserInfoDto(ResponseEntity<String> response) throws JsonProcessingException {
// responseBody에 있는 정보를 꺼냄
String responseBody = response.getBody();
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = objectMapper.readTree(responseBody);
String email = jsonNode.get("kakao_account").get("email").asText();
String profileImgUrl = jsonNode.get("properties")
.get("profile_image").asText();
String gender;
try{
gender = jsonNode.get("kakao_account").get("gender").asText();
}catch (Exception e){
gender = null;
}
String ageRange;
try{
ageRange = jsonNode.get("kakao_account").get("age_range").asText();
}catch (Exception e){
ageRange = null;
}
return SocialUserInfoDto.builder()
.email(email)
.profileImgUrl(profileImgUrl)
.gender(gender)
.ageRange(ageRange)
.build();
}
3. 전달받은 정보를 Dto로 가공하여 반환합니다.
private User registerKakaoUserIfNeed(SocialUserInfoDto UserInfo) {
User user = userRepository.findByEmail(UserInfo.getEmail()).orElse(null);
if(user == null) {
userRepository.save(User.builder()
.email(UserInfo.getEmail())
.snsType(KAKAO)
.profileImgUrl(UserInfo.getProfileImgUrl())
.ageRange(UserInfo.getAgeRange())
.gender(UserInfo.getGender())
.build());
user = userRepository.findByEmail(UserInfo.getEmail())
.orElseThrow(NotFoundUserException::new);
}
return user;
}
1. 전달받은 유저 정보로 DB를 조회합니다. (기존 유저인지 확인)
2. 조회가 되지 않으면 DB에 등록해 줍니다. (신규 유저 등록)
3. 최종 유저를 반환합니다.
8. App AccessToken 제작 (Back)
9. App AccessToken (Back -> Front)
private String createUserToken(User user) {
return jwtProvider.createAccessToken(UserDto.builder().id(user.getId()).build());
}
1. 전달받은 유저를 App AccessToken으로 만들어줍니다.
public String createAccessToken(UserDto user){
Long id = user.getId();
Claims claims = Jwts.claims().setSubject(id.toString());
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidMillisecond))
.signWith(getSigninKey(secretKey), SignatureAlgorithm.HS256)
.compact();
}
2. App AccessToken은 JWTProvider의 메서드를 사용해서 만들어줍니다.
최종적으로 만들어진 App AccessToken을 Front로 전달합니다.
전체적으로 카카오 로그인과 비슷하지만 차이점이 있습니다.
달라진 부분만 설명해 드리겠습니다.
1. 카카오에서 인가코드를 전달하면 AccessToken을 반환
-> 구글에선 IdToken과 AccessToken을 모두 반환합니다.
- AccessToken은 카카오에서와 같은 역할을 하며, IdToken은 사용자의 정보가 들어있습니다.
여기서 구현할 수 있는 방법은 두 가지입니다.
a. IdToken을 Back에서 유효한 토큰인지 검증 후 사용.
b. 카카오와 같은 방식으로 AccessToken을 이용.
Sluv은 IdToken을 사용하였습니다.
2.IdToken (Front -> Back)
- IdToken을 Back에게 전달합니다.
======= 여기서부터 Backend의 역할이 시작됩니다. =======
3. IdToken 인증
- IdToken의 유효성, 신뢰성 등을 검사합니다.
4. App AccessToken 제작 (Back)
- 전달받은 유저 정보를 바탕으로 유저를 확인하고, 신규 회원이라는 DB에 등록합니다.
- 유저 정보를 바탕으로 App AccessToken을 제작합니다.
5. App AccessToken (Back -> Front)
- 제작한 App AccessToken을 Front에게 전달합니다.
구글 소셜 로그인의 전체적인 플로우를 설명드렸습니다.
Backend 부분의 코드를 살펴보겠습니다.
public AuthResponseDto googleLogin(AuthRequestDto request) {
String idToken = request.getAccessToken();
// 1. idToken 검증
SocialUserInfoDto verifiedIdToken = verifyIdToken(idToken);
// 2. user 정보로 DB 탐색 및 등록
User googleUser = registerGoogleUserIfNeed(verifiedIdToken);
// 3. userToken 생성
return AuthResponseDto.builder()
.token(createUserToken(googleUser))
.build();
}
1. idToken의 유효성과 신뢰성을 검사합니다.
2. idToken이 검사를 통과했다면, idToken의 경로를 바탕으로 DB 검색 및 등록을 합니다.
3. user 정보를 가지고 App AccessToken을 만들어 반환합니다.
3. IdToken 인증
private SocialUserInfoDto verifyIdToken(String idToken){
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), GsonFactory.getDefaultInstance())
.setAudience(Arrays.asList(CLIENT_ANDROID, CLIENT_APPLE))
.build();
try {
GoogleIdToken verifiedIdToken = verifier.verify(idToken);
return convertResponseToSocialUserInfoDto(verifiedIdToken);
}catch (Exception e){
throw new InvalidateTokenException();
}
}
IdToken 인증은 Google 라이브러리를 사용합니다.
implementation 'com.google.api-client:google-api-client:2.2.0'
해당 종속성을 추가하면 GoogleIdTokenVerifier를 사용할 수 있습니다.
인증이 끝나면 Dto로 변경해서 반환합니다.
private static SocialUserInfoDto convertResponseToSocialUserInfoDto(GoogleIdToken idToken) {
// 정보를 꺼냄
String email = idToken.getPayload().getEmail();
String profileImgUrl = (String) idToken.getPayload().get("picture");
//Google에서 성별과 연령대 정보를 제공하지 않는 것 같음
return SocialUserInfoDto.builder()
.email(email)
.profileImgUrl(profileImgUrl)
.gender(null)
.ageRange(null)
.build();
}
4. App AccessToken 제작 (Back)
private User registerGoogleUserIfNeed(SocialUserInfoDto googleUserInfoDto) {
User user = userRepository.findByEmail(googleUserInfoDto.getEmail()).orElse(null);
if(user == null) {
userRepository.save(User.builder()
.email(googleUserInfoDto.getEmail())
.snsType(GOOGLE)
.profileImgUrl(googleUserInfoDto.getProfileImgUrl())
.ageRange(googleUserInfoDto.getAgeRange())
.gender(googleUserInfoDto.getGender())
.build());
user = userRepository.findByEmail(googleUserInfoDto.getEmail())
.orElseThrow(NotFoundUserException::new);
}
return user;
}
카카오와 로직은 동일합니다.
1. 전달받은 유저 정보로 DB를 조회합니다. (기존 유저인지 확인)
2. 조회가 되지 않으면 DB에 등록해 줍니다. (신규 유저 등록)
3. 최종 유저를 반환합니다.
5. App AccessToken
private String createUserToken(User user) {
return jwtProvider.createAccessToken(UserDto.builder().id(user.getId()).build());
}
public String createAccessToken(UserDto user){
Long id = user.getId();
Claims claims = Jwts.claims().setSubject(id.toString());
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidMillisecond))
.signWith(getSigninKey(secretKey), SignatureAlgorithm.HS256)
.compact();
}
이 부분 또한 카카오와 동일합니다.
1. 전달받은 유저를 App AccessToken으로 만들어줍니다.
2. App AccessToken은 JWTProvider의 메서드를 사용해서 만들어줍니다.
최종적으로 만들어진 App AccessToken을 Front로 전달합니다.
전체적인 플로우는 구글 로그인과 동일합니다.
하지만 identityToken을 인증하는 방법이 구글과 차이점이 있습니다.
public AuthResponseDto appleLogin(AuthRequestDto request) throws Exception {
String identityToken = request.getAccessToken();
// 1. 검증
if(!verifyIdToken(identityToken)){
throw new InvalidateTokenException();
}
// 2. UserIngoDto 생성
SocialUserInfoDto userInfo = getAppleUserInfo(identityToken);
// 3. idToken의 정보로 DB 탐색 및 등록
User appleUser = registerAppleUserIfNeed(userInfo);
// 4. userToken 생성
return AuthResponseDto.builder()
.token(createUserToken(appleUser))
.build();
}
private boolean verifyIdToken(String identityToken) throws Exception{
String[] pieces = identityToken.split("\\.");
if (pieces.length != 3) {
return false;
}
String header = new String(Base64.getUrlDecoder().decode(pieces[0]));
String payload = new String(Base64.getUrlDecoder().decode(pieces[1]));
JsonNode headerNode = objectMapper.readTree(header);
JsonNode payloadNode = objectMapper.readTree(payload);
String algorithm = headerNode.get("alg").asText();
String idKid = headerNode.get("kid").asText();
if (!algorithm.equals("RS256")) {
return false;
}
String iss = payloadNode.get("iss").asText();
if (!iss.equals(issUrl)) {
return false;
}
String aud = payloadNode.get("aud").asText();
if (!aud.equals(this.clientId)) {
return false;
}
long exp = payloadNode.get("exp").asLong();
if (exp < System.currentTimeMillis() / 1000) {
throw new ExpiredTokenException();
}
if(getPublicKeyFromPEM(identityToken, idKid) == null){
return false;
}
return true;
}
구글은 IdToken을 라이브러리를 사용해서 인증했지만, 애플은 라이브러리가 없으므로 직접 구현해줘야 합니다.
해당 부분을 제외하곤 구글 로그인과 동일하게 구현하였습니다.
이 부분 때문에 소셜 로그인을 구현할 때 Apple 로그인 구현이 가장 어렵다고 알려져 있습니다.
private SocialUserInfoDto getAppleUserInfo(String identityToken) throws JsonProcessingException {
String[] pieces = identityToken.split("\\.");
String payload = new String(Base64.getUrlDecoder().decode(pieces[1]));
JsonNode jsonNode = objectMapper.readTree(payload);
String email = jsonNode.get("email").asText();
String profileImgUrl;
try{
profileImgUrl = jsonNode.get("picture").asText();
}catch (Exception e){
profileImgUrl = null;
}
String gender;
try{
gender = jsonNode.get("gender").asText();
}catch (Exception e){
gender = null;
}
String ageRange;
try{
ageRange = jsonNode.get("birthdate").asText();
}catch (Exception e){
ageRange = null;
}
return SocialUserInfoDto.builder()
.email(email)
.profileImgUrl(profileImgUrl)
.gender(gender)
.ageRange(ageRange)
.build();
}
private User registerAppleUserIfNeed(SocialUserInfoDto userInfoDto) {
User user = userRepository.findByEmail(userInfoDto.getEmail()).orElse(null);
if(user == null) {
userRepository.save(User.builder()
.email(userInfoDto.getEmail())
.snsType(APPLE)
.profileImgUrl(userInfoDto.getProfileImgUrl())
.ageRange(userInfoDto.getAgeRange())
.gender(userInfoDto.getGender())
.build());
user = userRepository.findByEmail(userInfoDto.getEmail())
.orElseThrow(NotFoundUserException::new);
}
return user;
}
private String createUserToken(User user) {
return jwtProvider.createAccessToken(UserDto.builder().id(user.getId()).build());
}
public String createAccessToken(UserDto user){
Long id = user.getId();
Claims claims = Jwts.claims().setSubject(id.toString());
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidMillisecond))
.signWith(getSigninKey(secretKey), SignatureAlgorithm.HS256)
.compact();
}
이렇게 최종적으로 App AccessToken을 만들어 Front로 전달합니다.
스럽 서버 멀티모듈 전환기 (0) | 2024.09.30 |
---|---|
정보 공유 게시글의 조회 성능을 개선해보자! (1) | 2024.01.22 |
비동기 처리를 통해 검색 성능을 개선해보자! (0) | 2023.10.07 |
사진을 빠르고 안전하게!! PreSigned URL (0) | 2023.07.29 |
AOP!! Exception을 잡아줘!! (0) | 2023.04.29 |
댓글 영역