요즘 어느 사이트든 기본인 소셜 로그인을 구현해봐야겠다는 생각이 들어서 도전!
기본적으로 먼저 카카오 디벨로퍼에서 애플리케이션을 등록해주어야 한다.
📌 Kakao Developers 애플리케이션 등록
- Kakao Developers에 접속하여 로그인
- '내 애플리케이션' 으로 이동하여 '애플리케이션 추가'를 클릭하여 생성
- 플랫폼 설정
- Web 플랫폼을 추가하고 도메인을 설정
- 배포 전이라면 localhost:3000을 설정하고, 배포 후에는 배포한 도메인도 추가해야한다.
- Redirectr URI 설정 : 사용자가 카카오 로그인을 완료한 후 리디렉션될 URL을 설정
[ 전체 과정 ]
처음 구현해본 소셜 로그인이기에 직접 플로우차트를 그려보며 이해하려 노력했다.
- FrontEnd: 카카오로부터 인가코드를 받고, 해당 인가코드를 백엔드에게 넘겨줌
- BackEnd: 프론트로부터 인가코드를 받고, 카카오로부터 인증받아 토큰을 발급받음. 그리고 해당 토큰에 담긴 사용자 정보를 활용해서 프로젝트 전용 토큰을 발행 후, 프론트에게 돌려준다.
- 카카오 토큰을 그대로 클라이언트에게 넘겨주고 사용하게 하면 안되는 점을 주의!!!!!!
[ 클라이언트측 ]
● 카카오 로그인 버튼 구현: 사용자가 카카오 로그인을 시작할 수 있는 버튼을 UI에 추가. 이 버튼은 클릭되면 카카오 로그인 URL로 이동하도록 설정해준다.
● 카카오 로그인 URL 생성: 클라이언트는 카카오 로그인 URL을 생성하여 사용자를 카카오 인증 서버로 리디렉션한다. 이 때, 로그인 후 리디렉션할 클라이언트 측 URL도 함께 설정한다.
● 코드 수신 및 백엔드로 전송: 카카오에서 제공하는 코드(code)를 클라이언트가 받아옴. 이 코드를 백엔드의 카카오 로그인 엔드포인트로 전송. 전송할 때는 POST 메서드를 사용하고, 필요한 경우 헤더에 토큰을 추가하여 인증을 수행한다.
● 서버로부터 응답 처리: 백엔드에서 처리된 결과를 클라이언트가 받아와서 처리함. 성공적인 로그인 후에는 사용자 정보를 저장하고 세션을 유지하기 위한 작업을 수행. 실패한 경우에는 에러 메시지를 표시하거나 다시 로그인을 유도하는 등의 처리를 해준다.
● 리디렉션: 로그인 성공 시 메인 페이지로 리디렉션하거나, 실패 시 에러 메시지를 표시하고 다시 로그인 페이지로 이동하도록 설정.
1. Kakao Developers에서 애플리케이션 설정
- 애플리케이션 생성
- 플랫폼 추가와 도메인 설정
2. 환경 변수 파일 설정
env를 사용하여 키를 저장해줬다.
이렇게 하면 중요한 API 키와 같은 데이터를 코드에 하드코딩하지 않고 환경 변수로 관리할 수 있다.
주의할 점은
꼭 process.env.REACT_APP 으로 시작해야한다는 점!!!
const kakaoAPI = process.env.REACT_APP_KAKAO_REST_API_KEY;
3. 환경 변수를 사용하여 카카오 API 키 가져오기
환경 변수를 사용해 카카오 API키를 가져오고
로그인 링크를 생성하여 카카오 로그인 페이지로 리디렉션한다.
const kakaoAPI = process.env.REACT_APP_KAKAO_REST_API_KEY;
<a href={`https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=${kakaoAPI}&redirect_uri=http://localhost:3000/login/oauth2/code/kakao`} className="kakao-login-link">
카카오 로그인
</a>
https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=${REST_API_KEY}&redirect_uri={REDIRECT_URI}
이 주소로 접근하면 인가 코드를 받아올 수 있다.
client_id와 redirect_uri를 받아와서 {REST_API_KEY}와 {REDIRECT_URI} 에 넣어주어야 한다.
clien_id : kakao developers에서 내 애플리케이션을 추가했을 때 생기는 REST_API_KEY를 넣어줌
redirect_uri : 카카오 로그인 메뉴에 들어가서 추가를 해줘야 함
4. 카카오 로그인 화면
사용자가 카카오 로그인 버튼을 클릭하면 카카오 로그인 화면으로 이동한다.
로그인 화면에서 이메일과 비밀번호를 입력하고 동의하면, 설정한 'Redirect_URI' 로 이동된다.
이 화면에서 모든 과정이 처리된다. 물론 사용자 눈에는 보이지 않지만!
'Redirect_URI' 화면에서 인가 코드가 URL 파라미터로 전달된다.
주소창을 보면 Redirect_URL 뒤에 파라미터로 ?code=#$#$# 라고 인가 코드가 넘어온 것을 확인할 수 있다!
5. 파라미터로 넘어온 인가 코드 가져오기
이렇게 파라미터로 넘어온 인가 코드를 가져와야 한다.
window.location.href를 사용할 수도 있지만, React Router의 useLocation 훅을 사용하면 더욱 간편하게 처리할 수 있다.
KakaoLogin.js
리디렉션 URL에서 인가 코드를 받아 백엔드로 전송하는 컴포넌트를 작성해준다.
나는 아래와 같이 파라미터로 넘어온 인가 코드를 useLocation훅을 통해 가져와 code 라는 변수에 담아왔다.
그리고 이 인가 코드를 받아오면 axios 통신을 사용해 백엔드에게 넘겨준다.
6. App.js에서 경로 설정
App.js에서 해당 컴포넌트 경로를 설정하는 것도 잊지맙시다!
[ 백엔드 측 ]
이제 다음 단계를 백엔드 측에서 처리해야 한다.
인가 코드를 받아 토큰을 발급받고 → 그 토큰을 사용하여 사용자 정보를 가져온 후 → 클라이언트에게 응답
1. 인가 코드로 토큰 발급 받기
클라이언트로부터 받은 인가 코드를 사용해 카카오 인증 서버와 통신하여 인가코드를 주고 토큰을 받아와야 함
이 작업을 하기 위하여 KakaoOAuthService 클래스를 생성해줬다.
package com.example.demo.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.Map;
// 1. Kakao OAuth 서비스와 통신하여 access token을 얻는 역할을 하는 서비스 클래스
@Service
public class KakaoOAuthService {
// application.properties에서 kakao.client-id 값을 가져와 clientId 변수에 주입
@Value("${kakao.client-id}")
private String clientId; // 클라이언트 ID를 저장
// application.properties에서 kakao.redirect-uri 값을 가져와 redirectUri 변수에 주입
@Value("${kakao.redirect-uri}")
private String redirectUri; // 리다이렉트 URI를 저장
// application.properties에서 kakao.client-secret 값을 가져와 clientSecret 변수에 주입
@Value("${kakao.client-secret}")
private String clientSecret;
// code를 입력으로 받아 access token을 반환하는 메소드
public String getAccessToken(String code) {
// Kakao OAuth 토큰 요청 URL을 정의
String tokenUrl = "https://kauth.kakao.com/oauth/token";
// HTTP 요청을 보내기 위해 Spring의 RestTemplate 객체를 생성
RestTemplate restTemplate = new RestTemplate();
// HTTP 요청에 사용할 헤더를 생성
HttpHeaders headers = new HttpHeaders();
// Content-Type 헤더를 설정. 여기서는 URL 인코딩된 폼 데이터를 사용
headers.set("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
// 주어진 URL을 사용하여 URI 빌더를 생성
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(tokenUrl)
// grant_type 파라미터를 추가. 여기서는 authorization_code를 사용
.queryParam("grant_type", "authorization_code")
// client_id 파라미터를 추가하고 clientId 값을 사용
.queryParam("client_id", clientId)
// redirect_uri 파라미터를 추가하고 redirectUri 값을 사용
.queryParam("redirect_uri", redirectUri)
// code 파라미터를 추가하고 메소드의 매개변수 code 값을 사용
.queryParam("code", code)
// client_secret 파라미터를 추가하고 clientSecret 값을 사용
.queryParam("client_secret", clientSecret);
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("client_id", clientId);
params.add("redirect_uri", redirectUri);
params.add("code", code);
params.add("client_secret", clientSecret);
// 헤더를 포함한 HTTP 엔티티를 생성
HttpEntity<?> entity = new HttpEntity<>(headers);
// HTTP 요청을 보내고 응답을 받아옴
ResponseEntity<Map> response = restTemplate.exchange(
builder.toUriString(), // 완성된 URI 문자열을 가져옴
HttpMethod.POST, // HTTP 메소드는 POST를 사용
entity, // 요청 엔티티를 사용
Map.class // 응답 바디를 맵 형태로 변환
);
// 응답 상태 코드가 HTTP 200 OK인지 확인
if (response.getStatusCode() == HttpStatus.OK) {
// 응답 바디를 맵 형태로 가져옴
Map<String, Object> responseBody = response.getBody();
// 응답 바디에서 access_token 값을 가져와 문자열로 반환
return (String) responseBody.get("access_token");
} else {
throw new RuntimeException("kakao로부터 token 가져오기 실패");
}
}
}
2. 받아온 토큰을 사용해 사용자 정보 가져오기
// 2. accessToken을 입력으로 받아 사용자 정보를 포함하는 맵을 반환
public Map<String, Object> getUserInfo(String accessToken) {
// 사용자 정보 요청을 위한 Kakao API URL을 정의
String userInfoUrl = "https://kapi.kakao.com/v2/user/me";
// HTTP 요청을 보내기 위해 Spring의 RestTemplate 객체를 생성
RestTemplate restTemplate = new RestTemplate();
// HTTP 요청에 사용할 헤더를 생성
HttpHeaders headers = new HttpHeaders();
// Authorization 헤더를 설정. Bearer 토큰 방식으로 accessToken을 사용
headers.set("Authorization", "Bearer " + accessToken);
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
// headers.setBearerAuth(accessToken);
// 헤더를 포함한 HTTP 엔티티를 생성
HttpEntity<?> entity = new HttpEntity<>(headers);
// HTTP 요청을 보내고 응답을 받아옴
ResponseEntity<Map> response = restTemplate.exchange(
userInfoUrl, // 사용자 정보 요청 URL을 사용
HttpMethod.POST, // HTTP 메소드는 GET을 사용
entity, // 요청 엔티티를 사용
Map.class // 응답 바디를 맵 형태로 변환
);
if (response.getStatusCode() == HttpStatus.OK) {
// 응답 바디를 반환. 여기에는 사용자 정보가 포함되어 있음.
return response.getBody();
} else {
throw new RuntimeException("Failed to get user info from Kakao");
}
}
3. 사용자 정보를 바탕으로 회원가입 또는 로그인 처리
가져온 사용자 정보를 사용하여 데이터베이스에 사용자를 저장하거나 로그인 처리를 해줌
UserService.java
/**
*
기능 : 카카오 로그인
*/
// 3. userInfo 맵을 입력으로 받아 JWT 토큰을 반환하는 메서드
public JwtToken kakaoLogin(Map<String, Object> userInfo) {
// userInfo 맵에서 Kakao ID를 가져와 문자열로 변환
String kakaoId = userInfo.get("id").toString();
// kakaoId를 사용하여 데이터베이스에서 사용자를 조회
UserEntity user = userRepository.findByKakaoId(kakaoId)
// 만약 사용자가 존재하지 않으면 -> 새로운 사용자를 생성
.orElseGet(() -> {
UserEntity newUser = new UserEntity();
newUser.setKakaoId(kakaoId);
newUser.setUserId(kakaoId); // KakaoID를 userId로 설정
// 사용자에게 UserRole 객체를 생성하고 이를 UserEntity의 역할 목록에 추가한 후 저장
List<UserRole> roleList = new ArrayList<>(); // UserRole 객체들을 저장할 ArrayList 생성
UserRole userRole = new UserRole(); // 새로운 UserRole 객체 생성
userRole.setRoleId(1L); // 새로 생성한 UserRole 객체의 role_id를 설정
roleList.add(userRole); // 생성한 UserRole 객체를 roleList에 추가
newUser.setRoleList(roleList); // UserEntity에 roleList를 설정
return userRepository.save(newUser);
});
// 사용자 정보로부터 Spring Security의 UserDetails 객체 생성
UserDetails userDetails = createUserDetails(user);
// UserDetails 객체를 사용하여 Authentication 객체 생성
Authentication authentication = createAuthentication(userDetails);
// JWT 토큰 생성
return jwtTokenProvider.generateToken(authentication);
}
4. 우리 서버 전용 JWT 토큰 생성 및 반환
사용자 정보를 바탕으로 JWT 토큰을 생성하고 이를 클라이언트에게 반환해줌
UserController.java
/**
기능 : 카카오로그인
url : /kakaoLogin
request Data :
Response Data : 로그인 성공
*/
@PostMapping("/kakaoLogin")
public ResponseEntity<Map<String, String>> kakaoLogin(@RequestBody String code) {
JSONParser parser = new JSONParser();
String kakaoCode;
try {
JSONObject object = (JSONObject) parser.parse(code);
kakaoCode = (String) object.get("code");
} catch (ParseException e) {
return ResponseEntity.badRequest().build();
}
String accessToken = kakaoOAuthService.getAccessToken(kakaoCode);
Map<String, Object> userInfo = kakaoOAuthService.getUserInfo(accessToken);
JwtToken jwtToken = userService.kakaoLogin(userInfo);
Map<String, String> response = new HashMap<>();
response.put("accessToken", jwtToken.getAccessToken());
return ResponseEntity.ok(response);
}
[ 추가 설정 ]
1. UserEntity 에 'kakaoId' 필드 추가
private String kakaoId;
카카오 아이디를 저장하기 위해 새로 추가 해주었다.
근데...굳이 추가 안해줘도 되나? 싶기는 함
추후에 필요없다 싶으면 다시 삭제하고 기존 userId와 융합해서 사용할 수 있도록 하던지 해야겠다.
2. UserRepository 에 findByKakaoId 메서드 추가
데이터베이스에 이미 KakaoId가 존재하는지 조회하기 위한 메서드를 추가해줬다.
UserService 클래스에서 UserRepository를 사용하여 findByKakaoId 메서드를 호출하게 된다.
Optional<UserEntity> findByKakaoId(String kakaoId);
💣 [ 트러블 슈팅 ] parser.ParseException
[원인]
클라이언트에서 서버로 전달되는 데이터가 JSON 형식으로 되어 있다면, 서버 측에서 적절하게 처리하기 위해 JSON 파싱이 필요하다. (데이터 형식 일치)
인가 코드가 JSON 객체 내에 포함되어 있을 경우, 이를 직접 문자열로 사용하지 않고, JSON 파싱을 통해 추출해서 사용해야한다. 그런데 나는 이 부분 처리를 안해주었던 것!
[해결]
클라이언트에서 전달된 인가 코드를 JSON 파싱하여 처리
- buile.gardle의 dependencies 부분에 추가
- UserController의 kakaoLogin 메서드
@PostMapping("/kakaoLogin")
public ResponseEntity<Map<String, String>> kakaoLogin(@RequestBody String code) {
JSONParser parser = new JSONParser();
String kakaoCode;
try {
JSONObject object = (JSONObject) parser.parse(code);
kakaoCode = (String) object.get("code");
} catch (ParseException e) {
return ResponseEntity.badRequest().build();
}
String accessToken = kakaoOAuthService.getAccessToken(kakaoCode);
Map<String, Object> userInfo = kakaoOAuthService.getUserInfo(accessToken);
JwtToken jwtToken = userService.kakaoLogin(userInfo);
Map<String, String> response = new HashMap<>();
response.put("access_token", jwtToken.getAccessToken());
return ResponseEntity.ok(response);
}
💣 [ 트러블 슈팅 ] 401 Unauthorized
[문제]
클라이언트 측에서 인가코드를 전달 → 서버에서 인가코드를 받아온 것까지는 확인을 했는데
이후의 토큰을 받아오는 과정에서 401 Unauthorized 가 발생하여 애먹었는데 아주 간단한게 원인이었다...ㅎㅎ
[원인 찾는 과정1]
구글링을 해보니 401 에러가 날 때는 아래의 네 가지를 확인하라고 나와있었다.
1. Token 요청 시, Content-Type 확인
서버측에서 contentType을 확인해본 결과, 공식 문서대로 올바르게 application/x-www-form-urlencoded;charset=utf-8 을 사용하고 있음을 확인했다.
2. secret값 활성화 확인
비활성화된 상태를 확인했다.
3. 요청 방식
POST 방식을 사용했음을 확인했다.
4. Data Encoding 확인
이 네 가지는 올바르게 작성한 것으로 확인됐다.
그럼에도 계속 오류 발생...
[원인 찾는 과정 2]
디버깅을 해보자!
Kakao API로부터 Token을 받아오는 부분에서 발생하고 있는데
정확히 ResponseEntity<Map> response = restTemplate.exchange(builder.toUriString(), HttpMethod.POST, entity, Map.class); 이 부분에서 401 Unauthorized 에러가 발생하고 있다.
[해결]
올바른 URL 사용
URL을 바꿔서 쓴 아주 어이없는 실수였다^^..
- 토큰 요청 URL: https://kauth.kakao.com/oauth/token
- 사용자 정보 요청 URL: https://kapi.kakao.com/v2/user/me
처음에는 겁먹고 시작했지만, 공식 문서가 아주 친절하게 잘 나와있어서 생각보다는(?) 수월했다.
다만, 각종 key값과 URI들을 헷갈리지 않게 정확하게 설정하는 것을 주의해야할 것 같다. 참고로 나는 많이 헷갈렸다...^^
소셜 로그인을 구현해보니, 새삼 이 잠깐 사이에 사용자에게는 보여지지 않는 많은 과정이 존재하는구나.. 싶어서 신기하다.
구현해보기 전에는 아무 생각 없이 하던 소셜로그인을 이젠 다른 사이트에서 소셜로그인을 할 때도 '이렇게 구현했겠지?' 하는 생각이 드는게 단점이자 장점^^
다음에는 네이버 로그인으로 복습해보려 한다!
'Project 댕린이집' 카테고리의 다른 글
[Refactoring] Axios Interceptor 적용 (0) | 2024.07.29 |
---|---|
[Refactoring] Redis를 사용해 Refresh Token 관리해보자 (0) | 2024.07.25 |
[알림장] 작성 / 조회 (0) | 2024.07.01 |
[게시판] 댓글 (0) | 2024.06.29 |
[게시판] 좋아요 (0) | 2024.06.28 |