[Project] Oauth2 카카오 로그인 구현하기

clean·2024년 4월 10일
3

mewsinsa 기록

목록 보기
4/7
post-thumbnail

mewsinsa 프로젝트를 하며 스프링 시큐리티를 쓰지 않고 JWT를 이용해 소셜로그인을 구현하고 있습니다.
오늘은 OAuth2와 JWT를 이용한 로그인 기능 구현의 개념과 설계에 대해서 작성해보려고 합니다.

이 글에서는 JWT에 대한 내용을 제외하고, 카카오 인증 서버와 통신하여 회원의 정보를 받아오는데까지의 과정을 담고 있습니다.
그렇게 가져온 회원의 정보로 JWT 로그인을 처리하는 과정은 다음 포스트에서 작성해보겠습니다.

Overview

카카오 로그인 과정

원래 인가 코드, 토큰 발행은 각각의 단계를 가지지만, 그림에선 단순화 하여 한 단계인 것처럼 표현했습니다.

  1. 사용자가 카카오 로그인을 합니다.
  2. 제 서버가 카카오 서버와 통신하는 단계입니다.
    2-1. 로그인에 성공하면 카카오 인증 서버는 쿼리 스트링을 통해서 제 서버로 카카오 인증 토큰을 발행받을 때 필요한 인가 코드를 보내줍니다.
    2-2. 제 서버는 인가 코드와 API key를 가지고 카카오 서버에 액세스 토큰을 요청합니다.
  3. 액세스 토큰을 받으면 그 액세스 토큰을 가지고 카카오 회원의 정보를 가져올 수 있습니다. 저는 이메일과 이름 정보만 가져왔습니다.
  4. 카카오 로그인한 사용자가 뮤신사의 회원인지 확인합니다(DB 조회)
    4-1. 회원이 아니면 이름, 이메일 주소와 함께 회원가입 페이지로 리다이렉트 시킵니다.
    4-2. 회원이면 Jwt를 발행합니다.
  5. Refresh Token과 Access Token은 각각 DB(refresh_token 테이블, access_token 테이블)에 저장해둡니다.

로그아웃 과정

  1. 클라이언트가 로그아웃을 요청합니다.
  2. 서버는 클라이언트가 헤더에 넣어서 보낸 액세스 토큰을 parse해서 subject에 저장되어 있는 memberId를 조회합니다. 그리고 그 멤버 아이디를 통해서 refresh_token DB와 access_token DB에 있는 해당 회원의 토큰을 모두 지웁니다.

이런 방식으로 로그인, 로그아웃을 구현하였기 때문에 클라이언트 요청 헤더에 액세스 토큰이 포함되어 있다면, 다음과 같은 검증 과정이 필요할 것 같습니다.

  • 변조되지 않은 유효한 토큰인지 (내가 지정한 signKey로 잘 parse 되는지)
  • 해당 액세스 토큰이 DB(logout_token 테이블)에 존재하는지.
    • DB에 존재하지 않는다면 로그아웃된 사용자의 토큰이므로 다시 로그인하라는 의미의 에러 코드를 내려줍니다.
  • 액세스 토큰의 유효 기간이 지나지 않았는지
    • 지났다면 리프레시 토큰으로 재발급을 하라는 의미의 에러 코드를 내려줍니다.

구현해봅시다!

저는 kakao developers 카카오 로그인 문서를 보며 구현하였습니다.

공식 문서에 나와있는 프로세스입니다.

크게 보면
1. 인가 코드 받기: 클라이언트가 카카오 로그인을 하면, 카카오서버가 302 리다이렉트 url의 쿼리 파라미터로 인가코드를 보내줍니다.
2. 토큰 받기: 1번 단계에서 보내준 인가 코드와 API 키를 가지로 제 서버가 카카오 인증 서버에 토큰 요청을 보내면 응답 바디를 통해 토큰을 받을 수 있습니다.
3. 사용자 로그인 처리: 토큰까지 받으면 제 서버는 토큰 값을 가지고 카카오 인증서버에 요청을 보내서 회원의 정보를 조회할 수 있습니다(예를 들면 이메일, 이름, 닉네임 등등). 그렇게 조회한 정보를 가지고 사용자를 로그인 또는 회원가입 처리 시키는 것은 직접 구현해야합니다. (예를 들어 DB member 테이블에 이메일 주소를 조회해서 있다면 로그인 처리를 시키고, 없다면 회원 가입 페이지로 이동시킨다든가 이런 처리를 백엔드 개발자가 직접 구현해주어야 합니다.)

구현 전에 설정해줄 것들

앱 만들기

구현 전에, 카카오 개발자 페이지에서 제가 쓸 앱을 만들어주어야 합니다.

우측 상단 "내 애플리케이션" 버튼을 누르면 다음과 같은 화면이 보일 것입니다.
여기서 "애플리케이션 추가하기"를 눌러줍니다.

그러면 앱이 만들어질 것이고, 그 앱으로 들어가면

이렇게 제가 만든 앱의 api키를 확인할 수 있습니다.
api키는 타인에게 노출되면 안되기 때문에 가려놓았습니다.

사실 이 앱을 그대로 사용하는 것보다는 이 앱을 비즈앱으로 등록한 다음, TEST 앱을 만들어 그걸 사용하시는 것이 좋습니다.
비즈앱으로 등록되지 않은 앱은 접근할 수 있는 사용자 정보가 제한되어 있습니다. (닉네임, 프로필사진, 이메일만 접근 가능)
하지만 비즈앱으로 등록하면 개발 환경에서 쓸 수 있는 TEST앱을 만들 수 있고 그 테스트앱은 더 다양한 정보에 접근이 가능합니다.

저는 테스트앱을 만들어 사용하고 있는데, 사실 제가 구현한 내용을 보면 사용자 이메일 정보만 가지고도 충분하기 때문에 지금 생각하면 굳이 만들 필요까진 없었나 싶기도 합니다.

동의 항목 설정하기

아무튼, 이렇게 앱을 만들었다면 좌측 "카카오 로그인 -> 동의 항목"을 눌러서 회원의 어떤 정보를 가져올지를 선택할 수 있습니다.
회원이 로그인을 할 때 이 항목의 정보들을 제공해도 된다는 동의를 하게 됩니다.

저는 이름과 이메일을 필수로 받고 있습니다.

도메인과 리다이렉트 URI 설정하기

좌측 "플랫폼"을 누르고 스크롤을 아래로 내리면 Web이라는 부분이 보입니다.
여기서 사이트 도메인을 설정해주어야 합니다.

지금은 개발 단계이기 때문에 http://localhost:8080 을 넣어주었습니다.
그리고 그 밑에 리다이렉트 URI를 등록해야한다는 설명과 옆에 링크가 걸린 것을 보실 수 있는데, 그 링크로 들어가서 리다이렉트 URI를 등록해줍니다.

활성화 설정을 ON으로 바꾸어주고

아래에서 리다이렉트 URI를 등록해줍니다.

이 리다이렉트 URI는 바로 인가 코드를 받을 때 302로 리다이렉션 될 URI를 의미합니다.

즉 저는 "http://localhost:8080/oauth/code" 로 리다이렉션 해달라고 설정해놨기 때문에, 사용자가 로그인을 하면 카카오 인증 서버가 http://localhost:8080/oauth/code?code=코드값 으로 리다이렉션 시켜줍니다.

서버 개발자의 입장에서 말하면, 사용자가 카카오 로그인을 해서 성공하면 oauth/code?code=코드값 으로 GET요청이 들어오게 되므로, 뒤에 쿼리 파라미터로 들어온 코드 값을 가지고 카카오 인증 서버에 토큰을 요청하는 코드를 작성하면 되는 것입니다.


카카오 로그인과 관련한 상수값 설정해주기

카카오 로그인 시에 필요한 고정된 값들이 몇가지가 있습니다.
대표적으로 API 키가 있고, 그 외에도 리다이렉트 URI, 토큰을 얻기 위해 요청을 보내는 URI, 유저의 정보를 얻기 위해 요청을 보내는 URI 등등도 고정된 값이기 때문에 하드코딩 하는 것보다는 한 곳에 상수처럼 정의해놓고 쓰는 것이 좋겠다는 생각을 했습니다.
이렇게 한 곳에 모아 놓으면 나중에 그 값이 변경되더라도 수정이 용이할 것입니다.

저는 application-auth.properties에 그 값들을 모아 두었습니다.

API키는 타인에게 공개되면 안되기 때문에, 깃헙에 올라가지 않도록 .gitignoreapplication-auth.properties를 추가해줍니다.

그리고 이 상수들을 가져다 쓸 수 있도록 클래스를 하나 만들어 빈으로 등록해주고, @Value 어노테이션으로 값을 넣어주었습니다.

KakaoLoginProperties.java

package com.mewsinsa.auth.kakao;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.PropertySource;
import org.springframework.stereotype.Component;

/**
 * 카카오 소셜 로그인에 필요한 정보를 담고 있는 Bean입니다.
 */
@Component
@PropertySource("classpath:application-auth.properties")
public class KakaoLoginProperties {
  @Value("${kakao.login.api_key}")
  private String kakaoLoginApiKey;

  @Value("${kakao.login.redirect_uri}")
  private String redirectUri;

  @Value("${kakao.login.uri.code}")
  private String codeReqeustUri;

  @Value("${kakao.login.uri.base}")
  private String kakaoAuthBaseUri;

  @Value("${kakao.login.uri.token}")
  private String tokenRequestUri;

  @Value("${kakao.api.uri.base}")
  private String kakaoApiBaseUri;

  @Value("${kakao.api.uri.user}")
  private String kakaoApiUserInfoRequestUri;

  @Value("${kakao.login.client_secret}")
  private String kakaoClientSecret;

  //==Getter==//
  public String getKakaoLoginApiKey() {
    return kakaoLoginApiKey;
  }

  public String getRedirectUri() {
    return redirectUri;
  }

  public String getCodeReqeustUri() {
    return codeReqeustUri;
  }

  public String getKakaoAuthBaseUri() {
    return kakaoAuthBaseUri;
  }

  public String getTokenRequestUri() {
    return tokenRequestUri;
  }

  public String getKakaoApiBaseUri() {
    return kakaoApiBaseUri;
  }

  public String getKakaoApiUserInfoRequestUri() {
    return kakaoApiUserInfoRequestUri;
  }

  public String getKakaoClientSecret() {
    return kakaoClientSecret;
  }
}

이제 이 값이 필요한 클래스에서 해당 빈을 의존성 주입받아 사용하면 됩니다.
제 코드에서는 KakaoLoginService.java 클래스에서 해당 빈의 변수들을 사용하게 됩니다.


Controller 만들기

KakaoLoginController.java

@RequestMapping("/oauth/kakao")
@RestController
public class KakaoLoginController {

  private final KakaoLoginService kakaoLoginService;
  private final JwtService jwtService;

  public KakaoLoginController(KakaoLoginService kakaoLoginService, JwtService jwtService) {
    this.kakaoLoginService = kakaoLoginService;
    this.jwtService = jwtService;
  }

  @GetMapping("/code")
  public ResponseEntity<Object> kakaoLogin(@RequestParam(value = "code", required = false) String code,
      @RequestParam(value = "error", required = false) String error,
      @RequestParam(value = "error_description", required = false) String error_description,
      @RequestParam(value = "state", required = false) String state) {
      
      KakaoTokenResponseDto kakaoToken = kakaoLoginService.getToken(code);

      // 액세스 토큰으로 회원 정보 얻어오기
      KakaoUserInfoDto userInfo = kakaoLoginService.getUserInfo(kakaoToken);
     
     //  내용 생략 ...
  }

/oauth/kakao/codeGET 요청이 들어왔을 때 이를 처리하는 컨트롤러를 만들어줍니다.

이 문서를 보시면, 응답 값으로 어떤 것들이 넘어오는지가 자세히 나와있습니다.

저는 그냥 모든 쿼리 파라미터를 다 받아주었고, 모두 필수 값이 아니기 때문에 required = false를 붙여주었습니다.


Service - 토큰 받아오기

컨트롤러의 코드를 보면 getToken() 메소드를 호출하는 부분이 있습니다.

KakaoTokenResponseDto kakaoToken = kakaoLoginService.getToken(code);

이 부분이 카카오 인증 서버에 요청을 날려서 토큰 값을 받아오는 부분입니다.

KakaoLoginService.java

/**
   * 2. 토큰 얻기 단계
   * @param code 인증 코드
   * @return 카카오 토큰 정보
   */
  public KakaoTokenResponseDto getToken(String code) {
    // 토큰 요청 데이터 -> MultiValueMap
    MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
    params.add("grant_type", "authorization_code");
    params.add("client_id", kakaoLoginProperties.getKakaoLoginApiKey());
    params.add("redirect_uri", kakaoLoginProperties.getRedirectUri());
    params.add("code", code);
//    params.add("client_secret", kakaoLoginProperties.getKakaoClientSecret());


    // 웹 클라이언트로 요청보내기
    String response = WebClient.create(kakaoLoginProperties.getKakaoAuthBaseUri())
        .post()
        .uri(kakaoLoginProperties.getTokenRequestUri())
        .body(BodyInserters.fromFormData(params))
        .header("Content-type","application/x-www-form-urlencoded;charset=utf-8" ) //요청 헤더
        .retrieve()
        .bodyToMono(String.class)
        .block();

    //json 응답을 객체로 변환
    ObjectMapper objectMapper = new ObjectMapper();
    KakaoTokenResponseDto kakaoToken = null;

    try {
      kakaoToken = objectMapper.readValue(response, KakaoTokenResponseDto.class);
    } catch (JsonProcessingException e) {
      e.printStackTrace();
    }

    return kakaoToken;

  }

저는 WebClient로 카카오 인증 서버에 요청을 보냈습니다.

이 문서를 보시면 요청을 보낼 때의 조건들과 응답으로 오는 값들이 친절하게 설명되어 있습니다. 이 규격에 맞추어 요청을 보내주시면 됩니다.

그리고 응답으로 받은 String을 ObjectMapper를 써서 KakaoTokenResponseDto에 담아주었습니다.

KakaoTokenResponseDto.java

package com.mewsinsa.auth.kakao.controller.dto;

import com.fasterxml.jackson.annotation.JsonProperty;

public class KakaoTokenResponseDto {

  /**
   * tokenType: bearer로 고정
   */
  @JsonProperty("token_type")
  private String tokenType;
  @JsonProperty("access_token")
  private String accessToken;
  /**
   * 액세스 토큰과 ID 토큰의 만료 시간(초)
   */
  @JsonProperty("expires_in")
  private Integer expiresIn;
  @JsonProperty("refresh_token")
  private String refreshToken;
  /**
   * 리프레시 토큰 만료 시간(초)
   */
  @JsonProperty("refresh_token_expires_in")
  private Integer refreshTokenExpiresIn;

  @JsonProperty("id_token")
  private String idToken;
  @JsonProperty("scope")
  private String scope;

  //==Getter==//
  public String getTokenType() {
    return tokenType;
  }

  public String getAccessToken() {
    return accessToken;
  }

  public Integer getExpiresIn() {
    return expiresIn;
  }

  public String getRefreshToken() {
    return refreshToken;
  }

  public Integer getRefreshTokenExpiresIn() {
    return refreshTokenExpiresIn;
  }

  public String getIdToken() {
    return idToken;
  }

  public String getScope() {
    return scope;
  }

  //==Setter==//
  public void setTokenType(String tokenType) {
    this.tokenType = tokenType;
  }

  public void setAccessToken(String accessToken) {
    this.accessToken = accessToken;
  }

  public void setExpiresIn(Integer expiresIn) {
    this.expiresIn = expiresIn;
  }

  public void setRefreshToken(String refreshToken) {
    this.refreshToken = refreshToken;
  }

  public void setRefreshTokenExpiresIn(Integer refreshTokenExpiresIn) {
    this.refreshTokenExpiresIn = refreshTokenExpiresIn;
  }

  public void setIdToken(String idToken) {
    this.idToken = idToken;
  }

  public void setScope(String scope) {
    this.scope = scope;
  }
}

제 코드에선 사실상 저기서 accessToken만 필요하긴 합니다.
하지만 Object로 변환해서 꺼내와야하니, 응답으로 올수 있는 모든 필드를 정의해주었습니다.

토큰이 잘 넘어오는지 확인해보겠습니다.

이렇게 ObjectMapper로 응답 값을 객체로 바꾼 후, 액세스토큰 값을 찍어보면

잘 넘어오는 것을 확인할 수 있었습니다.


Service - 유저 정보 가져오기

이제 액세스 토큰까지 가져왔으니 거의 다 왔습니다.
위의 과정으로 받은 액세스 토큰을 가지로 카카오 인증 서버에 유저의 정보를 요청하면 됩니다.

아래는 KakaoLoginService.java에 정의되어 있는 유저 정보를 가져오는 메소드입니다.

  public KakaoUserInfoDto getUserInfo(KakaoTokenResponseDto kakaoToken) {

    // 액세스 토큰으로 회원정보를 가져옵니다.
    String response = WebClient.create(kakaoLoginProperties.getKakaoApiBaseUri())
        .post()
        .uri(kakaoLoginProperties.getKakaoApiUserInfoRequestUri())
        .header("Authorization", "Bearer " + kakaoToken.getAccessToken())
        .header("Content-type","application/x-www-form-urlencoded;charset=utf-8" ) //요청 헤더
        .retrieve()
        .bodyToMono(String.class)
        .block();


    //json 응답을 객체로 변환
    ObjectMapper objectMapper = new ObjectMapper();
    KakaoUserInfoDto userInfo = null;

    try {
      userInfo = objectMapper.readValue(response, KakaoUserInfoDto.class);
    } catch (JsonProcessingException e) {
      throw new IllegalStateException(e);
    }

    return userInfo;
  }

역시 WebClient로 요청을 보내고, 받은 정보를 ObjectMapper를 통해 KakaoUserInfoDto에 담아주었습니다.

KakaoUserInfoDto.java

package com.mewsinsa.auth.kakao.controller.dto;

import com.fasterxml.jackson.annotation.JsonProperty;

public class KakaoUserInfoDto {
  private String id;
  @JsonProperty("connected_at")
  private String connectedAt;
  @JsonProperty("kakao_account")
  private KakaoAccount kakaoAccount;

  //==Getter==//
  public String getId() {
    return id;
  }

  public String getConnectedAt() {
    return connectedAt;
  }

  public KakaoAccount getKakaoAccount() {
    return kakaoAccount;
  }

  //==Setter==//

  public void setId(String id) {
    this.id = id;
  }

  public void setConnectedAt(String connectedAt) {
    this.connectedAt = connectedAt;
  }

  public void setKakaoAccount(
      KakaoAccount kakaoAccount) {
    this.kakaoAccount = kakaoAccount;
  }


  public class KakaoAccount {
    @JsonProperty("name_needs_agreement")
    private boolean nameNeedsAgreement;
    private String name;
    private String email;
    @JsonProperty("has_email")
    private boolean hasEmail;
    @JsonProperty("email_needs_agreement")
    private boolean emailNeedsAgreement;
    @JsonProperty("is_email_valid")
    private boolean isEmailValid;
    @JsonProperty("is_email_verified")
    private boolean isEmailVerified;

    //==Getter==//
    public String getName() {
      return name;
    }

    public String getEmail() {
      return email;
    }

    public boolean isNameNeedsAgreement() {
      return nameNeedsAgreement;
    }

    public boolean isHasEmail() {
      return hasEmail;
    }

    public boolean isEmailNeedsAgreement() {
      return emailNeedsAgreement;
    }

    public boolean isEmailValid() {
      return isEmailValid;
    }

    public boolean isEmailVerified() {
      return isEmailVerified;
    }

    //==Setter==//
    public void setName(String name) {
      this.name = name;
    }

    public void setEmail(String email) {
      this.email = email;
    }

    public void setNameNeedsAgreement(boolean nameNeedsAgreement) {
      this.nameNeedsAgreement = nameNeedsAgreement;
    }

    public void setHasEmail(boolean hasEmail) {
      this.hasEmail = hasEmail;
    }

    public void setEmailNeedsAgreement(boolean emailNeedsAgreement) {
      this.emailNeedsAgreement = emailNeedsAgreement;
    }

    public void setEmailValid(boolean emailValid) {
      isEmailValid = emailValid;
    }

    public void setEmailVerified(boolean emailVerified) {
      isEmailVerified = emailVerified;
    }
  }
}

이너 클래스로 정의된 KakaoAccount에 유저 정보에 대한 필드들을 정의해줍니다.
해당 Dto는 이 문서를 참고보고 어떤 항목 동의를 받으면 어떤 값들이 넘어오는지를 확인하여 구현하였습니다.

이것도 마찬가지로 로그를 찍어보면


유저 정보가 잘 넘어오는 것을 확인할 수 있습니다.

여기까지가 카카오 로그인에 대한 구현이었습니다.
앞에서 작성했다싶이 카카오 인증 서버에서 유저 정보를 제공받은 뒤 로그인처리는 서버 개발자가 구현해주어야 합니다.
이 부분은 JWT를 이용한 토큰 방식 로그인 내용이기에 다음 포스트에서 로그인 요청이 들어오면 JWT를 만들어 발행하는 부분을 다뤄보고자 합니다.

Reference

profile
블로그 이전하려고 합니다! 👉 https://onfonf.tistory.com 🍀

0개의 댓글