와너두 05

·2023년 11월 17일
0

웹 프로젝트

목록 보기
11/18

오늘은 OAuth 2.0 중 카카오와 깃허브 로그인을 연결할 것이다.

카카오 로그인

우선 카카오 로그인의 대략적인 과정은 아래와 같다.

/outh/authorize 로 get 방식 요청을 하면 카카오계정을 확인하고 동의 항목을 확인한 뒤 카카오에서 인가 코드를 설정해둔 redirect uri 로 전달해준다. 그럼 나는 그 redirect uri 의 querystring 에서 ?code= 뒷부분, 그러니까 인가코드를 통해 outh/token 으로 post 요청을 보내 access 토큰을 발급받는다. 이 access 토큰을 이용해 사용자 정보를 얻어올 수 있는 것이다.

처음에는 모두 백에서 처리하고 return 값으로 access 토큰, refresh 토큰을 받으면 될 것이라고 간단하게 생각했으나, 백에서 redirect uri 를 실행하면 토큰도 백엔드의 uri 로 가 프론트에서 받아오는 방법을 찾지 못했다.(검정 바탕에 흰 글씨로 {"access": dsfsdlfdf~~, "refresh": dslfkjskf~} 이런 것만 나왔다...)

그리고 그런 카카오톡 로그인을 프론트와 백이 분리해서 처리하는 방법을 열심히 찾아본 결과, 보통 저 중 step1 을 프론트에서 처리해 인가 코드를 백에 넘겨주거나, 아예 javascript sdk 를 사용해서 로그인 자체를 프론트에서 처리한다고 한다.

그러니까

프론트에서 카카오톡 로그인 및 동의 화면을 출력 > 카카오에서 인가 코드를 redirect_uri 뒤에 붙여서 return > 해당 uri 에서 쿼리스트링을 가공하여 백엔드 url 로 POST > django 백엔드에서 해당 코드와 client_id 등을 이용해 token 을 받아옴 > 해당 access token 으로 사용자 정보를 받아옴 > 필요한 정보를 추출하여 User Model 로 저장한 뒤 해당 user 의 정보를 토큰과 함께 프론트로 return > 프론트에서는 받은 정보를 localStorage 에 저장

이런 방식인 것이다. 처음에는 이걸 모르고 백엔드에서 준 코드를 사용해야 한다! 라는 생각만 하면서 어떻게든 토큰을 뽑아오려고 했으나, redirect uri 에서 멈춘 화면을 몇 번이나 본 뒤 아예 새로운 방법으로 로그인을 시도했다.

우선 인가 코드를 받을 redirect 창을 따로 만들어준다. 나는 redirect.html 과 redirectGit.html 을 만들어서 카카오와 깃을 각각 처리했다.

login.html 에서 k 버튼을 누르면 handleKakao() 가 실행된다.

login.js

async function handleKakao() {

  window.Kakao.Auth.authorize({
    redirectUri: `${frontend_base_url}/templates/redirect.html`,
    // scope: 'profile_nickname, account_email, profile_profile_image_url',

  });
}
  • 저 scope 는 허가를 요청하는 사용자 정보 범위인데, 그냥 모두 받아온 뒤 가공하기로 했다.
  • login.html 의 head 부분에 <script src="https://t1.kakaocdn.net/kakao_js_sdk/2.5.0/kakao.js" integrity="sha384-Y2BJfyFHzxWwUOht5LBqgEusLOryH3F4Pi9MipRF6YWrrPBk5KLvfF9UbR0Ec2RD" crossorigin="anonymous"></script> 을 써주어야 한다. integrity 버전은 sdk 버전마다 다를 수 있으니 여기를 참고하여 고치자.
  • 카카오 자체에서 kakao.auth.authorize() 를 제공하기 때문에 해당 함수를 통해 카카오 인증을 시작하는 창에 진입할 수 있다.(redirect_uri 설정은 필수)

redirect.html

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <title>LogIn</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="stylesheet" href="../css/login.css">
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
    <!-- <script src="https://t1.kakaocdn.net/kakao_js_sdk/${VERSION}/kakao.min.js" integrity="${INTEGRITY_VALUE}"
        crossorigin="anonymous"></script> -->
    <script src="https://unpkg.com/react-query/dist/react-query.development.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>

</head>

<body>

    <script type="module" src="../script/redirect.js"></script>
</body>

</html>

이제 이 redirct.html 에서 인가코드를 받으면 redirect.js 로 인가코드만 백에 보낼 것이다.

redirect.js

import config from '../APIkey.js'

const code = window.location.search;


axios.post(`${config.backend_base_url}/accounts/kakao/login/`, { code: code })
    .then((response) => {
        localStorage.clear()
     
        const response_json = response.data;

        localStorage.setItem("access", response_json.access);
        localStorage.setItem("refresh", response_json.refresh);
        localStorage.setItem("profile", JSON.stringify(response_json.user_profile));

        alert("환영합니다.");
        window.location.replace(`${config.frontend_base_url}/templates/main.html`);
    })
    .catch((error) => {
        console.error('Error:', error);
        // Handle error
    });

window.location.search 로 front_base_url 뒤의 ?code=sldfksdlfs~~ 만 받은 뒤 axios.post() 를 사용해 post 방식으로 백엔드의 카카오 로그인 처리 url 로 보내준다.

그러면 백엔드에서는 받은 코드값을 이용해 카카오 토큰 api 로 requests.post() 를 보낸다.

client_id = os.environ.get("KAKAO_REST_API_KEY")

received_code = request.data.get('code')  # 받은 ?code='' 값
code_value = received_code.split("?code=")[-1]  # 코드 값만 추출
print(code_value)

kakao_token = requests.post(
	"https://kauth.kakao.com/oauth/token",
	 headers={"Content-Type": "application/x-www-form-urlencoded"},
	 data={
			"grant_type": "authorization_code",
			"client_id": client_id,
			"redirect_uri": "http://127.0.0.1:5500/templates/redirect.html",
			"code": code_value
		   },
	  )

client_id 는 .env 에 저장해두었다. 이제 data 에는 access_token 과 refresh_token 이 있을 것이다.

후에 return 값으로 토큰을 보낼 예정이니 미리 token_data 변수에 담아놓는다.

access_token = kakao_token.json()['access_token']
refresh_token = kakao_token.json()['refresh_token']

token_data = {'access': access_token, 'refresh': refresh_token}

이제 토큰을 받았으니 user data 를 받아야 한다. user data 는 일단 모두 받아둔 뒤 필요한 정보만 뽑아올 예정이다.

user_data = requests.get(
            "https://kapi.kakao.com/v2/user/me",
            headers={"Authorization": f"Bearer {access_token}"},
        )
        # 이메일, 닉네임, 프로필 사진 가져오기
        
        user_data = user_data.json()

        kakao_account = user_data.get('kakao_account')
        
        user_email = kakao_account.get('email')
        user_nickname = kakao_account.get('profile')['nickname']
        try:
            user_img = kakao_account.get('profile')['profile_image_url']
        except:
            user_img = 'media/userProfile/default.png'

받은 access_token 으로 유저 데이터를 반환하는 api 에 접속한 다음 해당 return 값에서 이메일, 닉네임, 프로필 사진을 가져온다. 이때 프로필 사진은 추가 동의 항목이므로 사용자가 동의하지 않을 시 디폴트 이미지를 넣어준다. 해당 api 는 여기서 사용법을 자세히 볼 수 있다.

이제 사용자 정보를 가져왔으니 내 데이터베이스에 사용자를 저장한 뒤, 해당 유저의 id 와 email 을 프론트로 보내야 한다.

try:
            user = User.objects.get(email=user_email)
            login(request, user, backend='django.contrib.auth.backends.ModelBackend')
            print(user.nickname, user.email, "password", user.password)

            token_data['user_profile'] = {'uid': user.id, 'email': user.email}
            return Response(data=token_data, status=status.HTTP_200_OK)
        except User.DoesNotExist:
            user = User.objects.create(
                email=user_email,
                nickname=user_nickname,
                profile_img=user_img,
            )
            user.set_unusable_password()
            user.save()
            login(request, user, backend='django.contrib.auth.backends.ModelBackend')
            print(user.nickname, user.email, "password", user.password)

            token_data['user_profile'] = {'uid': user.id, 'email': user.email}
            return Response(data=token_data, status=status.HTTP_200_OK)
        except Exception:
            return Response(status=status.HTTP_400_BAD_REQUEST)
  • 이때 로그인 뒤의 backend='django.contrib.auth.backends.ModelBackend' 을 쓰지 않으면 multiple authentication 오류가 난다.

그러면 이제 프론트에서는 받은 값을 localStorage 에 저장한 뒤 필요할 때마다 가져오면 된다.

import config from '../APIkey.js'

const code = window.location.search;
// console.log(code)


axios.post(`${config.backend_base_url}/accounts/kakao/login/`, { code: code })
    .then((response) => {
        localStorage.clear()
        
        const response_json = response.data;

        localStorage.setItem("access", response_json.access); // access 토큰 저장
        localStorage.setItem("refresh", response_json.refresh); // refresh 토큰 저장
        localStorage.setItem("profile", JSON.stringify(response_json.user_profile));  // user profile 저장

        alert("환영합니다.");
        window.location.replace(`${config.frontend_base_url}/templates/main.html`);
    })
    .catch((error) => {
        console.error('Error:', error);
        // Handle error
    });

백엔드 함수 전문

class KakaoLogin(APIView):
    def post(self, request):
        state = os.environ.get("STATE")
        client_id = os.environ.get("KAKAO_REST_API_KEY")

        received_code = request.data.get('code')  # 받은 ?code='' 값
        code_value = received_code.split("?code=")[-1]  # 코드 값만 추출
        print(code_value)

        kakao_token = requests.post(
            "https://kauth.kakao.com/oauth/token",
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            data={
                "grant_type": "authorization_code",
                "client_id": client_id,
                "redirect_uri": "http://127.0.0.1:5500/templates/redirect.html",
                "code": code_value
            },
        )
        print(kakao_token.json()['access_token'])  # access_token 발급 완료
        access_token = kakao_token.json()['access_token']
        refresh_token = kakao_token.json()['refresh_token']

        token_data = {'access': access_token, 'refresh': refresh_token}
        # access_token 으로 사용자 정보 가져오기
        user_data = requests.get(
            "https://kapi.kakao.com/v2/user/me",
            headers={"Authorization": f"Bearer {access_token}"},
        )
        # 이메일, 닉네임, 프로필 사진 가져오기
        # print(user_data.json())
        user_data = user_data.json()

        kakao_account = user_data.get('kakao_account')
        # print(user_data)
        # print(kakao_account)
        user_email = kakao_account.get('email')
        user_nickname = kakao_account.get('profile')['nickname']
        try:
            user_img = kakao_account.get('profile')['profile_image_url']
        except:
            user_img = 'media/userProfile/default.png'
        # print(user_email, user_nickname, user_img)

        # 유저의 이메일이 존재하지 않으면 저장 / 존재하면 오류 출력
        try:
            user = User.objects.get(email=user_email)
            login(request, user, backend='django.contrib.auth.backends.ModelBackend')
            print(user.nickname, user.email, "password", user.password)

            token_data['user_profile'] = {'uid': user.id, 'email': user.email}
            return Response(data=token_data, status=status.HTTP_200_OK)
        except User.DoesNotExist:
            user = User.objects.create(
                email=user_email,
                nickname=user_nickname,
                profile_img=user_img,
            )
            user.set_unusable_password()
            user.save()
            login(request, user, backend='django.contrib.auth.backends.ModelBackend')
            print(user.nickname, user.email, "password", user.password)

            token_data['user_profile'] = {'uid': user.id, 'email': user.email}
            return Response(data=token_data, status=status.HTTP_200_OK)
        except Exception:
            return Response(status=status.HTTP_400_BAD_REQUEST)

깃허브

깃허브의 로직은 카카오와 똑같다. 몇 개 다른 점이 있다면 우선 깃허브에서는 자체적인 authorize 함수를 제공하지 않기 때문에 href 로 이동을 해야 한다.

login.js

async function handleGithub() {
  console.log("github")
  const client_id = config.SOCIAL_AUTH_GITHUB_CLIENT_ID
  const redirect_uri = `${frontend_base_url}/templates/redirectGit.html`
  
  const githubURL = `https://github.com/login/oauth/authorize?client_id=${client_id}&redirect_uri=${redirect_uri}&scope=read:user,user:email`
  console.log(githubURL)
  window.location.href = githubURL

}

또한 깃허브는 scope 를 통해 별도의 요청을 하지 않을 시 public 모드인 이메일만 제공해주기 때문에, 이메일을 uniquer field 로 해놓은 우리는 scope=read:user,user:email 를 통해 별도의 권한 요청을 해주어야 했다.

redirectGit.js

import config from '../APIkey.js'

console.log("redirect_git")
const code = window.location.search;
console.log(code)


axios.post(`${config.backend_base_url}/accounts/github/login/`, { code: code })
    .then((response) => {
        localStorage.clear()
        console.log(response.data); // Log token and accompanying information
        const response_json = response.data;

        localStorage.setItem("access", response_json.access);

        localStorage.setItem("payload", JSON.stringify(response_json.user_profile));

        alert("환영합니다.");
        window.location.replace(`${config.frontend_base_url}/templates/main.html`);
    })
    .catch((error) => {
        console.error('Error:', error);
        // Handle error
    });

백은 카카오와 똑같다. 프론트에서 인가코드를 보내주면 백은 토큰 요청 url 에 requests.post() 요청을 통해 토큰을 받아오고, 해당 access_token 으로 유저 정보를 받아오는 식이다. 그러면 프론트는 다시 그 토큰과 유저 정보를 받아 localStorage 에 저장한다. 보내는 body 와 header 가 조금씩 달라지는 정도이다.

백엔드 로직 전문

class GithubLogin(APIView):
    def post(self, request):

        state = os.environ.get("STATE")
        client_id = os.environ.get("SOCIAL_AUTH_GITHUB_CLIENT_ID")
        client_secret = os.environ.get("SOCIAL_AUTH_GITHUB_SECRET")

        received_code = request.data.get('code')
        code_value = received_code.split("?code=")[-1]

        """토큰"""
        github_token = requests.post(
            f"https://github.com/login/oauth/access_token",
            headers={'Accept': 'application/json'},
            data={
                "client_id": client_id,
                "client_secret": client_secret,
                "redirect_url": "http://127.0.0.1:5500/templates/redirectGit.html",
                "code": code_value,
            },
        )

        print(github_token.json()['access_token'])
        access_token = github_token.json()['access_token']
        # refresh_token = github_token.json()['refresh_token']

        token_data = {'access': access_token }

        """유저 데이터"""
        user_data = requests.get(
            "https://api.github.com/user",
            headers={
                "Authorization": f"Bearer {access_token}",
                "Accept": "application/json",
                "Scope": "user"
            },
        )

        user_data = user_data.json()
        # print("user data" , user_data)

        """유저 이메일"""
        user_emails = requests.get(
            "https://api.github.com/user/emails",
            headers={
                "Authorization": f"Bearer {access_token}",
                "Accept": "application/json",
            },
        )
        user_emails = user_emails.json()
        
        user_emails = user_emails[0]['email']
        # print("user email" , user_emails)
        try:
            user = User.objects.get(email=user_emails)
            login(request, user, backend='django.contrib.auth.backends.ModelBackend')

            token_data['user_profile'] = {'uid': user.id, 'email': user.email}
            return Response(data=token_data, status=status.HTTP_200_OK)
        except User.DoesNotExist:
            user = User.objects.create(
                nickname=user_data.get("login"),
                email=user_emails,
                profile_img=user_data.get("avatar_url"),
            )
            user.set_unusable_password()    # password 없음
            user.save()
            login(request, user, backend='django.contrib.auth.backends.ModelBackend')
            # print(user.nickname, user.email, "password", user.password)

            token_data['user_profile'] = {'uid': user.id, 'email': user.email}
            return Response(data=token_data, status=status.HTTP_200_OK)
        except Exception:
            return Response(status=status.HTTP_400_BAD_REQUEST)

이러면 사용자의 화면은 login.html > redirect.html?code=aldsjl~ > (백엔드 처리) > main.html 로 가게 된다.

  • 참고로 redirect uri 는 카카오든 깃허브든 앱을 등록할 때 같이 등록해두어야 한다.

비교적 간단한 로직인데도 계속 헤맸던 이유는 첫째, 프론트와 백의 영역을 어떻게 나눠야 할지 몰랐고 둘째, 백에서 준 코드를 변경하지 말고 사용해야 한다고 생각했고 셋째, 결국 로그인이 어떤 식으로 이루어지는지 몰랐기 때문이다. 처음에는 redirect_uri 가 뭔지도 몰라서 아무거나 설정해놨다가 오류를 많이 만났고, 인가 코드 또한 개념이 안 잡혀서 response 로 오는 줄 알고 계속해서 fetch 문으로 보내다가 오류를 많이 만났다.

직접 코드를 짜고 구글링 해보면서 OAuth 에 대해 조금 더 알게 된 기분이 든다.

참고 사이트
https://developers.kakao.com/docs/latest/ko/javascript/getting-started
https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info
https://kyumin1020.tistory.com/65
https://velog.io/@ads0070/%EC%B9%B4%EC%B9%B4%EC%98%A4-%EB%A1%9C%EA%B7%B8%EC%9D%B8-API%EB%A1%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%9D%B8%EC%A6%9D%ED%95%98%EA%B8%B0
https://heokknkn.tistory.com/57

profile
공부 중

1개의 댓글

comment-user-thumbnail
2023년 11월 20일

로그인 구현 과정을 상세하게 기록해주셨네요👍

답글 달기