[SpringBoot] Kakao Login

HandMK·2024년 6월 20일
0

SpringBoot

목록 보기
5/6
post-thumbnail
post-custom-banner

💻 버전 관리
Spring Boot : 3.2.0
JDK : 17
Build : Maven

📌 서론

대부분의 서비스는 자체적인 로그인 / 회원가입도 있으나, 외부 플랫폼의 유저 정보를 활용하여 서비스에 도입한다. 보통 소셜 로그인이라고 하는데 이에 대해 공부한 내용과 코드를 정리하고자 한다.

📌 본론

소셜 로그인이란?

우리 서비스에서 타 플랫폼에 저장되어 있는 유저의 정보를 가져와 별도의 회원가입 절차 없이 플랫폼 유저 정보를 활용하여 우리 서비스를 이용하는 것을 말한다.

처음엔 단순히 이런식의 구조를 생각하였다. 공부를 하면서 알게 된 거지만, 이 구조는 미친 짓이라고 한다.
그 이유는

타 플랫폼 입장에서 사용자의 민감한 정보를 탈취당한 입장이기에, 우리 서비스에 소송을 걸어올 수도 있는 상황이 야기된다.

이런 문제를 해결하기 위해서 OAuth 가 등장하기 이전에는 구글에서 AuthSub, 야후의 BBAuth 등 각자 회사가 개발한 통신 규약을 사용하도록 했다고 한다.

이렇게 되면 HTTP 마냥 표준화가 되지 않기 때문에 구글 로그인을 구현할 땐 AuthSub, 야후를 구현할 땐 BBAut에 맞춰 개발하고 유지보수를 해야 하는데 굉장히 머리아플것이다.

이를 위해서 처음으로 나온게 OAuth1.0 이다.

지금 현재까지 자주 사용되는 OAuth2.0은 OAuth1.0를 조금 더 단순화 하고 모바일에서도 안전하게 사용될 수 있도록 업그레이드 하여 릴리즈 된 버전이라고 한다.

OAuth2.0?

OAuth 통신을 정확하게 이해하려면 3가지 용어를 꼭 이해해야 한다.

  • Resource Owner
    • 리소스 소유자
    • 카카오로 치면, Resource - 카카오톡 친구 목록 | Resource Owner - 김멋사
  • Authorization & Resource Server
    • Authorization - Resource Owner 를 인증하고, Client에게 액세스 토큰을 발급하는 서버
    • Resource Server - 구글, 카카오, 네이버와 같은 리소스를 갖고 있는 서버
  • Client
    • Resource Server의 자원을 이용하고자 하는 서비스, 즉 나의 서비스
  • Redirect URI
    - 인증이 성공한 사용자를 타 플랫폼 어플리케이션에서 사전에 등록해 놓은 Redirect URI(우리 서비스 경로) 로만 리다이렉트 시킨다

    이런식으로

플로우는 생각보다 간단하다!

동작 매커니즘

  • 1번, 2번 - Resource Owner(김멋사)가 우리 서비스의 카카오로 로그인하기 버튼을 클릭

즉, 카카오 로그인 페이지 호출!

이때, 플랫폼마다 변수 값은 조금 다르긴 하지만, response_type, client_id, redirect_uri, scope 등을 매개로 요청한다! (Front 에서 하는 일)

  • 3번, 4번 - 로그인 페이지에서 ID/PW 입력
  • 5번, 6번 - ID PW 가 정상적이면, Authorization ServeRedirect URI(우리 서버) 로 리다이렉트 시킨다. 이때, Authorization Code 를 같이 넘겨 준다! 이때의 Authorization CodeResource를 얻기 위한 Access Token 을 획득하기 위해 잠시 사용하는 임시 코드!

(Authorization → Access Token 라는 것이 핵심)

  • 7번, 8번 -Authorization CodeAuthorization Server 로 전달해 Access Token 을 발급.
  • 9번, 10번 - 이후 Access Token 으로 Resource Server 에서 Resource에 접근하고 Resource Owner 에게 로그인 완료 전송.

💡 왜 Authorization Code 가 필요할까?
Authentication Server 에서 AccessToken 을 바로 넘겨주면 되지 않나? 라는 생각을 해봤다.
그 이유는 Authorization Server에서 Access Token 을 발급 하고 다시 Redirect URI 를 넘어와야 하는데 그 과정에서 노출이 되면 OAuth2.0 통신을 하는 이유가 사라진다고 한다. (앞서 얘기한 내용과 동일한 내용)
때문에 때문에 AccessToken 은 프론트단에서 Authorization Code 를 백엔드단에 넘겨주고 백엔드 단에서 AccessToken 을 저장하는 것이 가장 안전하다!

카카오 OAuth 서비스 신청하기

카카오 공식 문서 에서 가져온 내용이다.

상단 메뉴에 [내 애플리케이션] → [생성]

1. 인가코드 발급


공식문서를 읽어보면 인가코드를 가져오기 위한 사전 준비는 다음과 같다.

사전설정
플랫폼 등록
카카오 로그인 활성화
RedirectURI 등록
동의항목
OpenID Connect 활성화 (선택)
간편가입 (선택)
플랫폼 등록

카카오 API는 카카오디벨로퍼스에 플랫폼 정보가 등록된 서비스에서만 사용 가능하다.

[내 애플리케이션] > [플랫폼]에서 서비스의 각 플랫폼 정보를 등록할 수 있다.

카카오 로그인 활성화

Redirect URI 등록

카카오 ID/PW 입력 후 Authorization Code(인가코드) 를 redirect 할 서버 URI 를 지정하는 단계

왼쪽 탭 [카카오 로그인] → Redirect URI 등록

동의 항목

Resource Server 에서 어떤 정보들을 가져올 수 있는지 선택하는 단계

권한을 늘리고 싶으면 비즈니스 앱으로 인증을 받아야 한다.

API 키 확인

여기까지 사전준비 하면 사용하기만 하면 된다.

🧑‍💻 CODE

resources/templates/login.html
로그인 버튼을 누른다는 시나리오로 타 플랫폼 로그인 화면을 불러오기 위해 간단한 html 과 authorizationCode를 callback 할 redirect controller 를 작성하였다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<a href="https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}"><img src="/kakao_login_large_wide.png"></a>
</body>
</html>

controller/AuthController.java

@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
@Slf4j
public class AuthController {
	private final AuthService authService;
    
    @GetMapping("/kakao/callback")
    public ResponseEntity<?> getKaKaoAuthorizeCode(@RequestParam("code") String authorizeCode, String type){
        type = "kakao";
        log.info("[google login] authorizeCode : {}", authorizeCode);
        return authService.signIn(authorizeCode, type);
    }

의존성 주입을 위한 서비스 인터페이스를 생성하였다.
service/AuthService

public interface AuthService {
    ResponseEntity<?> signIn(String authorizeCode, String type);
}

이제 AccessToken 을 발급 받아야 한다.

💡 코드에 상수값이 있는 것은 보기 좋지 않기에 설정 파일에서 관리!

application.properties

spring.application.name=6W

kakao.client.id = ${CLIENT_ID}
kakao.redirect.url = ${REDIRECT_URL}
kakao.accesstoken.url = https://kauth.kakao.com/oauth/token
kakao.userinfo.url = https://kapi.kakao.com/v2/user/me

service/impl/AuthServiceImpl
구현체

@Service
@Slf4j
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {
    private final AuthDAO authDAO;  
    @Value("${kakao.client.id}")
    private String kakaoClientKey;
    @Value("${kakao.redirect.url}")
    private String kakaoRedirectUrl;
    @Value("{$kakao.accesstoken.url}")
    private String kakaoAccessTokenUrl;
    @Value("${kakao.userinfo.url}")
    private String kakaoUserInfoUrl;

    @Override
    public ResponseEntity<?> signIn(String authorizeCode, String type) {
        switch (type){
            case "kakao":
                log.info("[kakao login] issue a authorizecode");
                ObjectMapper objectMapper = new ObjectMapper();
                RestTemplate restTemplate = new RestTemplate(); // http 통신을 위한 객체

                HttpHeaders headers = new HttpHeaders();
                headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

                MultiValueMap<String, String> params = new LinkedMultiValueMap<>(); // 해당 명세에 맡게 파라미터 지정
                params.add("grant_type", "authorization_code");
                params.add("client_id", kakaoClientKey);
                params.add("redirect_uri", kakaoRedirectUrl);
                params.add("code", authorizeCode);

                HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest = new HttpEntity<>(params, headers);

                try{
                    ResponseEntity<String> response = restTemplate.exchange(
                            kakaoAccessTokenUrl,
                            HttpMethod.POST,
                            kakaoTokenRequest,
                            String.class
                    );
                    log.info("[kakao login] authorizecode issued successfully");
                    Map<String, Object> responseMap = objectMapper.readValue(response.getBody(), new TypeReference<Map<String, Object>>() {});
                    String accessToken = (String) responseMap.get("access_token");

                    RequestAuthDto requestSignUpDto = getKakaoUserInfo(accessToken);

                    return authDAO.login(requestSignUpDto);

                }catch (Exception e){
                    log.warn("[kakao login] fail authorizecode issued");
                    return ResponseEntity.status(ResultCode.PASSWORD_NOT_MATCH.getCode())
                            .body(CommonResponse.fail(ResultCode.PASSWORD_NOT_MATCH));
                }
                
               }
                return null;
                }

이제 사용자 정보를 가져와야 한다.
해당 API 명세는 다음과 같다.


service/impl/AuthServiceImpl

private RequestAuthDto getKakaoUserInfo(String accessToken){
        RestTemplate restTemplate = new RestTemplate();
        HttpHeaders headers = new HttpHeaders();
        ObjectMapper mapper = new ObjectMapper();

        headers.add("Authorization", "Bearer "+accessToken);
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        MultiValueMap<String, String> requestBody = new LinkedMultiValueMap<>();
        requestBody.add("secure_resource", "true");

        HttpEntity<?> entity = new HttpEntity<>(requestBody,headers);

        ResponseEntity<String> response = restTemplate.postForEntity(kakaoUserInfoUrl,entity,String.class);

        try{
            Map<String, Object> responseMap = mapper.readValue(response.getBody(), new TypeReference<Map<String, Object>>() {});
            Map<String, Object> kakaoAccount = (Map<String, Object>) responseMap.get("kakao_account");
            Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");

            RequestAuthDto requestSignUpDto = RequestAuthDto.builder()
                    .name((String) kakaoAccount.get("name"))
                    .nickName((String) kakaoAccount.get("nickname"))
                    .password(getRandomPassword())
                    .phoneNumber((String) kakaoAccount.get("phone_number"))
                    .email((String)kakaoAccount.get("email"))
                    .profileUrl((String) profile.get("profile_image_url"))
                    .loginType(LoginType.KAKAO.toString())
                    .useAble(true)
                    .build();

            return requestSignUpDto;
        }catch (Exception e){
            e.printStackTrace();
            return null;
        }
    }

마지막으로 우리 서비스 DB에 회원가입이 이미 되어 있는지 확인 후 회원가입 or 로그인을 DAO 단에서 해결하면 된다.
dao/AuthDAO

public interface AuthDAO {
    ResponseEntity<?> login(RequestAuthDto requestAuthDto);
}

dao/impl/AuthDAOImpl

@Component
@RequiredArgsConstructor
@Slf4j
public class AuthDAOImpl implements AuthDAO {
    private final UserRepository userRepository;
    private final JwtTokenProvider jwtTokenProvider;
    @Override
    public ResponseEntity<?> login(RequestAuthDto requestAuthDto) {
        if (checkUserExist(requestAuthDto.getEmail(), requestAuthDto.getLoginType())) {
            User user = userRepository.findByEmailAndLoginType(requestAuthDto.getEmail(), requestAuthDto.getLoginType());
            log.info("user name : {}",user.getUsername());
            if (user.isEnabled()) {
                return ResponseEntity.status(ResultCode.OK.getCode())
                        .body(ResponseAuthDto.builder()
                                .accessToken(jwtTokenProvider.createAccessToken(user.getEmail(), user.getRoles()))
                                .refreshToken(jwtTokenProvider.createRefreshToken(user.getEmail()))
                                .name(user.getUsername())
                                .status(CommonResponse.success())
                                .build());

            } else {
                return ResponseEntity.status(ResultCode.DELETED_USER.getCode())
                        .body(ResultCode.DELETED_USER);
            }
        } else {
            log.info("[sign up] no user");
            CommonResponse commonResponse = signUp(requestAuthDto);
            if (commonResponse.getCode() == 200) {
                return login(requestAuthDto);
            }
        }
        return null;
    }


    private CommonResponse signUp(RequestAuthDto requestAuthDto){
        User user = User.builder()
                .name(requestAuthDto.getName())
                .nickName(requestAuthDto.getNickName())
                .password(requestAuthDto.getPassword())
                .phoneNumber(requestAuthDto.getPhoneNumber())
                .email(requestAuthDto.getEmail())
                .profileUrl(requestAuthDto.getProfileUrl())
                .loginType(requestAuthDto.getLoginType())
                .useAble(requestAuthDto.isUseAble())
                .roles(Collections.singletonList("ROLE_USER"))
                .build();

        userRepository.save(user);

        return CommonResponse.success();
    }
    private boolean checkUserExist(String email, String loginType){
        return userRepository.existsByEmailAndLoginType(email, loginType);
    }
}

📌 참고자료

카카오 공식문서

profile
몫을 다하는 개발자
post-custom-banner

2개의 댓글

comment-user-thumbnail
2024년 8월 18일

안녕하세요 카카오로그인관련 질문이 있는데요! 백엔드 측에서 저렇게 마지막 사진처럼 로그인이 성공한게 html에 보여지는거 이후에 로그인 성공시 어떠한 페이지로 이동하는것도 혹시 백엔드측에서 처리를 해야하는건가요?

1개의 답글