# [React x Django] Google登入登出功能 ## 前置安裝 後端 Django `pip install django-allauth` `pip install django-rest-auth` `pip install social-auth-app-django` 前端 Reactjs `npm i react-google-login` ## 技術 1. React.js SPA 2. Django Rest Framework(DRF) 3. Google 登入 ## 流程圖 ```plantuml @startuml Google<-Frontend:使用者按了登入Google按鈕後,送相關資訊到Google的伺服器 Google->Frontend:Google驗證通過後回傳 tokenID(JWT) Frontend->Backend:將tokenID後送到後端 Backend -> Backend:後端驗證此token正確後,登入使用者 Frontend <- Backend:回傳網站的 access_token Frontend <-> Backend:前端需用這個access_token才能跟後端API溝通, @enduml ``` ## 步驟 ### Google API Console 1. 前往 [Google Cloud Platform](https://console.cloud.google.com) 2. 找到 APIs & Service ![](https://i.imgur.com/wbsej2h.png) 3. 建立新的Project ![](https://i.imgur.com/0F5vFpi.png) 4. `APIs & Services` Oauth consent screen 設定 App information 中 `User support email` 跟 Developer contact information中的`Email addresses` 皆填入自己的google 帳號 `Scope`:可以依需求設定,我是將`userinfo.email`,`userinfo.profile`,`openid`皆設為non-sensitive scopes 其他保留預設 5. `Credentials`設定 進到Credentials頁面後點擊 `+CREATE CREDENTIALS` 選擇 `OAuth client ID` `Application type`選擇 `Web application` 進行命名與相關授權路徑設定 * 注意這邊會拿到一組`Client ID`,`Client secret`很重要,後面驗證需要這組資訊 `Authorized JavaScript origins`:填入 `http://localhost:8000`,`http://localhost:3000` 此項目為whitelist要讓網站可以使用Google登入 `Authorized redirect URIs`:填入 `https://127.0.0.1:8000/accounts/google/login/callback` 6. 完成驗證的設定後,回到自己的程式碼 7. Frontend要新增一個登入按鈕,要能按了以後跳出Goolge登入頁面,並且登入成功後回傳資料 ```javascript= import React from "react"; import GoogleLogin from "react-google-login"; ``` 在要使用Google登入的地方,加入這個物件 ```javascript= <GoogleLogin clientId="填入自剛剛申請的clientId" buttonText="使用 GOOGLE 登入" onSuccess={responseGoogle} onFailure={responseGoogle} cookiePolicy={'single_host_origin'} /> ``` 另外,`onSuccess`與`onFailure`的地方有呼叫函式,我們可以印出來看 ```javascript= const responseGoogle = (response) => { console.log(response); } ``` 結果: 畫面 ![](https://i.imgur.com/YsySSmh.png) 登入按鈕按下後 `F12`查看相關json傳遞 ![](https://i.imgur.com/nQKAhAa.png) 8. 取出`tokenId` jwt deocde 將上一步最後拿到的response中的資料拿取`tokenId`項目出來貼到 [JWT.io](https://jwt.io/) ```json= eyJhbGciOiJSUzI1NiIsImtpZCI6IjFiZjhhODRkM2VjZDc3ZTlmMmFkNWYwNmZmZDI2MDWhTa93K_IC05LJ55o_VHYTZ0-yDYaU4PrpJgM15g ``` Decode結果 ```json= { "iss": "accounts.google.com", "azp": "723-o0plq03jna3d56rg4l362ticv6e785fd.apps.googleusercontent.com", "aud": "704623-o0plq03jna3d56rg4l362ticv6e785fd.apps.googleusercontent.com", "sub": "11268", "email": "xuunnis123@gmail.com", "email_verified": true, "at_hash": "HJPg", "name": "Ezra Lin", "picture": "https://lh3.googleusercontent.com/a-/AOh14GgDe7Z_RNMaSshkBO4IT6d4Oxw_CWlWKqoSbC8ntZM=s96-c", "given_name": "Ezra", "family_name": "Lin", "locale": "en", "iat": 1625727318, "exp": 1625730918, "jti": "1e4191d04d7" } ``` 確定tokenId有我們需要的資料 9. 傳入後端處理驗證 此處邏輯撰寫為 ![](https://i.imgur.com/ojftyao.png) 後端項目Django中的`settings.py` ```python= INSTALLED_APPS = [ # ... 'accounts' 'rest_framework', 'corsheaders', ] SOCIAL_GOOGLE_CLIENT_ID = '<CLIENT_ID>.apps.googleusercontent.com' # Rest framework settings REST_FRAMEWORK = { #'DEFAULT_PERMISSION_CLASSES': [ # 'rest_framework.permissions.IsAuthenticated', #], 'DEFAULT_AUTHENTICATION_CLASSES': ('rest_framework_simplejwt.authentication.JWTAuthentication',) } # CORS header CORS_ORIGIN_WHITELIST = [ "http://localhost:3000", ] ``` ```DEFAULT_PERMISSION_CLASSES``` 項目可以視需要打開 `models.py`中設立新table,建立google id 跟User的連結 ```python= class SocialAccount(models.Model): provider = models.CharField(max_length=200, default='google') # 若未來新增其他的登入方式,如Facebook,GitHub... unique_id = models.CharField(max_length=200) user = models.ForeignKey( User, related_name='social', on_delete=models.CASCADE) ``` * 進行`python manage.py makemigrations` 與 `python manage.py migrate` 10. `serializers.py`設置 這裡為了方便我們將值梳理好 ```python= from rest_framework import serializers from django.contrib.auth.models import User from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.serializers import TokenObtainPairSerializer from google.oauth2 import id_token from google.auth.transport import requests from backend.settings import SOCIAL_GOOGLE_CLIENT_ID from .models import * class SocialLoginSerializer(serializers.Serializer): token = serializers.CharField(required=True) def verify_token(self, token): """ 驗證 id_token 是否正確 token: JWT """ try: idinfo = id_token.verify_oauth2_token( token, requests.Request(), SOCIAL_GOOGLE_CLIENT_ID) if idinfo['iss'] not in ['accounts.google.com', 'https://accounts.google.com']: raise ValueError('Wrong issuer.') if idinfo['aud'] not in [SOCIAL_GOOGLE_CLIENT_ID]: raise ValueError('Could not verify audience.') # Success return idinfo except ValueError: pass def create(self, validated_data): idinfo = self.verify_token(validated_data.get('token')) if idinfo: # User not exists if not SocialAccount.objects.filter(unique_id=idinfo['sub']).exists(): user = User.objects.create_user( username=f"{idinfo['name']} {idinfo['email']}", # Username has to be unique first_name=idinfo['given_name'], last_name=idinfo['family_name'], email=idinfo['email'] ) SocialAccount.objects.create( user=user, unique_id=idinfo['sub'] ) return user else: social = SocialAccount.objects.get(unique_id=idinfo['sub']) return social.user else: raise ValueError("Incorrect Credentials") class UserSerializer(serializers.ModelSerializer): class Meta: model = User fields = ['username', 'email', 'first_name', 'last_name'] ``` 11. 啟動後端`127.0.0.1:8000/admin` 會發現多了SOCIAL ACCOUNTS的位置 ![](https://i.imgur.com/Jpzxr60.png) 在Social applications中新增 `Google API`並設定相關變數 ![](https://i.imgur.com/5ldI0rI.png) 其中 `client Id`,`Secret key`記得放上原本在Google申請的那組 下面的sites的部分則填入 `localhost:8000`並儲存。 到這裡我們完成Social accounts登入會自動在Django後台的Authentication註冊登入的關聯動作 12. views中進行後端實作 ```python= from rest_framework.permissions import IsAuthenticated, IsAdminUser,AllowAny from rest_framework.response import Response from rest_framework_simplejwt.serializers import TokenObtainPairSerializer from rest_framework_simplejwt.views import TokenObtainPairView from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.views import TokenObtainPairView from ..serializers import MyTokenObtainPairSerializer, SocialLoginSerializer def get_tokens_for_user(user): refresh = RefreshToken.for_user(user) return { 'refresh': str(refresh), 'access': str(refresh.access_token), } class GoogleLogin(TokenObtainPairView): permission_classes = (AllowAny, ) # AllowAny for login serializer_class = SocialLoginSerializer def post(self, request): serializer = self.get_serializer(data=request.data) if serializer.is_valid(raise_exception=True): user = serializer.save() return Response(get_tokens_for_user(user)) else: raise ValueError('Not serializable') ``` 13. app/urls.py 設定走的路徑,可以呼叫到我們剛剛的views內容 ```python= from rest_framework_simplejwt import views as jwt_views #from rest_framework_simplejwt import views as jwt_views from ..views.user_views import GoogleLogin urlpatterns=[ path('token/obtain/', GoogleLogin.as_view()), path('token/refresh/', jwt_views.TokenRefreshView.as_view(), name='token_refresh'), ] ``` `project/urls.py` ```python= path('api/users/',include('base.urls.user_urls')), ``` 14. 前後端串連 延伸 步驟7 的實作,利用axios打到我們後端的api,這邊將token打過去後拿回來的`res` 中的相關資訊我們需要存在localStorage作為驗證 `access_token`,`refresh_token` 是我們的在網站內的令牌,所以需要帶著走 >需要`refresh_token`是因為如果`access_token`過期了就會自動拿 `refresh_token`去換新的`access_token` 這裡也將`givenName`變數拿出來,以利後面使用 ```javascript= const responseGoogle = (response) => { console.log(response); axios .post("http://localhost:8000/api/users/token/obtain/", { token: response.tokenId, }) .then((res) => { console.log("res.data=",res.data); // 拿到的 token 存在 localStorage localStorage.setItem("access_token", res.data.access); localStorage.setItem("refresh_token", res.data.refresh); localStorage.setItem("givenName",JSON.stringify(response.profileObj.name)); window.location.href="/"; }) .catch((err) => { console.log(err); }); } ``` 15. `store.js`設定載入初始值 ```javascript= const userInfoFromGoogle =localStorage.getItem('givenName') ? JSON.parse(localStorage.getItem('givenName')): null ``` 載入畫面時如果有giveName就代表剛剛已經登入也有把這個值存進去localStorage,所以可以取出來塞到`userInfoFromGoogle` ```javascript= const initialState = { . . . userLoginGoogle:{userGoogleInfo : userInfoFromGoogle} } ``` 這邊準備是為了在我們的頁面上使用 ```javascript= {userGoogleInfo ? ( <NavDropdown title={userGoogleInfo} id ='username' > <LinkContainer to='/profile'> <NavDropdown.Item>個人資料</NavDropdown.Item> </LinkContainer> <GoogleLogout ... </GoogleLogout> </NavDropdown> ):( <GoogleLogin... /> )} ``` 實現了登入後自動帶入名字的功能 ![](https://i.imgur.com/J8in4Dc.png) 16. 完成google登入 ### Google登出功能 1. 使用<GoogleLogout></GoogleLogout> ```javascript= <GoogleLogout clientId="一開始申請的google client" buttonText="登出" onLogoutSuccess={logoutfromGoogle} > </GoogleLogout> ``` 動作處理 `onLogoutSuccess`這邊需要執行 logoutfromGoogle ```javascript= import { logout,logoutByGoogle } from '../actions/userActions' const dispatch = useDispatch() const logoutfromGoogle = () => { dispatch(logoutByGoogle()) } ``` 因為登出這邊有將動作拆開到actions的檔案執行動作邏輯 所以我們要寫一下我們的userActions `actions/userActions` ```javascript= export const logoutByGoogle =()=>(dispatch)=>{ localStorage.removeItem("access_token") localStorage.removeItem("refresh_token") localStorage.removeItem("givenName") dispatch({type:USER_LOGOUT}) dispatch({type:USER_DETAILS_RESET}) window.location.href="/"; } ``` 將我們的令牌等資訊都清掉 再將路徑導回首頁,完成登出動作。 ### 未來優化 1. 將功能拆乾淨 2. 帳戶相關資訊再截入網站中 --- ### 問題集 1. ![](https://i.imgur.com/KyKFmXS.png) The OAuth client was not found. <GoogleLogin clientId="767817704623-o0plq03jna3d56rg4l362ticv6e785fd.apps.googleusercontent.com" buttonText="使用GOOGLE登入" onSuccess={googleResponse} onFailure={googleResponse} /> 1. 記得填入clientId 2. https://stackoverflow.com/questions/17166848/invalid-client-in-google-oauth2 錯誤代碼 400: redirect_uri_mismatch 記得給 ![](https://i.imgur.com/1b3UurO.png) https://blog.hanklu.tw/post/2020/spa-api-social-loign/