오늘은 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',
});
}
<script src="https://t1.kakaocdn.net/kakao_js_sdk/2.5.0/kakao.js" integrity="sha384-Y2BJfyFHzxWwUOht5LBqgEusLOryH3F4Pi9MipRF6YWrrPBk5KLvfF9UbR0Ec2RD" crossorigin="anonymous"></script>
을 써주어야 한다. integrity 버전은 sdk 버전마다 다를 수 있으니 여기를 참고하여 고치자.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)
그러면 이제 프론트에서는 받은 값을 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 가 뭔지도 몰라서 아무거나 설정해놨다가 오류를 많이 만났고, 인가 코드 또한 개념이 안 잡혀서 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
로그인 구현 과정을 상세하게 기록해주셨네요👍