# [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/