프로젝트

📌 3. Django 백엔드 카카오 OAuth 최종코드

Chansman 2025. 6. 13. 06:03
from urllib.parse import parse_qs, urlencode

import requests
from django.conf import settings
from django.contrib.auth import get_user_model, login
from django.contrib.auth.base_user import BaseUserManager
from django.core import signing
from django.http import Http404
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import RedirectView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework_simplejwt.tokens import RefreshToken

from django.http import HttpResponse
from accountbook.utils.jwt_cookie import set_jwt_cookie
from oauth.serializers import (  # 👈 반드시 serializers 위치 확인
    NicknameCheckSerializer,
    NicknameSerializer,
)

User = get_user_model()


#  OAuth 기본 상수
NAVER_CALLBACK_URL = '/oauth/naver/callback/'
NAVER_STATE = 'naver_login'
NAVER_LOGIN_URL = 'https://nid.naver.com/oauth2.0/authorize'
NAVER_TOKEN_URL = 'https://nid.naver.com/oauth2.0/token'
NAVER_PROFILE_URL = 'https://openapi.naver.com/v1/nid/me'

GITHUB_CALLBACK_URL = '/oauth/github/callback/'
GITHUB_STATE = 'github_login'
GITHUB_LOGIN_URL = 'https://github.com/login/oauth/authorize'
GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'
GITHUB_PROFILE_URL = 'https://api.github.com/user'

KAKAO_LOGIN_URL = 'https://kauth.kakao.com/oauth/authorize'
KAKAO_TOKEN_URL = 'https://kauth.kakao.com/oauth/token'
KAKAO_PROFILE_URL = 'https://kapi.kakao.com/v2/user/me'
KAKAO_CALLBACK_URL = '/oauth/kakao/callback/'



#  NAVER 로그인 Redirect
class NaverLoginRedirectView(RedirectView):
    def get_redirect_url(self, *args, **kwargs):
        domain = self.request.scheme + '://' + self.request.META.get('HTTP_HOST', '')
        callback_url = domain + NAVER_CALLBACK_URL
        state = signing.dumps(NAVER_STATE)

        params = {
            'response_type': 'code',
            'client_id': settings.NAVER_CLIENT_ID,
            'redirect_uri': callback_url,
            'state': state,
        }

        return f'{NAVER_LOGIN_URL}?{urlencode(params)}'


#  NAVER Callback 처리
def naver_callback(request):
    code = request.GET.get('code')
    state = request.GET.get('state')

    if NAVER_STATE != signing.loads(state):
        raise Http404("Invalid state value")

    access_token = get_naver_access_token(code, state)
    profile = get_naver_profile(access_token)
    email = profile.get('email')

    user = User.objects.filter(email=email).first()
    if user:
        if not user.is_active:
            user.is_active = True
            user.save()
        login(request, user)
        return redirect('main')

    return redirect(
        reverse('oauth:nickname') + f'?access_token={access_token}&oauth=naver'
    )


# GitHub 로그인 Redirect
class GithubLoginRedirectView(RedirectView):
    def get_redirect_url(self, *args, **kwargs):
        domain = self.request.scheme + '://' + self.request.META.get('HTTP_HOST', '')
        callback_url = domain + GITHUB_CALLBACK_URL
        state = signing.dumps(GITHUB_STATE)

        params = {
            'client_id': settings.GITHUB_CLIENT_ID,
            'redirect_uri': callback_url,
            'state': state,
        }

        return f'{GITHUB_LOGIN_URL}?{urlencode(params)}'


#  GitHub Callback 처리
def github_callback(request):
    code = request.GET.get('code')
    state = request.GET.get('state')

    if GITHUB_STATE != signing.loads(state):
        raise Http404("Invalid state value")

    access_token = get_github_access_token(code, state)
    if not access_token:
        raise Http404("Access token 없음")

    profile = get_github_profile(access_token)
    email = profile.get('email')

    user = User.objects.filter(email=email).first()
    if user:
        if not user.is_active:
            user.is_active = True
            user.save()
        login(request, user)
        return redirect('main')

    return redirect(
        reverse('oauth:nickname') + f'?access_token={access_token}&oauth=github'
    )


#  닉네임 설정 (회원가입 최종 단계)
@extend_schema(
    parameters=[
        OpenApiParameter(
            "access_token",
            OpenApiTypes.STR,
            OpenApiParameter.QUERY,
            required=True,
            description="네이버/깃허브 access_token",
        ),
        OpenApiParameter(
            "oauth",
            OpenApiTypes.STR,
            OpenApiParameter.QUERY,
            required=True,
            description="naver 또는 github",
        ),
    ],
    request=NicknameSerializer,
    responses={201: None},
)
@api_view(['POST'])
@permission_classes([AllowAny])
def oauth_nickname(request):
    access_token = request.query_params.get('access_token')
    oauth = request.query_params.get('oauth')

    if not access_token or oauth not in ['naver', 'github']:
        return Response(
            {'detail': '잘못된 요청입니다.'}, status=status.HTTP_400_BAD_REQUEST
        )

    serializer = NicknameSerializer(data=request.data)
    if serializer.is_valid():
        nickname = serializer.validated_data['nickname']

        if oauth == 'naver':
            profile = get_naver_profile(access_token)
        else:
            profile = get_github_profile(access_token)

        email = profile.get('email')
        if User.objects.filter(email=email).exists():
            return Response(
                {'detail': '이미 가입된 이메일입니다.'}, status=status.HTTP_409_CONFLICT
            )

        user = User(email=email, nickname=nickname, is_active=True)
        user.set_password(get_random_string(12))
        user.save()

        login(request, user)

        response = Response(
            {'detail': '회원가입 및 로그인 성공'}, status=status.HTTP_201_CREATED
        )
        return set_jwt_cookie(response, user)

    return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


#  NAVER 액세스 토큰 요청
def get_naver_access_token(code, state):
    params = {
        'grant_type': 'authorization_code',
        'client_id': settings.NAVER_CLIENT_ID,
        'client_secret': settings.NAVER_SECRET,
        'code': code,
        'state': state,
    }

    response = requests.get(NAVER_TOKEN_URL, params=params)
    if response.status_code != 200:
        raise Http404("NAVER 토큰 요청 실패")

    return response.json().get('access_token')


#  NAVER 프로필 요청
def get_naver_profile(access_token):
    headers = {'Authorization': f'Bearer {access_token}'}

    response = requests.get(NAVER_PROFILE_URL, headers=headers)
    if response.status_code != 200:
        raise Http404("NAVER 프로필 요청 실패")

    return response.json().get('response')


#  GitHub 액세스 토큰 요청
def get_github_access_token(code, state):
    params = {
        'client_id': settings.GITHUB_CLIENT_ID,
        'client_secret': settings.GITHUB_SECRET,
        'code': code,
        'state': state,
    }

    response = requests.get(GITHUB_TOKEN_URL, params=params)
    if response.status_code != 200:
        return None

    parsed = parse_qs(response.content.decode())
    return parsed.get('access_token', [None])[0]


#  GitHub 프로필 요청
def get_github_profile(access_token):
    headers = {'Authorization': f'Bearer {access_token}'}
    print("== get_github_profile ==")
    print(f"access_token: {access_token}")
    print(f"headers: {headers}")

    response = requests.get(GITHUB_PROFILE_URL, headers=headers)
    print(f"response.status_code: {response.status_code}")
    print(f"response.text: {response.text}")

    if response.status_code != 200:
        raise Http404("GitHub 프로필 요청 실패")

    result = response.json()
    if not result.get('email'):
        result['email'] = f'{result["login"]}@id.github.com'
    return result


#  닉네임 중복 확인
@extend_schema(
    parameters=[
        OpenApiParameter(
            name="nickname",
            required=True,
            type=OpenApiTypes.STR,
            location=OpenApiParameter.QUERY,
            description="중복 확인할 닉네임",
        ),
    ],
    responses={
        200: OpenApiResponse(
            description="사용 가능 여부 응답",
            examples=[
                {"name": "사용 가능", "value": {"is_available": True}},
                {
                    "name": "사용 불가",
                    "value": {
                        "is_available": False,
                        "detail": "이미 사용 중인 닉네임입니다.",
                    },
                },
            ],
        )
    },
    summary="닉네임 중복 확인 (GET)",
    description="닉네임이 사용 가능한지 GET 요청으로 확인합니다.",
)
@api_view(['GET'])
@permission_classes([AllowAny])
def check_nickname_get(request):
    serializer = NicknameCheckSerializer(data=request.query_params)
    if serializer.is_valid():
        return Response({'is_available': True}, status=status.HTTP_200_OK)
    return Response(
        {
            'is_available': False,
            'detail': serializer.errors.get('nickname', ['유효하지 않은 요청입니다.'])[
                0
            ],
        },
        status=status.HTTP_200_OK,
    )


# 닉네임 중복 확인 swagger용

@extend_schema(
    request=NicknameCheckSerializer,
    responses={200: None},
    summary="닉네임 중복 확인 (POST)",
    description="닉네임이 사용 가능한지 POST 요청으로 확인합니다.",
)
@api_view(['POST'])
@permission_classes([AllowAny])
def check_nickname_post(request):
    serializer = NicknameCheckSerializer(data=request.data)
    if serializer.is_valid():
        return Response({"available": True}, status=status.HTTP_200_OK)
    return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

# KAKAO 로그인 Redirect
class KakaoLoginRedirectView(RedirectView):
    def get_redirect_url(self, *args, **kwargs):
        params = {
            'client_id': settings.KAKAO_CLIENT_ID,
            'redirect_uri': settings.KAKAO_REDIRECT_URI,
            'response_type': 'code',
        }
        url = "https://kauth.kakao.com/oauth/authorize?" + urlencode(params)
        return url

def kakao_callback(request):
    code = request.GET.get('code')
    # 1. 액세스 토큰 요청
    token_json = get_kakao_access_token(
        code,
        redirect_uri='http://localhost:8000/oauth/kakao/callback/',
        client_id=settings.KAKAO_CLIENT_ID,
        client_secret=getattr(settings, 'KAKAO_CLIENT_SECRET', None)
    )
    access_token = token_json.get('access_token')
    if not access_token:
        return HttpResponse("토큰 발급 실패!")

    # 2. 사용자 정보 요청
    profile = get_kakao_profile(access_token)
    kakao_id = profile.get('id')
    kakao_account = profile.get('kakao_account', {})
    kakao_email = kakao_account.get('email')
    nickname = profile.get('properties', {}).get('nickname', f'kakao_{kakao_id}')

    # 이메일 없을 경우, 대체 이메일 생성
    if not kakao_email:
        kakao_email = f"{kakao_id}@kakao.com"

    # 3. 사용자 등록/로그인 (이메일 or id 기준)
    user, created = User.objects.get_or_create(
        email=kakao_email,
        defaults={
            'nickname': nickname,
            'is_active': True,
        }
    )

    login(request, user)

    # 4. JWT 쿠키 발급 (필요하다면 set_jwt_cookie 사용)
    response = redirect('main') # 배포시 ('https://mydomain.com/oauth/success')
    return set_jwt_cookie(response, user)


# 카카오 엑세스 토큰 발급
def get_kakao_access_token(code, redirect_uri, client_id, client_secret=None):
    url = "https://kauth.kakao.com/oauth/token"
    data = {
        'grant_type': 'authorization_code',
        'client_id': client_id,
        'redirect_uri': redirect_uri,
        'code': code,
    }
    if client_secret:
        data['client_secret'] = client_secret

    response = requests.post(url, data=data)
    return response.json()

# 카카오 유저 조희
def get_kakao_profile(access_token):
    url = "https://kapi.kakao.com/v2/user/me"
    headers = {
        "Authorization": f"Bearer {access_token}"
    }
    response = requests.get(url, headers=headers)
    return response.json()