팀프로젝트_PetHarmony

카카오 로그인 시도 (2)

이채림 2024. 8. 17. 04:18

코드를,, 블로그를,, 몇 번 지웠다가 썼다가를 반복한지 모르겠다,,,,

소셜 로그인 중 카카오 로그인이 제일 쉽다고 들었는데,,,, ㅠㅠㅠㅠㅠㅠㅠ

관련 블로그와 카카오 문서를 계속 본 것 같다. 이제 어떻게 돌아가는지 정확히 안다. 성공만 하면 된다!!

 

이 블로그가 최종이길 바라며 다시 처음부터 차근차근 해보려고 한다.


먼저 카카오 문서다.

https://developers.kakao.com/docs/latest/ko/kakaologin/common

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

카카오 로그인 과정

1. [카카오톡 로그인] 버튼(LoginJoinButton(mode="kakao"))을 누르면 KakoAuthUrl로 이동한다.

2. KakaoAuthUrl에서 카카오 로그인이 완료되면, RedirectUri로 이동한다.

3. RedirectUri URL에 뜨는 인가코드를 프론트에서 추출한다.

4. 이 인가코드를 API에 쿼리스트링으로 같이 파싱하여 백엔드로 넘겨준다.

5. 백엔드에서는 이 인가코드를 받아서 토큰을 받아 프론트로 넘겨준다.

6. 토큰을 받아 로그인을 유지한다.

내 애플리케이션 설정

먼저, 위에 Kako Developers에 들어가서 애플리케이션을 등록해야 한다.

나는 테스트용으로 추가하였다.

 

앱 키를 확인할 수 있다. 

우리는 REST API 키를 사용할 것이다.

 

플랫폼을 설정해준다.

 

활설화 설정 상태를 ON으로 바꾸고 Redirect URI를 설정한다.

 

동의 항목도 체크해준다.

우리 프로젝트에서는 커뮤니티 이용을 위해 이름(name)이 필요하고,

아이디 찾기를 위해 전화번호(phone_number)가 필요하고, 임시 비밀번호 발급을 위해 이메일(account_email)이 필요하다.

 

 

 

React 구현

앱 키는 무조건 .env에서 관리하고 꼭 gitignore에 추가시켜야 한다.

[앱 키]에 있는 REST API 키와 위에 Client Secret 코드를 .evn에 적으면 된다.

 

1) 로그인 화면 페이지와 2) Redirect URI로 갈 때 보여질 화면 페이지를 생성한다.

1) 로그인 화면 페이지는 이미 만들어둔 <LoginJoinButton> 컴포넌트를 사용할 것이다.

 

src/common/button/components/LoginJoinButton.jsx

const REST_API_KEY = process.env.REACT_APP_REST_API_KEY;
const REDIRECT_URI = `http://localhost:3000/oauth`;
const kakaoURL = `https://kauth.kakao.com/oauth/authorize?client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}&response_type=code`;
 
...
             ) : isKakaoMode ? (
                <div className="ljb_set">
                    <div onClick={() => window.location.href = kakaoURL}>
                        <img src={kakao} alt="카카오톡 로그인" />
                    </div>
                    <span>카카오톡 로그인</span>
                </div>
            ) : isGoogleMode ? (
...

 

src/App.js

import Oauth from "./login/components/Oauth";
...

function App() {
  return (
    <Router>
      <Routes>
        ...
        <Route path="/oauth" element={<Oauth />} />
        ...
      </Routes>
    </Router>
  );
}

export default App;

e

로그인을 하면 인가코드가 뜬다. 

 

src/login/Oauth.jsx

import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import axios from "axios";

export function Oauth() {
    const navigate = useNavigate();
    const [isFetching, setIsFetching] = useState(false); // 상태 변수 추가
    const REST_API_KEY = process.env.REACT_APP_REST_API_KEY;
    const REDIRECT_URI = `http://localhost:3000/oauth`;
    const code = new URL(window.location.href).searchParams.get("code");

    const getCode = async () => {
        setIsFetching(true); // 요청을 시작할 때 상태를 true로 설정
        try {
            const response = await axios.post('https://kauth.kakao.com/oauth/token',
                {
                    grant_type: "authorization_code",
                    client_id: REST_API_KEY,
                    redirect_uri: REDIRECT_URI,
                    code: code,
                },
                {
                    headers: {
                        "Content-type": "application/x-www-form-urlencoded;charset=utf-8",
                    },
                }
            );
            return response;
        } finally {
            setIsFetching(false); // 요청이 완료된 후 상태를 false로 설정
        }
    };

    useEffect(() => {
        if (!isFetching) { // 이미 요청 중이지 않은 경우에만 실행
            getCode()
                .then((response) => {
                    if (response) {
                        localStorage.setItem("code", JSON.stringify(response.data.access_token));
                        navigate("/");
                    }
                })
                .catch((err) => console.log(err));
        }
    }, [isFetching]); // 상태 변수를 의존성 배열에 추가

    return <></>;
};

export default Oauth;

응답으로 받은 scope 안의 값들을 보면 내가 동의항목으로 표시한 account_email, name, phone_number가 찍힌 걸 알 수 있다.

하지만 이거와 별개로 access_token을 통해 추가 요청을 보내 사용자 정보를 가져와야 한다.

카카오의 access_token 만으로는 직접 사용자 정보를 제공하지 않기 때문이다.

 

Oauth.jsx의 useEffect()를 수정하면

    useEffect(() => {
        if (!isFetching) { 
            getCode()
                .then((response) => {
                    if (response) {
                        localStorage.setItem("access_token", JSON.stringify(response.data.access_token));
                        axios.post("http://localhost:8080/api/public/kakao", {
                            accessToken: response.data.access_token
                        })
                        .then(res => {
                            navigate("/"); // 로그인 후 리다이렉트
                        })
                        .catch(err => console.log(err));
                    }
                })
                .catch((err) => console.log(err));
        }
    }, [isFetching]);

access_token을 로컬 스토리지에 저장하고, 이 후에 access_token을 이용해 백엔드 서버에 요청을 보내 사용자 정보를 가져올 수 있도록 해야한다.

 

Spring Boot 구현

먼저 DTO를 생성해주었다.

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class KakoInfoDTO {
    private Long id;

    private String name;

    private String account_email;

    private String phone_number;
}

 

서비스 코드는 두 개의 메소드가 필요하다.

public interface UserService {
    // 카카오로 회원 정보 가져오기
    KakoInfoDTO getUserInfoFromKakao(String accessToken);
    // 카카오 로그인
    User kakaoLogin(KakoInfoDTO kakoInfoDTO);
}

 

   @Override
    public KakoInfoDTO getUserInfoFromKakao(String accessToken) {
        RestTemplate restTemplate = new RestTemplate();
        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + accessToken);

        HttpEntity<String> entity = new HttpEntity<>("", headers);
        ResponseEntity<String> response;

        try {
            response = restTemplate.exchange(
                    "https://kapi.kakao.com/v2/user/me",
                    HttpMethod.GET,
                    entity,
                    String.class
            );
        } catch (HttpClientErrorException e) {
            throw new RuntimeException("카카오 API 호출에 실패했습니다: " + e.getMessage());
        }

        if (response.getStatusCode() != HttpStatus.OK) {
            throw new RuntimeException("카카오 API 응답 상태가 좋지 않습니다: " + response.getStatusCode());
        }

        // 응답을 로그에 출력
        log.info("카카오 API 응답: {}", response.getBody());

        // JSON 응답을 DTO로 매핑
        ObjectMapper objectMapper = new ObjectMapper();
        KakoInfoDTO userInfo;
        try {
            userInfo = objectMapper.readValue(response.getBody(), KakoInfoDTO.class);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("카카오 API 응답을 처리하는 중 오류가 발생했습니다: " + e.getMessage());
        }

        return userInfo;
    }

    @Override
    public User kakaoLogin(KakoInfoDTO kakoInfoDTO) {
        User user = User.builder()
                .userName(kakoInfoDTO.getName())
                .email(kakoInfoDTO.getAccount_email())
                .password("kakao_password")
                .phone(kakoInfoDTO.getPhone_number())
                .role(Role.USER)
                .userState(UserState.ACTIVE)
                .kakaoId(kakoInfoDTO.getId())
                .build();

        return userRepository.save(user);
    }

 

컨트롤러

    @PostMapping("api/public/kakao")
    public ResponseEntity<User> kakaoLogin(@RequestBody Map<String, String> payload) {
        String accessToken = payload.get("accessToken");
        KakoInfoDTO kakoInfoDTO = userService.getUserInfoFromKakao(accessToken);

        User user = userService.kakaoLogin(kakoInfoDTO);

        return ResponseEntity.ok(user);
    }

 

Spring Security의 OAuth2 클라이언트 설정에서 발생하는 것으로, ClientRegistrationRepository 빈이 없어서 발생한 문제이다. 이 빈은 OAuth2 클라이언트 설정을 위해 필요한 등록 정보를 관리한다. 

 

build.gradle

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' #1)
	implementation 'org.springframework.boot:spring-boot-starter-web'           #2)
}

#1) OAuth 2.0 클라이언트 기능을 추가한다. 이를 통해 애플리케이션은 OAuth 2.0 인증을 사용하는 다양한 외부 서비스와의 연동을 쉽게 구현할 수 있다.

#2) JSON/XML 등의 데이터 직렬화 및 역직렬화 지원한다.

 

application.properties

spring.security.oauth2.client.registration.kakao.client-id=REST API 키
spring.security.oauth2.client.registration.kakao.redirect-uri=REDIRECT URI
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.kakao.scope=name,account_email,phone_number
spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me
spring.security.oauth2.client.provider.kakao.user-name-attribute=id

 

[refactor 필요]

WebConfig.java

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
                ...
                .allowedHeaders("*")
    }
}
.allowedHeaders("*")

CORS 설정을 할 때 사용되는 메소드이다. 이 메소드는 클라이언트가 서버로 요청을 보낼 때, 어떤 HTTP 헤더들을 허용할지를 설정하는 데 사용된다. 모든 헤더를 허용하겠다는 의미이다.

아직 개발 단계이므로 빠르게 개발하고 테스트하기 위해 모든 헤더를 허용했지만 리팩토링 단계에서는 필요한 헤더만 허용해야 한다.

 

SecurityConfig.java

    ...
    @Bean 
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable()) // CSRF 비활성화
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/api/public/**").permitAll()  // 공용 엔드포인트
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")  // 관리자 엔드포인트
                        .requestMatchers("/api/user/**").hasRole("USER")  // 사용자 엔드포인트
                        .anyRequest().authenticated()  // 그 외 모든 요청은 인증 필요
                )
                // OAuth2를 통해 사용자 인증 처리
                .oauth2Login(oauth2 -> oauth2
                        .loginPage("/oauth")	// 로그인 페이지 경로
                        .defaultSuccessUrl("/", true)	// 로그인 성공 시 리디렉션될 URL
                        .failureUrl("/oauth?error=true")	// 로그인 실패 시 리디렉션될 URL
                        .userInfoEndpoint(userInfo -> userInfo
                                .userService(oauth2UserService()))	// 사용자 정보 oauth2UserService() 메소드로 처리
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public OAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService() {
        DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
        return request -> {
            OAuth2User oAuth2User = delegate.loadUser(request);
            Map<String, Object> attributes = oAuth2User.getAttributes();	// attributes 맵에서 get

            String kakaoId = String.valueOf(attributes.get("id"));	// 카카오 사용자 ID
            // kakaoAccount : 추가 정보가 포함된 객체(이메일, 이름, 전화번호)
            Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");

            String email = (String) kakaoAccount.get("email");
            String userName = (String) kakaoAccount.get("name");
            String phone = (String) kakaoAccount.get("phone_number");

            return oAuth2User;
        };
    }
    ...

 

ㄴㄴ