[TIL] 241114 카카오 소셜 로그인 구현하기

MONA·2024년 11월 14일

나혼공

목록 보기
29/92

오늘은.. 카카오 소셜 로그인을 구현해봤다.
로그인 자체는 어렵지 않으나 로직이 좀 헷갈려서 오래 걸렸다.

카카오 소셜 로그인

Kakao Developers

흐름

  1. 프론트에서 카카오 로그인 요청
    • 사용자가 프론트엔드에서 카카오 로그인 버튼을 클릭
    • 프론트엔드는 카카오 OAuth 서버에 인증 요청을 보내고, 사용자에게 카카오 로그인 화면을 보여줌
  2. 사용자가 카카오 로그인 승인
    • 사용자가 카카오 계정으로 로그인하고 승인을 완료하면, 카카오는 프론트엔트에 Authorization code를 반환
    • 프론트엔드는 이 코드를 서버로 전달함
  3. 서버에서 Authorization CodeAccess Token 요청
    • 서버의 카카오로그인 메서드로 요청이 들어오고, Authorization Code를 이용해 카카오 OAuth 서버에 Access Token 요청을 보냄
    • 요청에는 client_id, redirect_uri, code 등의 정보가 포함되어 있음
    • 이 정보로 카카오 OAuth 서버에서 Access Token을 반환
  4. Access Token으로 사용자 정보 요청
    • 수신한 Access Token을 이용해 카카오 API에 사용자 정보 요청을 보냄
    • 카카오는 사용자 고유 ID(kakao_id), 이메일, 닉네임 등 사용자의 정보를 반환함
  5. DB에서 사용자 존재 여부 확인
    • DB에 저장된 사용자를 조회하여 이미 가입된 유저인지 확인
  6. 없을 경우 신규 회원 가입, 있을 경우 로그인
    6-1. 신규 회원인 경우 -> DB에 사용자 정보가 없는 경우, 신규 회원으로 등록
    6-2. 기존 회원인 경우 -> AccessToken, RefreshToken을 생성하여 로그인 처리 진행
  7. 프론트엔드는 6의 응답을 바탕으로 신규 회원인 경우 추가 정보 입력 화면을 표시, 기존 회원이라면 로그인 완료 화면 이동

등록

먼저 서비스를 Kakao Developers에 등록해야 한다.

적당히 넣으면 된다.


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


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


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


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


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


카카오 로그인 > 동의항목
이제는 필요한 정보를 잔뜩 요청할 수 있게 되었다!

구현

WebSecurityConfig

카카오 로그인을 진행할 요청 경로를 인증 제외함

@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을 반환시켜 필터를 지나가게도 함

UserController

// 카카오 로그인 요청
    @GetMapping("/auth/kakao-login")
    public ResponseEntity<ResponseDto> kakaoLogin(@RequestParam String code) throws ParseException {
        return kakaoService.processKakaoLogin(code);
    }

사용자가 카카오 로그인을 승인하여 반환한 Authorization Code를 받는 메서드이다.
카카오 인증 로직이 길어질 것 같기도 하고, 사용자만을 다루는 UserService와 구분하려 KakaoService를 따로 만들었다.

KakaoService

KakaoService에 필요한 메서드는 세 가지로 분류했다.

processKakaoLogin: 중심 로직을 실행하는 메서드. Authorization Code를 사용하여 카카오 로그인 과정을 처리
getKakaoTokens: Authorization Code을 사용해 Kakao에 요청을 보내 Access Token과 Refresh Token을 가져옴
getUserInfo: AccessToken을 사용해 카카오에서 사용자 정보를 가져옴. 이 정보를 토대로 DB를 조회하여 기존 회원 여부에 따라 로그인, 신규가입을 처리한다

  1. processKakaoLogin
public 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);
    }
  1. getKakaoTokens 메서드 호출로 카카오 API에서 Access Token과 Refresh Token을 받아옴
  2. getUserInfo 메서드 호출로 카카오 API에서 사용자의 이메일, 닉네임 등의 정보를 가져옴
  3. DB에서 사용자를 조회하여 기존 회원인지 판단함
  • userService.findByEmail 메서드를 통해 DB에서 이메일로 사용자를 찾음
    3-1. 사용자가 DB에 없으면 신규 가입이 필요하다는 응답을 반환
    • getUserInfo로 받은 Kakao 유저 정보를 프론트에 같이 반환하여, 추가해야 할 부분만 추가해서 기존의 회원가입 요청으로 받도록 함
      3-2. 사용자가 DB에 있으면 로그인 처리
    • JWT AccessToken과 Refresh Token을 생성하여 로그인 처리를 진행
    • 생성한 토큰을 헤더와 쿠키에 저장하여 응답 반환
  1. getKakaoTokens
private 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;
    }
  • 'https://kauth.kakao.com/oauth/token'으로 POST 요청을 보내야 함
  • 카카오 개발자 문서를 참고하여 헤더와 파라미터를 설정함
  • client_id는 yml 파일에서 가져오도록 함
  • 처음에 전달받은 Authorization Code를 'code'로 전달함
  • restTemplate.postForEntity로 POST 요청을 보내고, 응답을 받아 Access Token과 Refresh Token을 추출해 반환함
  1. getUserInfo
private 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에 유저 정보를 요청하고 반환함

JwtUtil

카카오 로그인 시 인증 부분을 만드는데 토큰을 헤더에 추가하고 쿠키에 추가하고 하는 부분이 인증 필터에서 진행하는 부분과 번복되는 것 같아 그냥 헤더랑 토큰을 반환하게 수정함

// 엑세스 토큰이 담긴 헤더
    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의 코드들이 조금 번복되는 느낌인데, 한 번 정리하고 싶음..

UserService

카카오 로그인 회원과 자체 로그인 회원을 같은 테이블에서 관리할 것이라 회원가입 로직을 조금 수정함

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);

    }
  • User 엔티티에 kakaoId, socialProvider 필드를 추가해 어떤 회원가입/로그인 타입인지 저장함
  • 카카오 회원가입인 경우 비밀번호를 따로 받지 않기 때문에 임의로 생성해서 인코딩 해 저장함. 어차피 쓸 일 없음

재미있었당

profile
고민고민고민

1개의 댓글

comment-user-thumbnail
2024년 11월 14일

우와 양질의 글입니다~~ 잘 보고 갑니다!

답글 달기