과제/팀과제

[Django] 최종 프로젝트 : 지금은 전시상황!(5) - [BE&FE]소셜(구글)로그인 구현하기(수정)

마이구미+ 2023. 6. 15. 03:33
  • 거의 지난 프로젝트의 코드를 컨닝한 셈이지만 새로운 프로젝트 컨벤션에 맞게 적용하면서 코드를 다 이해했다면 괜찮지 않을까...?!

<사전 준비>

  • 구글에서 API 키를 얻어야 한다
  • 이 블로그를 참고해서 얻어보자!
 

Google 클라우드 플랫폼

로그인 Google 클라우드 플랫폼으로 이동

accounts.google.com

  • API 키를 얻었다면 .env 파일에 넣으면 된다
  • settings.json 쓰면 거기 넣고, my_settings.py 쓰면 거기 넣으면 된다
  • 알아서 각자 잘 넣어보자 ㅋㅋ

<백엔드>

- 유저 모델 필드 확인하기

class User(AbstractBaseUser):
    email = models.EmailField(
        verbose_name="사용자 이메일",
        max_length=255,
        unique=True,
    )

    nickname = models.CharField(max_length=100, unique=True, verbose_name="사용자 닉네임")
    password = models.CharField(max_length=255, verbose_name="비밀번호")

    GENDER_CHOICES = [
        ("남성", "남성"),
        ("여성", "여성"),
        ("밝히고 싶지 않음", "밝히고 싶지 않음"),
    ]
    gender = models.CharField(
        max_length=10,
        choices=GENDER_CHOICES,
        blank=False,
        error_messages="필수 입력 값입니다.",
        verbose_name="성별",
    )
    age = models.PositiveIntegerField(null=True, verbose_name="사용자 나이")
    bio = models.TextField(blank=True, null=True, verbose_name="사용자 자기소개")
    profile_image = models.ImageField(
        upload_to="profile_images/", blank=True, null=True, verbose_name="사용자 프로필이미지"
    )
    created_at = models.DateTimeField(auto_now_add=True, verbose_name="사용자 계정 생성일")
    updated_at = models.DateTimeField(auto_now=True, verbose_name="사용자 정보 마지막 수정일")
    LOGIN_TYPES = [
        ("normal", "일반"),
        ("google", "구글"),
        ("naver", "네이버"),
        ("kakao", "카카오"),
    ]
    login_type = models.CharField(
        "로그인유형", max_length=10, choices=LOGIN_TYPES, default="normal"
    )

    is_active = models.BooleanField(default=True)
    is_admin = models.BooleanField(default=False)

    objects = UserManager()

    USERNAME_FIELD = "email"  # 이걸로 로그인 하겠다 하는 필드. 들어가는 값은 unique=True 속성.
    REQUIRED_FIELDS = ["nickname"]  # createsuperuser할때 어떤 필드들을 작성받을 지 적는 필드.

    ...
  • 우리 프로젝트에서는 email을 통해 로그인을 하고 있고
  • 이메일이 중복될 수 없기 때문에 로그인 유형이 필요해서 필드를 추가하였다

- url 설정

# users/urls.py

from django.urls import path
from users import views
...

urlpatterns = [
    ...
    path("google/", views.GoogleLogin.as_view(), name="google_login"),
]
  • 일단 구글로그인을 수행할 url을 먼저 지정해준다

- 구글로그인 클래스형 뷰 만들기

class GoogleSignin(APIView):
    """구글 소셜 로그인"""

    def get(self, request):
        GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY")
        return Response(GOOGLE_API_KEY, status=status.HTTP_200_OK)

    def post(self, request):
        access_token = request.data["access_token"]
        user_data = requests.get(
            "https://www.googleapis.com/oauth2/v2/userinfo",
            headers={"Authorization": f"Bearer {access_token}"},
        )
        user_data = user_data.json()
        data = {
            "profile_image": user_data.get("picture"),
            "email": user_data.get("email"),
            "nickname": user_data.get("name"),
            "signin_type": "google",
        }
        return SocialSiginin(**data)
  • API키는 각자 설정파일에서 잘 불러오면 된다
  • 우선 구글 로그인 버튼을 눌렀을 때 get요청으로 들어간다
  • 구글 API키를 반환해서 프론트엔드에서 활용할 수 있도록 한다
  • 구글 로그인 창에서 내 계정을 클릭하면 post 요청으로 들어간다
  • 계정을 클릭하면 주소창에 access token이 띄워지는데 프론트엔드에서는 그걸 받아서 백엔드로 보내준다
  • 그럼 백엔드에서는 그 토큰을 이용해서 구글 사용자 정보 url에 접속해 사용자 정보를 얻고 소셜 로그인을 진행한다

- 소셜로그인 함수 만들기

  • 구글 외에도 카카오, 네이버 로그인을 구현할 예정이기 때문에 소셜로그인 기능을 세 소셜 로그인에서 사용할 수 있도록 함수로 따로 정의했다
def SocialSiginin(**kwargs):
    """소셜 로그인, 회원가입"""
    # 각각 소셜 로그인에서 유저 정보를 받아오고 None인 값들은 빼줌
    data = {k: v for k, v in kwargs.items() if v is not None}
    email = data.get("email")
    signin_type = data.get("signin_type")
    if not email:
        # email이 없으면 회원가입이 불가능하므로 프론트에 error메시지와 http status를 보냄
        return Response(
            {"error": "해당 계정에 email정보가 없습니다."}, status=status.HTTP_400_BAD_REQUEST
        )
    try:
        user = User.objects.get(email=email)
        if signin_type == user.signin_type:
            # 로그인 타입이 같으면, 토큰 발행해서 프론트로 보내주기
            refresh_token = RefreshToken.for_user(user)
            access_token = CustomTokenObtainPairSerializer.get_token(user)
            return Response(
                {
                    "refresh": str(refresh_token),
                    "access": str(access_token.access_token),
                },
                status=status.HTTP_200_OK,
            )
        else:
            # 유저의 다른 소셜계정으로 로그인한 유저라면, 해당 로그인 타입을 보내줌.
            # (프론트에서 "{signin_type}으로 로그인한 계정이 있습니다!" alert 띄워주기)
            return Response(
                {"error": f"{user.signin_type}로 이미 가입된 계정이 있습니다!"},
                status=status.HTTP_400_BAD_REQUEST,
            )
    except User.DoesNotExist:
        # 유저가 존재하지 않는다면 회원가입시키기
        new_user = User.objects.create(**data)
        new_user.set_unusable_password()  # pw는 사용불가로 지정
        new_user.save()
        # 회원가입 후 토큰 발급해서 프론트로 보냄
        refresh_token = RefreshToken.for_user(new_user)
        access_token = CustomTokenObtainPairSerializer.get_token(new_user)
        return Response(
            {"refresh": str(refresh_token), "access": str(access_token.access_token)},
            status=status.HTTP_200_OK,
        )
  • 소셜 로그인 시에 이미 소셜로 로그인 해서 비밀번호가 있으므로 데이터베이스에 비밀번호를 사용하지 않음을 명시한다
  • 이렇게 소셜 로그인 함수까지 거치고 나면 프론트엔드에 유저의 토큰이 보내진다

<프론트엔드>

- 로그인 버튼 만들기

<-- index.html !-->
<head>
    <script type="module" src="../static/js/navbar.js"></script>
    <script type="module" src="../static/js/signin.js"></script>
</head>
<body>
    <div class="social-login">
        <button type="button" id="googleLoginBtn" class="social-btn">
            <img class="social-btn-img" src="/static/img/google.png">
        </button>
    </div>
</body>
  • 적당한 곳에 버튼을 넣어준다
  • 구글 로고 이미지가 들어간 버튼이다
  • button 태그의 id값을 이용해서 해당 id의 button 태그가 클릭되면 함수가 실행되게 할 예정이다

- 버튼 눌렀을 때 실행될 함수 생성하기

// signin.js

import { googleAPI, frontendBaseURL } from "./api.js";

// 구글 로그인
async function googleSignin() {
    googleAPI().then((responseJson) => {
        const google_id = responseJson
        const scope = 'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile'
        const param = `scope=${scope}&include_granted_scopes=true&response_type=token&state=pass-through value&prompt=consent&client_id=${google_id}&redirect_uri=${frontendBaseURL}`
        window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${param}`
    })    
}
// 구글 로그인 function 실행
document.getElementById("googleSigninBtn").addEventListener("click", googleSignin);
  • googleAPI 함수는 백엔드에 get요청을 보내서 구글API키를 받고 json 형식으로 바꿔서 반환한다
  • 이동할 url에 API키, scope, param을 다 넣는다
  • 구글 로그인 버튼이 클릭되면 이 함수가 실행된다
  • 이 함수가 실행되면 메인으로 가면서 주소창에 access token이 생긴다

- 백엔드와 통신하는 API 함수 생성하기

// api.js

// 구글 로그인 API
export async function googleAPI(google_token) {
    if (google_token == undefined) {
        const response = await fetch(`${backendBaseURL}/users/google/`, { method: 'GET' })
        const responseJson = await response.json();
        return responseJson;
    } else {
	    const response = await fetch(`${backendBaseURL}/users/google/`, {
		    method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: JSON.stringify({ access_token: google_token })
	    });
        const responseJson = await response.json();
        return { response, responseJson };
    }
}
  • 내 구글 계정을 클릭했을 때 실행되는 함수다
  • 구글로그인 버튼 클릭 시 get요청을 보내서 구글 API 키를 반환받는다 그걸 json 형식으로 바꿔서 반환하면 siginin.js에서 로그인 수행할 url을 만들어서 이동시킨다=구글 로그인 창으로 이동한다
  • 구글로그인 창에서 계정을 클릭하고 나면 주소창에 access token이 생기고 그걸 매개변수로 받아서 백엔드에 post 요청을 보낸다
  • 백엔드에서는 이 토큰을 받아서 구글 사용자 정보에 접근해서 사용자 정보를 받아 로그인/회원가입을 진행시킨다

- 토큰을 받아서 로컬저장소에 저장하기

import { googleAPI, frontendBaseURL, payload } from "./api.js";

// 로컬 스토리지에 jwt 토큰 저장하기
function setLocalStorage(responseJson) {
	localStorage.setItem("access", responseJson.access);
    localStorage.setItem("refresh", responseJson.refresh);
    const base64Url = responseJson.access.split('.')[1];
    const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
    const jsonPayload = decodeURIComponent(atob(base64).split('').map(function (c) {
        return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
    }).join(''));
    // payload로 저장하기 
    localStorage.setItem("payload", jsonPayload);
}

// url에서 access_token 얻기
if (payload) {
} else if (location.href.split("=")[1]) {
    let hashParams = new URLSearchParams(window.location.hash.substr(1));
    let google_token = hashParams.get("access_token");
    // 백엔드 통신 함수 
    googleAPI(google_token).then(({ response, responseJson }) => {
        if (response.status == 200) {
            alert('로그인 성공');
            window.location.replace(`${frontendBaseURL}/`);
        } else {
            alert(responseJson.error);
        }
        setLocalStorage(responseJson)
    })
}
  • 17번째 줄부터 보자면 혹시 로그인이 되어 있으면 그냥 아무 일도 안 일어나고, 안 되어 있는 경우에 코드가 진행된다
  • 주소창에서 access token을 따와서 google_token에 넣어준다
  • 위에서 만든 백엔드에 post 요청을 하는 googleAPI에 파라미터로 구글토큰을 넣어준다
  • response와 json화된 response를 받아서 HTTP 상태코드에 따라 동작이 나뉜다
  • 상태코드가 200이면 로그인 성공이라는 알림창과 함께 새로고침된다
  • 이외의 코드가 나오면 백엔드에서 지정해준 error 메시지를 알림창으로 띄운다
  • 그리고 json화된 response를 로컬저장소에 토큰을 저장하는 함수의 파라미터로 넣어준다
  • 지금 보니 29번째 줄에 있는 코드가 24나 25번째 줄에 있어도 되겠네
  • 근데 if-else문 아래에 있든 if문 안에 있든 상관 없는 것 같다
  • 의미상 로그인이 성공해야 토큰이 로컬저장소에 저장되는 거니까 if문 아래에 있는 게 더 나아보이는 것 같으니 옮겨주겠다..!!
    googleAPI(google_token).then(({ response, responseJson }) => {
        if (response.status == 200) {
            setLocalStorage(responseJson)
            alert('로그인 성공');
            window.location.replace(`${frontendBaseURL}/`);
        } else {
            alert(responseJson.error);
        }
    })
  • 로컬저장소에 토큰 저장하는 함수 위위 코드의 24번째줄로 옮겨줌!
  • 그리고 로컬저장소에 토큰 저장하는 건 뭐 정해져 있으니까 고대로 해주면 끝..!

<시연>

로그인 버튼 클릭
구글 로그인 버튼 클릭
로그인 할 계정 클릭
로그인 성공 알림창 확인 클릭(주소창에 구글 사용자 정보에 접근할 엑세스 토큰이 들어와있다)
프론트엔드 메인 주소로 이동하고 로컬 저장소에는 백엔드에서 발급한 토큰이 저장된 걸 확인할 수 있음!