오늘은.. 카카오 소셜 로그인을 구현해봤다.
로그인 자체는 어렵지 않으나 로직이 좀 헷갈려서 오래 걸렸다.
Authorization code를 반환Authorization Code로 Access Token 요청Authorization Code를 이용해 카카오 OAuth 서버에 Access Token 요청을 보냄client_id, redirect_uri, code 등의 정보가 포함되어 있음Access Token을 반환먼저 서비스를 Kakao Developers에 등록해야 한다.

적당히 넣으면 된다.

카카오 로그인을 활성화하고, Authorization code를 받을 Redirect URI를 등록한다.

플랫폼 > Web에 사이트 도메인도 추가한다. 아직 배포 전이라 로컬로 요청을 보낸다.

카카오 로그인 > 동의항목을 보면 제공받을 정보를 설정할 수 있다.
기본 제공되는 정보는 제한적인데, 나는 실제 사업자번호도 없기 때문에 테스트앱을 생성해서 추가적인 정보를 제공받을 것이다.

비즈니스 > 개인 개발자 비즈 앱 전환

일반 > 테스트 앱 정보 > 테스트앱 생성

카카오 로그인 > 동의항목
이제는 필요한 정보를 잔뜩 요청할 수 있게 되었다!
카카오 로그인을 진행할 요청 경로를 인증 제외함
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
// 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers("/api/members/auth/signup").permitAll() // 회원가입
.requestMatchers( "/api/members/auth/login").permitAll() // 로그인 요청 제외
.requestMatchers("/api/members/auth/kakao-login").permitAll() // 카카오 로그인
.anyRequest().authenticated() // 그 외 모든 요청에 대해 인증처리
);
// 필터
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
return http.build();
}
이전에 필터로 고통받은 기억이 있어서 인증 필터에서 uri를 읽어와 카카오 로그인 요청이면 null을 반환시켜 필터를 지나가게도 함
// 카카오 로그인 요청
@GetMapping("/auth/kakao-login")
public ResponseEntity<ResponseDto> kakaoLogin(@RequestParam String code) throws ParseException {
return kakaoService.processKakaoLogin(code);
}
사용자가 카카오 로그인을 승인하여 반환한 Authorization Code를 받는 메서드이다.
카카오 인증 로직이 길어질 것 같기도 하고, 사용자만을 다루는 UserService와 구분하려 KakaoService를 따로 만들었다.
KakaoService에 필요한 메서드는 세 가지로 분류했다.
processKakaoLogin: 중심 로직을 실행하는 메서드. Authorization Code를 사용하여 카카오 로그인 과정을 처리
getKakaoTokens: Authorization Code을 사용해 Kakao에 요청을 보내 Access Token과 Refresh Token을 가져옴
getUserInfo: AccessToken을 사용해 카카오에서 사용자 정보를 가져옴. 이 정보를 토대로 DB를 조회하여 기존 회원 여부에 따라 로그인, 신규가입을 처리한다
processKakaoLoginpublic ResponseEntity<ResponseDto> processKakaoLogin(String code) throws ParseException {
// 토큰 가져오기
Map<String, String> tokens = getKakaoTokens(code);
String kakaoAccessToken = tokens.get("access_token");
String kakoRefreshToken = tokens.get("refresh_token");
// 유저 정보 가져오기
KakaoUserDto kakaoUserDto = getUserInfo(kakaoAccessToken);
P_user user = userService.findByEmail(kakaoUserDto.getEmail());
// 없을 경우 추가정보 입력 필요 응답
if (user == null) {
return ResponseEntity.ok(new ResponseDto(ResponseDto.SUCCESS, "신규회원, 추가정보 필요", kakaoUserDto));
}
// 로그인 처리
String accessToken = jwtUtil.createAccessToken(user.getEmail(), user.getRole().name());
String refreshToken = jwtUtil.createRefreshToken(user.getEmail());
LoginResponseDto loginResponse = new LoginResponseDto(user.getEmail(), user.getNickname(), user.getRole().name());
ResponseDto response = new ResponseDto(ResponseDto.SUCCESS, "로그인이 성공적으로 완료되었습니다", loginResponse);
// 헤더, 쿠키 설정
HttpHeaders headers = jwtUtil.createAccessTokenHeader(accessToken);
headers.add(HttpHeaders.SET_COOKIE, jwtUtil.createRefreshTokenCookie(refreshToken).toString());
return ResponseEntity.ok()
.headers(headers)
.body(response);
}
getKakaoTokens 메서드 호출로 카카오 API에서 Access Token과 Refresh Token을 받아옴getUserInfo 메서드 호출로 카카오 API에서 사용자의 이메일, 닉네임 등의 정보를 가져옴userService.findByEmail 메서드를 통해 DB에서 이메일로 사용자를 찾음getUserInfo로 받은 Kakao 유저 정보를 프론트에 같이 반환하여, 추가해야 할 부분만 추가해서 기존의 회원가입 요청으로 받도록 함getKakaoTokensprivate Map<String, String> getKakaoTokens(String code) {
String url = "https://kauth.kakao.com/oauth/token";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("client_id", kakaoApiKey);
params.add("redirect_uri", "http://localhost:8080/api/members/auth/kakao-login");
params.add("code", code);
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(url, request, Map.class);
Map<String, Object> responseBody = response.getBody();
Map<String, String> tokens = new HashMap<>();
tokens.put("access_token", (String) responseBody.get("access_token"));
tokens.put("refresh_token", (String) responseBody.get("refresh_token"));
return tokens;
}
client_id는 yml 파일에서 가져오도록 함Authorization Code를 'code'로 전달함restTemplate.postForEntity로 POST 요청을 보내고, 응답을 받아 Access Token과 Refresh Token을 추출해 반환함getUserInfoprivate KakaoUserDto getUserInfo(String accessToken) throws ParseException {
String url = "https://kapi.kakao.com/v2/user/me";
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + accessToken);
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
HttpEntity<Void> request = new HttpEntity<>(headers);
ResponseEntity<Map> response = restTemplate.exchange(url, HttpMethod.GET, request, Map.class);
Map<String, Object> responseBody = response.getBody();
Date birth = null;
Long kakaoId = ((Number) responseBody.get("id")).longValue();
Map<String, Object> kakaoAccount = (Map<String, Object>) responseBody.get("kakao_account");
String email = (String) kakaoAccount.get("email");
Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");
String nickname = (String) profile.get("nickname");
String profileImageUrl = (String) profile.get("profile_image_url");
String birthYear = ((String) responseBody.get("birthyear"));
String birthday = ((String) responseBody.get("birthday"));
if(birthday != null && birthYear != null){
String birthString = birthYear + "-" + birthday.substring(0,2) + "-" + birthday.substring(2, 4);
SimpleDateFormat dateFormat = new SimpleDateFormat(("yyyy-MM-dd"));
birth = dateFormat.parse(birthString);
}
String phone = (String) kakaoAccount.get("phone_number");
phone = phone.replaceAll("[^0-9]", "");
if (phone.startsWith("82")) {
phone = "0" + phone.substring(2);
}
if (phone.length() == 11) {
phone = phone.replaceFirst("(\\d{3})(\\d{4})(\\d+)", "$1-$2-$3");
} else if (phone.length() == 10) {
phone = phone.replaceFirst("(\\d{3})(\\d{3})(\\d+)", "$1-$2-$3");
} else {
phone = null;
}
return new KakaoUserDto(kakaoId, email, nickname, profileImageUrl, birth, phone);
}
Access Token을 이용해 카카오 API에 유저 정보를 요청하고 반환함카카오 로그인 시 인증 부분을 만드는데 토큰을 헤더에 추가하고 쿠키에 추가하고 하는 부분이 인증 필터에서 진행하는 부분과 번복되는 것 같아 그냥 헤더랑 토큰을 반환하게 수정함
// 엑세스 토큰이 담긴 헤더
public HttpHeaders createAccessTokenHeader(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.add(AUTHORIZATION_HEADER, BEARER_PREFIX + accessToken);
return headers;
}
// 리프레시 토큰이 담긴 쿠키
public ResponseCookie createRefreshTokenCookie(String refreshToken) {
return ResponseCookie.from("refreshToken", refreshToken)
.httpOnly(true)
.secure(true) // HTTPS 환경에서만 전송
.path("/")
.maxAge(7 * 24 * 60 * 60) // 일주일간 유효
.build();
}
JwtUtil의 코드들이 조금 번복되는 느낌인데, 한 번 정리하고 싶음..
카카오 로그인 회원과 자체 로그인 회원을 같은 테이블에서 관리할 것이라 회원가입 로직을 조금 수정함
public ResponseDto createUser(CreateUserRequestDto request) {
Point latLngPoint = geometryFactory.createPoint(new Coordinate(request.getLng(), request.getLat()));
String socialProvider = "NONE";
String password = request.getPassword();
// 이메일 중복확인
if(userRepository.findByEmail(request.getEmail()).isPresent()) {
return new ResponseDto<>(-1, "중복된 이메일입니다", null);
}
// 닉네임 중복확인
if(userRepository.findByNickname(request.getNickname()).isPresent()) {
return new ResponseDto<>(-1, "중복된 닉네임입니다", null);
}
// 카카오 회원가입일 경우
if(request.getKakaoId() != null) {
socialProvider = "KAKAO";
// 비밀번호 랜덤 생성
password = UUID.randomUUID().toString();
}
// 일반 회원가입인데 비밀번호가 없는 경우
if(request.getKakaoId() == null && password == null) {
return new ResponseDto<>(-1, "비밀번호가 없습니다", null);
}
// 비밀번호 암호화
String encodedPassword = passwordEncoder.encode(password);
P_user user = P_user.builder()
.email(request.getEmail())
.password(encodedPassword)
.nickname(request.getNickname())
.phone(request.getPhone())
.birth(request.getBirth())
.use_yn(true)
.role(UserRoleEnum.CUSTOMER)
.imageProfile(request.getImageProfile())
.latLng(latLngPoint)
.address(request.getAddress())
.kakaoId(request.getKakaoId())
.socialProvider(socialProvider)
.build();
userRepository.save(user);
return new ResponseDto<>(1, "회원가입이 완료되었습니다", null);
}
재미있었당
우와 양질의 글입니다~~ 잘 보고 갑니다!