首頁 » Django » 前後端分離開發實戰 » 登入與註冊 » Oauth 2.0 » Google » Python Django 深度教學:OAuth 2.0 手動串接Google登入認證
Python Django 深度教學:OAuth 2.0 手動串接Google登入認證

一、前言

在這個數位化快速發展的時代,網站和應用程式為了方便用戶快速登入與註冊,紛紛採用了第三方登入服務。其中,以OAuth 2.0為基礎的Google登入認證,準確、安全、便利的特性讓它成為市場上的首選。OAuth 2.0不只是流行的選擇,它的協議長度遠超舊版本,引入了更多的彈性與擴展性,架構起一個堅實的安全環境。

透過本文的深度教學,我們將一步步指導您如何在Django Web框架中,不借助任何外掛手動串接Google的OAuth 2.0登入認證。不僅僅為了功能實作,更重要的是讓學習者能深入理解整個認證流程及其安全機制。該教學適合對OAuth 2.0有一定基礎知識、具備Python開發經驗,同時期望能在Django專案中實現更進階自定義和優化工作的專業開發者。

二、基礎知識介紹

OAuth 2.0 簡介

OAuth 2.0是一種開放標準的授權協議,允許用戶提供一個安全的授權方式,不需將登入的使用者名稱和密碼直接暴露給第三方服務。它透過”授權令牌”來讓第三方應用訪問用戶在另一個網絡服務上的資源,而這一切都在用戶的授權下進行。在這個流程中,用戶絕對掌控其個人資訊,選擇是否授權第三方應用進行訪問。OAuth 2.0已成為當今網絡安全授權的事實標準。

OAuth 2.0之所以受到重視並被廣泛採用的主要原因在於它的安全性和彈性:

  • 安全性:使用OAuth 2.0可以簡化安全流程,無需把用戶的真實憑證暴露給第三方服務。該協議能透過令牌而非用戶憑證,在用戶和第三方服務之間進行安全溝通。
  • 用戶體驗:用戶可以使用現有的賬號信息快速登入不同的應用程式或網站,無需創建新的用戶名和密碼,降低了記憶負擔,提升了用戶體驗。
  • 彈性:OAuth 2.0提供了多種授權方式,允許在不同的應用情境中進行最佳的選擇與定制,滿足從網頁到桌面應用、從智慧手機到物聯網設備等多元化的需求。
  • 廣泛支持:作為業界認證的標準,許多大型互聯網公司和服務,如Google、Facebook和Twitter等,都支持OAuth 2.0,這意味著使用者可以利用這些服務的賬號來登入許多其他的網路服務。

因此,學習和理解OAuth 2.0的工作原理,對於開發具有現代化用戶認證系統的應用,具有至關重要的作用。進一步的,手動集成和定制OAuth 2.0不僅能強化安全性,同時也能提供更多控制權,使得應用更能符合特定的商業要求和用戶需求。

Google 帳號作為第三方登入的優勢

Google 帳號是全球眾多用戶的首選電子郵件服務之一,其相關服務如Gmail、Google Drive和YouTube等,滲透到日常生活的各個角落。因此,使用Google帳號作為第三方登入,可以迅速連接到廣大的用戶群體,降低新用戶的註冊門檻,提升用戶的轉化率。

作為科技巨頭之一,對於資安投入了大量的資源,以確保其用戶資料的安全。利用Google帳號進行登入時,Google將負責用戶認證的大部分安全工作,如安全令牌的生成和驗證,這意味著第三方服務可以依賴Google的安全機制,降低自身被攻擊的風險。

透過Google OAuth 2.0提供的服務,開發者可以要求並獲得用戶授予的特定範圍的訪問權限。這使得應用不必取得用戶過多的個人資訊,而只需根據實際業務需求來請求必要的數據。這種可控的資料訪問策略,既保護了用戶的隱私,又便於第三方服務高效管理用戶資訊。

總結來說,Google帳號作為第三方登入的解決方案,不僅連接了廣泛的用戶基礎,而且提供了高度的安全保障和隱私控制,為開發者和用戶雙方創造了共贏的局面。

Django Web 框架概述

Django是一款高層次的Python Web框架,讓開發複雜的資料驅動網站變得簡單快速。採用了MTV(模型-範本-視圖)架構,幫助開發者整理代碼並加強可重用性。Django易於擴展,擁有強大的資料庫模型,並內置許多用於處理用戶認證、內容管理、RSS、站點地圖等的模組,適合快速開發具有清晰結構和維護性的Web應用。

Django備受讚譽的主要理由包含以下幾點:

  • 快速開發:Django力求減少網路應用的開發時間,並促進開發流程的進行,有助於從概念快速轉化到具體的產品。
  • 重視安全:安全性是Django設計中的重要考量。它有助於開發者避免常見的安全錯誤,如跨站請求偽造、跨站腳本、SQL注入攻擊等,其內置的用戶認證系統支持密碼和權限的管理。
  • 可擴展性:Django的可擴展性讓它適合從小型項目到大型企業級應用。無論項目規模如何變化,Django的設計都能夠輕鬆應對擴充需求。
  • DRY原則:Django堅持”不要重複自己”(Don’t Repeat Yourself)的設計哲學,透過重用代碼和模組化,促進代碼的簡潔性和可維護性。
  • 大量資源:擁有一個活躍的社群,提供不斷增長的資源,如插件、程式庫和教程,方便開發者學習和實踐最佳開發實踐。

相對其他Python Web框架來說:

  • Flask:Flask是一個輕量級的Web框架,相比於Django,Flask提供了更多的靈活性和控制力。它不強制使用任何特定的工具或庫,因而適合需要定制大量組件的專案。然而,這也意味著開發者可能需要手動處理某些元素,這在Django中是自動處理的。
  • Pyramid:Pyramid位置介於Flask和Django之間,它既不如Flask那麼靈活,也不如Django那樣包羅萬象。它適合需要更多定制比Flask,但又不想使用全部Django特性的中小型應用。
  • FastAPI:FastAPI是一個現代、快速(高性能)的Web框架,用於建立APIs,使用Python 3.6+的類型提示特性。它特別適合建立RESTful APIs,並且內建數據驗證和序列化功能。

選擇哪個框架往往取決於專案需求、開發團隊的熟悉度以及社區支持情況。Django由於其豐富的功能集和健壯的架構,開發者可以聚焦於編寫應用程式的核心部分,而不必過分擔憂基礎架構的問題,通常被選擇來構建標準化程度高和安全性要求強的大型應用。

三、Why not allauth?

在Django開發社群中,使用像是allauth這類的認證外掛對於快速實現第三方登入功能來說,無疑是一個極為便利的選擇。這些外掛通常具有現成的解決方案,能夠快速且方便地集成到您的應用程式中。然而,儘管如此,直接使用Django實作OAuth 2.0集成的方法,而非依賴allauth這樣的外掛,具有其獨特的優勢和考量。

首先,在專案中引入額外的外掛常常意味著增加了依賴性和潛在的安全風險。每個外掛都可能引入未知的錯誤或安全漏洞,這些問題可能會影響您的應用程序的整體穩定性和安全性。而且,當外掛未及時更新以應對新出現的安全威脅時,這種風險會變得更加突出。

其次,依賴特定外掛可能會限制您自定義認證流程的能力。每個業務都有其獨特的需求,通過手動實現OAuth,您能擁有更大程度的自由和控制,能更精細地調整認證流程,以更好地適應業務需求。從長遠來看,這對於維護產品的獨特賣點和增加用戶體驗有著不可忽視的積極作用。

再者,直接實作OAuth 2.0集成過程同時也是一次寶貴的學習經驗,它使開發者必須更深入地瞭解OAuth 2.0協議和Django框架如何交互工作。這種深層次的了解將有助於開發者在未來遇到相關問題時,能夠更加迅速和有效地解決。

當然,這種方法也有其弊端。自主實現需要耗費更多的時間和資源,這在項目時間緊張或者預算有限的情況下,可能不是一個可行的選擇。此外,若開發團隊缺乏相關知識或經驗,也可能增加實施過程中的困難度和風險。

在決定是否使用allauth或其他外掛之前,開發團隊需要仔細評估自身的能力、需求以及專案的時間和資源。如果團隊有足夠的能力實施定製解決方案,且認為長遠來看這將為專案帶來更大的好處,那麼自主實作OAuth 2.0登入方式無疑會是一個值得考慮的選項。

四、準備工作

專案環境

  1. Windows 11 + VMWare + Ubuntu 20.04
  2. Python 3.8.10
  3. Django 4.1.2

環境建置可以參考這篇文章<Python Django開發環境設置 Win 11 + VMWare + Ubuntu 20.04>

設定Google API Console

  1. 申請GCP帳戶
  2. 啟用API
  3. 設定授權憑證

設定方式請參考<[教學]免費試用GCP&OAuth 2.0用戶端憑證申請>

配置Django專案

請先跟著<深入探究前後端分離:用Python Django建構現代化登入註冊系統 (alfred.wiki)>建立Django專案及基礎的登入註冊系統,以下會直接以文章的基礎進行後續修改。

五、Google OAuth 2.0串接

基本步驟

1. 從Google API Console取得OAuth 2.0憑證

如果你完成了前一節的準備工作,那你應該會獲得一個json檔案,且裡面有著你的憑證。

2. 從Google授權伺服器取得Access Token

在準備存取私密資料之前,我們需要使用先前取得的憑證,向Google請求一個存取令牌(Access Token)。在這個請求過程中,我們會指定所需的授權範圍(Scope),這表示允許存取的範圍,可涵蓋單一或眾多範疇。Google利用網址參數來確定各作業的Scope,各種可供選擇的Scope請參見Google的相關文件

Google OAuth 2.0 Scope表現方式是一個網址

此外,在請求過程中,用戶需要登入其Google帳戶來進行身份驗證並授予所需權限,這就是我們熟知的Google帳戶登入彈窗。在彈出視窗中,Google會顯示該次請求所涉及的授權範圍。然而,許多用戶包括我自己在內,往往忽略了仔細閱讀這些資訊。

一旦用戶授權成功,Google便會生成Access Token,並Callback給你。接著,我們需要妥善保存這個Token,因為它將用於後續發起資源請求時的認證。

一般而言,最好在實際需要特定權限時逐步請求,而非一開始便索取全部的權限。舉例來說,只有當用戶打算將事件新增至日曆,應用程式才會請求Google日曆的訪問權限,這種方式被稱作「增量授權」。我們這裡不會深入探討這個概念,但對此感興趣的讀者可以查閱Google提供的增量授權文件以獲得更多詳情。

3. 檢視使用者授予的訪問範圍權限

即使使用者同意了所有提出的Scope,回傳的存取範圍可能與請請求中的範圍有所不同。因此,必須查詢每個Google API的文檔,以驗證所需的精準存取範圍。特別是當一個API將多個請求的範圍字串映射到一個統一的存取權範圍時,所有被允許的請求將會獲得相同的範圍字串回傳。例如:即使應用程式要求授權的Scope為https://www.google.com/m8/feeds/,Google People API可能僅回傳https://www.googleapis.com/auth/contacts作為Scope;而且,若要使用Google人員API的people.updateContact方法來更新聯絡人資料,則必須擁有https://www.googleapis.com/auth/contacts這個範圍的授權。

換句話說,當你成功請求Access Token後,Google在Callback資料時會附上最終授權的Scope。Google建議開發者應當仔細比對發出和接收的Scope是否吻合。如有出入,則需參閱相應Scope文件進一步了解。

4. 發起API請求時帶上Access Token

獲得Access Token後,若要對API發起請求,需要在Header中加入Access Token的資訊。雖然也可以將Token透過URI查詢字串參數的形式發送,但不建議這麼做,因為會造成Access Token洩露。此外,遵循良好的REST實踐來避免不必要的URI參數名稱也是很重要的。

Access Token僅對Scope中描述的資源有效。例如,如果為Google日曆API發行了一個存取令牌,它不會授予訪問Google聯絡人API的權限。不過,可以將該存取令牌多次發送給Google日曆API,用於執行相仿的操作。

5. 必要時更新Access Token

如果需要在Token的生命周期之外訪問Google API,需要更新Token。

Web處理流程

Google OAuth 2.0端點支持使用PHP、Java、Python、Ruby及ASP.NET等語言和框架的網頁伺服器應用程式。

1. 當使用者點擊了註冊或登入,Web會重新導向到Google登入頁面。此時有一個重點,為了避免CSRF攻擊,通常會產生一個隨機的State紀錄於伺服器,後續資料Callback回來時,也會帶上這個State,我們比對State,確保不會收到駭客造假的資料。

2. 使用者在Google的頁面處理用戶身份驗證,並針對網站所要求的Scope進行授權。

3. 處理完成後會將結果Callback給我們,此時網頁會從Google的頁面重新導向回我們的Web,重新導向的URI也就是我們在文章<[教學]免費試用GCP&OAuth 2.0用戶端憑證申請 | Alfred’s Python Wiki>中設定的URI,相關的Token會透過查詢參數帶給我們。同時也會有前面提到的State,避免CSRF攻擊。

4. 透過Token,我們就可以向Google請求使用者的資訊,像是使用者名稱、Email或頭像等。有了這些這些資訊就可以跟我們的Web登入系統串接了。

這張圖是Google說明文件上的圖,根據我們拆解的結果總共分成4個流程,除了流程2是在Google的頁面上進行外,其餘是在我們的網頁上進行處理。後續實作時可以回來比較這些流程,了解自己身處哪一個階段。

實作

專案程式碼

hsunAlfred/AlfredWiki at oauth (github.com)

Python套件安裝

首先開啟VMWare並啟動Ubuntu,接著開啟Terminal,並使用以下指令啟動虛擬環境,

source ~/VENV/bin/activate

接著安裝Google的Python用戶端及requests模組,

pip3 install --upgrade google-api-python-client google-auth google-auth-oauthlib google-auth-httplib2 requests

接下來透過VSCode開啟專案資料夾,準備來寫Code啦,後續會在Terminal跟VSCode間切換。

URL Endpoint定義

在專案資料夾下找到Member資料夾,並找到urls.py,我們從URL Endpoint的定義開始依序修改專案,修改結果如下,包含了:

  1. /member/login//member/signup/:登入註冊頁面。
  2. /member/google/:紀錄State並導向Google。
  3. /member/google/callback/:在Google完成後,Google會將使用者重新導向回我們的網頁。
  4. /member/logout/:登出。
  5. 首頁

其中只有2、3是新增的,但除了新增的部分,原有程式碼也會進行些微調整,後續會針對修改的部分進行說明。

from django.urls import path
from Member.views import login, signup, google, google_callback, logout, index

urlpatterns = [
    path('member/login/', login),  # login page
    path('member/signup/', signup),  # signup page
    path('member/google/', google),  # user choose login/signup with google
    path('member/google/callback/', google_callback),  # google callback
    path('member/logout/', logout),  # logout
    path('', index),
]

這裡定義Callback URI為/member/google/callback/,我們開啟GCP,進入API和服務找到憑證,修改之前設定的OAuth 2.0用戶端 ID,將已授權的重新導向URI設定為http://127.0.0.1:8000/member/google/callback/,並新的JSON檔案放到跟專案同名的資料夾底下(跟settings.py在相同資料夾)。因為現在是在本地端測試,所以HOST會是http://127.0.0.1:8000,正式上線時就會是我們的網域,之後也會有相關文章介紹,目前預計會打照一個有完整功能的網站,但那個之後可能還要很久。

在Google OAuth 2.0中的Callback其實就是當使用者登入完後,再重新導向回我們的網站的行為,因此需要我們設定的值才會叫做重新導向URI。

設定Google OAuth 2.0用戶端 ID的已授權的重新導向URI

models.py

為了區分使用者註冊的來源,因此我們必須有一張Table來記錄不同的User分別是從何種來源進行註冊,以目前的例子來說,分為直接註冊跟透過Google註冊,請修改Member/models.py如下:

from django.db import models
from django.contrib.auth.models import User

# Create your models here.


class UserSignupPlatform(models.Model):
    User = models.OneToOneField(
        User,
        on_delete=models.CASCADE
    )

    Platform_Choice = [
        ("Self", "Self"),
        ("Google", "Google"),
    ]
    Platform = models.CharField(
        "Platform", max_length=10, choices=Platform_Choice)
    
    GoogleUserID = models.CharField("GoogleUserID", max_length=50, null=True, blank=True)

    class Meta:
        ordering = ['User__username', 'Platform']

    def __str__(self):
        return f"{self.User.username} | {self.Platform}"

views.py

在專案資料夾下找到Member資料夾,並找到views.py,以下是本次需要使用的模組:

from django.shortcuts import render
from django.contrib import auth
from django.http import HttpResponseRedirect, JsonResponse, HttpResponse
from Member.utils.oauth2.google import startValid, callbackHandler, testSession, revokeAccess
from Member.utils.secure.secureTools import sessionKeyGenerate
from Member.utils.loginSignup.login import loginCheck
from Member.utils.loginSignup.signup import signupCheck

from AlfredWiki.settings import DEBUG
import json
from django.contrib.auth.decorators import login_required

接下來會跟著URL Endpoint的順序修改程式碼:

login

這個function在透過loginCheck物件呼叫process方法時,傳遞loginby="Self",記錄此次是透過Web本身的系統進行登入。

def login(request):
    # login page
    if request.method not in ['GET', 'POST']:
        return JsonResponse({'ok': False, 'message': 'Method Not Allowed.'}, status=405)

    if request.user.is_authenticated:
        return HttpResponseRedirect("/")

    if request.method == 'GET':
        session_public_key, session_private_key = sessionKeyGenerate()
        request.session['login_public_key'] = session_public_key
        request.session['login_private_key'] = session_private_key
        context = {
            "spk_client": session_public_key,
        }
        return render(request, 'member/login.html', context=context)

    # call api to login
    body = json.loads(request.body)

    lc = loginCheck()

    lc.decryptBody(body, request.session['login_private_key'])
    if not lc.lsr.ok:
        return JsonResponse({'ok': lc.lsr.ok, 'message': lc.lsr.message}, status=lc.lsr.code)

    lc.process(loginby="Self")

    if lc.lsr.ok:
        auth.login(request, lc.lsr.user_obj)

    return JsonResponse({'ok': lc.lsr.ok, 'message': lc.lsr.message}, status=lc.lsr.code)

先前loginCheck物件呼叫process方法並無相關功能,所以接下來要修改Member/utils/loginSignup/login.py,這邊可以直接覆寫整個檔案,但我們會先注重於process這個方法。

相較之前,這裡會先對來源平台進行比對,從UserSignupPlatform找出該使用者註冊的來源(以前沒有記錄到,所以沒有記錄到使用者都是在平台本身直接註冊),若登入使用者是直接註冊,但現在卻用相同Email透過Google串接登入,此時我們會記錄錯誤;當登入跟註冊時的來源相同時,才進行登入程序,這個部分就是原來的程式碼了。本次的對process這個方法在於登入與註冊的來源比對。

from django.contrib import auth
from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist
import sys
from Member.utils.secure.secureTools import hmacsha, sessionDecrypted
from Member.utils.loginSignup.fieldVaild import emailVaild, ValidException
from Member.utils.loginSignup.loginSignup import loginSignupBase
from Member.models import UserSignupPlatform


class loginCheck(loginSignupBase):
    def decryptBody(self, body, login_private_key):
        try:
            userEmail_encrypt = body['userEmail']
            userEmail_decrypt = sessionDecrypted(
                userEmail_encrypt, login_private_key)

            pass_encrypt = body['pass']
            pass_decrypt = sessionDecrypted(pass_encrypt, login_private_key)

            if not userEmail_decrypt['status'] or not pass_decrypt['status']:
                raise

            self.userEmail_decrypt = userEmail_decrypt['info']
            self.isMail = False
            if emailVaild(self.userEmail_decrypt):
                self.isMail = True

            self.pass_decrypt = pass_decrypt['info']
        except ValidException as e:
            self.lsr.setFail(str(e), 400)
        except Exception as e:
            print(e)
            self.lsr.setFail('Invalid Parameter.', 400)
    
    def oauthSet(self, email, userid):
        self.userEmail_decrypt = email
        self.isMail = True
        self.pass_decrypt = hmacsha(email, userid)

    def process(self, loginby):
        try:
            if self.isMail:
                user_tmp = User.objects.get(
                    email=self.userEmail_decrypt
                )
            else:
                user_tmp = User.objects.get(
                    username=self.userEmail_decrypt
                )
            
            platform = "Google"
            try:
                usp = UserSignupPlatform.objects.get(User=user_tmp)
                platform = usp.Platform
            except ObjectDoesNotExist:
                platform = "Self"
            
            if loginby != platform:
                self.lsr.setFail("This account is registered by other method.", 401)
            else:
                username_tmp = user_tmp.username
                
                pass_hash = hmacsha(username_tmp, self.pass_decrypt)

                try:
                    user_obj = auth.authenticate(
                        username=username_tmp, password=pass_hash)

                    if user_obj is not None:
                        if not user_obj.is_active:
                            self.lsr.setFail("User not active.", 401)
                        else:
                            self.lsr.user_obj = user_obj
                    else:
                        self.lsr.setFail("Incorrect login info.", 401)
                except Exception as e:
                    exception_type, exception, exc_tb = sys.exc_info()
                    print(exception_type, exception, exc_tb)

                    self.lsr.setFail(str(e), 401)
        except Exception as e:
            print(e)
            self.lsr.setFail("User not exist.", 401)
signup

現在回到Member/views.pysignup,與login相同,呼叫process方法時,傳遞loginby="Self",內容調整如下:

def signup(request):
    # signup page
    if request.method not in ['GET', 'POST']:
        return JsonResponse({'ok': False, 'message': 'Method Not Allowed.'}, status=405)

    if request.user.is_authenticated:
        return HttpResponseRedirect("/")

    if request.method == 'GET':
        session_public_key, session_private_key = sessionKeyGenerate()
        request.session['signup_public_key'] = session_public_key
        request.session['signup_private_key'] = session_private_key
        context = {
            "spk_client": session_public_key,
        }
        return render(request, 'member/signup.html', context=context)

    body = json.loads(request.body)

    sc = signupCheck()
    sc.decryptBody(body, request.session['signup_private_key'])

    if not sc.lsr.ok:
        return JsonResponse({'ok': sc.lsr.ok, 'message': sc.lsr.message}, status=sc.lsr.code)

    sc.process(signupby="Self")

    return JsonResponse({'ok': sc.lsr.ok, 'message': sc.lsr.message}, status=sc.lsr.code)

同樣的我們要修改Member/utils/loginSignup/signup.py,一樣直接覆寫整個檔案,同時注重於process這個方法,此處則是在註冊成功時增加了紀錄註冊來源的程式碼。

from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist
from Member.utils.secure.secureTools import hmacsha, sessionDecrypted
from Member.utils.loginSignup.fieldVaild import usernameVaild, emailVaild, passwordVaild, ValidException
from Member.utils.loginSignup.loginSignup import loginSignupBase
from Member.models import UserSignupPlatform


class signupCheck(loginSignupBase):
    def decryptBody(self, body, signup_private_key):
        try:
            username_encrypt = body['username']
            username_decrypt = sessionDecrypted(
                username_encrypt, signup_private_key)

            email_encrypt = body['email']
            email_decrypt = sessionDecrypted(email_encrypt, signup_private_key)

            pass_encrypt = body['pass']
            pass_decrypt = sessionDecrypted(pass_encrypt, signup_private_key)

            if not username_decrypt['status'] or not email_decrypt['status'] or not pass_decrypt['status']:
                raise

            self.username_decrypt = username_decrypt['info']
            if not usernameVaild(self.username_decrypt):
                raise ValidException("Invalid username.")

            self.email_decrypt = email_decrypt['info']
            if not emailVaild(self.email_decrypt):
                raise ValidException("Invalid email.")

            self.pass_decrypt = pass_decrypt['info']
            if not passwordVaild(self.pass_decrypt):
                raise ValidException("Invalid password.")

            self.pass_hash = hmacsha(self.username_decrypt, self.pass_decrypt)
        except ValidException as e:
            self.lsr.setFail(str(e), 400)
        except Exception as e:
            self.lsr.setFail('Invalid Parameter.', 400)
            
    def oauthSet(self, username, email, userid):
        self.username_decrypt = username
        self.email_decrypt = email
        
        self.pass_decrypt = hmacsha(email, userid)
        self.pass_hash = hmacsha(self.username_decrypt, self.pass_decrypt)
        self.userid=userid

    def process(self, signupby):
        try:
            User.objects.get(username=self.username_decrypt)

            if signupby == "Google":
                User.objects.get(username=self.username_decrypt+"_Google")
            else:
                self.lsr.setFail('Username has been registered.', 400)
        except ObjectDoesNotExist as e:
            print('Username OK')

            try:
                User.objects.get(email=self.email_decrypt)

                self.lsr.setFail('Email has been registered.', 400)
            except ObjectDoesNotExist as e:
                print('Email OK')
            except Exception as e:
                print('Email', e)

                self.lsr.setFail('Invalid Parameter.', 400)
        except Exception as e:
            print('Username', e)

            self.lsr.setFail('Invalid Parameter.', 400)

        if not self.lsr.ok:
            return self.lsr

        try:
            user = User.objects.create_user(
                username=self.username_decrypt, email=self.email_decrypt, password=self.pass_hash)
            user.save()

            UserSignupPlatform.objects.create(User=user, Platform=signupby, GoogleUserID=self.userid if signupby=="Google" else None)
            
            self.lsr.user_obj = user
        except Exception as e:
            print(e)
            self.lsr.setFail('Sign up fail.', 400)
google

當使用者選擇Continue with Google後,將由Member/views.py中加入google這個函數進行處理,其中透過Requests Header中的referrer,判斷本次是要註冊還是登入,並計入在SessionGoogleOauth2By的這個key底下,如果都不是就會拒絕本次請求。接著就會startValid()產生前往Google進行登入的URL,獲取成功後會重新導向到該URL並將state紀錄在Session中。

def google(request):
    # user choose login/signup with google
    referrer = request.headers.get('Referer')

    if not referrer:
        return JsonResponse({"message": "Forbidden"}, status=403)

    if "/member/login/" in referrer:
        request.session['GoogleOauth2By'] = 'Login'
    elif "/member/signup/" in referrer:
        request.session['GoogleOauth2By'] = 'Signup'
    else:
        return JsonResponse({"message": "Forbidden"}, status=403)

    obj = startValid()
    authorization_url = obj['authorization_url']
    request.session['state'] = obj['state']

    return HttpResponseRedirect(authorization_url)

接著請在Member底下的utils資料夾中建立oauth2資料夾,並在底下建立google.py,未來如果有透過oauth2這個標準串接其他平台帳號進行登入,就會將相關程式碼全部放在這邊。google.py的程式碼如下,這裡我們先留意startValid(),這裡透過Google提供的模組,取得authorization_url,而state的部分則是我們自行生成sha256hash值,並將兩者組裝成字典檔案回傳。

import google.oauth2.credentials
import google_auth_oauthlib.flow
import googleapiclient.discovery
import hashlib
import os
import json
import requests

from AlfredWiki.settings import GOOGLE_SERECT, GOOGLE_SCOPES, GOOGLE_REDIRECT_URI, API_SERVICE_NAME, API_VERSION


def startValid():
    # https://developers.google.com/identity/protocols/oauth2/scopes
    flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file(
        GOOGLE_SERECT,
        scopes=GOOGLE_SCOPES
    )

    # same as set in gcp
    flow.redirect_uri = GOOGLE_REDIRECT_URI

    # Generate URL for request to Google's OAuth 2.0 server.
    # Use kwargs to set optional request parameters.
    state = hashlib.sha256(os.urandom(1024)).hexdigest()

    authorization_url, state = flow.authorization_url(
        # Enable offline access so that you can refresh an access token without
        # re-prompting the user for permission. Recommended for web server apps.
        access_type='offline',
        # Enable incremental authorization. Recommended as a best practice.
        include_granted_scopes='true',
        state=state
    )

    infos = {
        "authorization_url": authorization_url,
        "state": state
    }

    return infos


def credentials_to_dict_str(credentials):
    return json.dumps({
        'token': credentials.token,
        'refresh_token': credentials.refresh_token,
        'token_uri': credentials.token_uri,
        'client_id': credentials.client_id,
        'client_secret': credentials.client_secret,
        'scopes': credentials.scopes
    })


def callbackHandler(state, authorization_response):
    print(state)
    print(authorization_response)
    flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file(
        GOOGLE_SERECT,
        scopes=GOOGLE_SCOPES,
        state=state
    )
    flow.redirect_uri = GOOGLE_REDIRECT_URI

    flow.fetch_token(authorization_response=authorization_response)

    credentials = flow.credentials

    return credentials_to_dict_str(credentials)


def testSession(credentials):
    # Load credentials from the session.
    credentials = google.oauth2.credentials.Credentials(
        **json.loads(credentials))

    datas = googleapiclient.discovery.build(
        API_SERVICE_NAME, API_VERSION, credentials=credentials
    )

    userInfos = datas.userinfo().get().execute()

    # Save credentials back to session in case access token was refreshed.
    # ACTION ITEM: In a production app, you likely want to save these
    #              credentials in a persistent database instead.
    credentials = credentials_to_dict_str(credentials)

    infos = {
        "credentials": credentials,
        "userInfos": userInfos
    }

    return infos


def revokeAccess(credentials):
    credentials = google.oauth2.credentials.Credentials(
        **json.loads(credentials))

    revoke = requests.post(
        'https://oauth2.googleapis.com/revoke',
        params={
            'token': credentials.token
        },
        headers={
            'content-type': 'application/x-www-form-urlencoded'
        }
    )

    status_code = getattr(revoke, 'status_code')
    if status_code == 200:
        return 'Credentials successfully revoked.<a href="/google/clear/">clear</a>'
    else:
        return 'An error occurred.'
google_callback

Member/views.py底下google_callback的程式碼如下所示,當使用者在Google成功登入後會將使用者重新導向回來我們的網站,而這個函數就是專門在處理這個請求。

def google_callback(request):
    # google callback
    state = request.session['state']

    authorization_response = (
        "http://" if DEBUG else "https://") + request.get_host()+request.get_full_path()

    credentials = callbackHandler(
        state, authorization_response)

    obj = testSession(credentials)

    print(obj['userInfos'])

    request.session['credentials'] = obj['credentials']

    try:
        GoogleOauth2By = request.session['GoogleOauth2By']
        if not GoogleOauth2By:
            raise
    except:
        return HttpResponseRedirect('/member/logout/')

    if GoogleOauth2By == 'Login':
        lc = loginCheck()

        lc.oauthSet(obj['userInfos']['email'], obj['userInfos']['id'])

        lc.process(loginby="Google")

        if lc.lsr.ok:
            auth.login(request, lc.lsr.user_obj)
            return HttpResponseRedirect('/')

        return render(request, "member/callback.html", {"GoogleOauth2By": GoogleOauth2By, "fail_reason": lc.lsr.message, "redirect": "/member/login/"})

    if GoogleOauth2By == 'Signup':
        sc = signupCheck()

        sc.oauthSet(obj['userInfos']['name'], obj['userInfos']
                    ['email'], obj['userInfos']['id'])

        sc.process(signupby="Google")

        if sc.lsr.ok:
            auth.login(request, sc.lsr.user_obj)
            return HttpResponseRedirect('/')

        return render(request, "member/callback.html", {"GoogleOauth2By": GoogleOauth2By, "fail_reason": sc.lsr.message, "redirect": "/member/signup/"})

    return HttpResponseRedirect('/member/logout/')

首先會透過callbackHandler解析Google傳遞給我們的資訊,而testSession可以從Google獲得使用者的相關資訊,兩者程式碼在介紹startValid時已經提供,同樣是透過呼叫Google提供的Python模組處理請求。

Session中的GoogleOauth2By這個Key中判斷本次登入還是註冊,如果是登入就會呼叫loginCheck,註冊就是signupCheck,大家可以跟直接登入或是註冊的程式碼比較,其實是呼叫相同的物件,只是在呼叫process方法前,不需要decryptBody,而是透過oauthSet設定物件屬性。

相關程式碼如下,如果你有跟著我的步驟,登入(Member/utils/loginSignup/login.py)註冊(Member/utils/loginSignup/signup.py)oauthSet應該都完成修改了。

從以下程式碼可以看出兩者只有單純的設定屬性,只是如果要登入Django的會員系統還是要有一組虛擬的密碼,這邊是透過email及userid計算hash值,假裝是使用者輸入的原始密碼。

從這邊應該就可以看出模組化設計程式碼的好處,只要對接口稍作修改,就可以輕易地進行各種串接,同時由於模組化的程式碼設計,也方便後續的修改。

# 登入
def oauthSet(self, email, userid):
    self.userEmail_decrypt = email
    self.isMail = True
    self.pass_decrypt = hmacsha(email, userid)

# 註冊
def oauthSet(self, username, email, userid):
    self.username_decrypt = username
    self.email_decrypt = email
        
    self.pass_decrypt = hmacsha(email, userid)
    self.pass_hash = hmacsha(self.username_decrypt, self.pass_decrypt)
    self.userid=userid

在Google那邊登入成功後會重新導向回我們的網站,但也可能出現登入註冊方式不一,或是已經註冊的狀況發生。這邊我們簡單建立一個頁面,讓使用者可以識別發生什麼事。

這裡簡單的使用了模板語法,直接將當前是登入還是註冊、失敗原因及重新導向網址,從後端導向前端。這就是傳統的後端的做法,從這個例子來說非常方便,我自己會在這種簡單的需求時使用這種做法,避免不必要的時間浪費。

請在以下路徑templates/member/建立callback.html,並貼上以下程式碼。

{% extends 'member/base/base.html' %}

{% block content %}
<nav class="navbar navbar-expand-lg">
    <div class="container-fluid">
        <a class="navbar-brand" href=".">
            <img src="/static/logo.png">
            <span>Alfred's Python Wiki</span>
        </a>

        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
            aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarSupportedContent">

        </div>
    </div>
</nav>

<div class="container">
    <h1 class="container-ele">
        {{GoogleOauth2By}} Fail
    </h1>

    <span>{{fail_reason}}</span>

    <a class="container-ele btn btn-outline-light third-login-method" href="/member/logout/?redirect={{redirect}}">
        <span id="again5">Back to {{GoogleOauth2By}}. Auto redirect in 5 seconds.</span>
    </a>
</div>
{% endblock %}

{% block custom_body %}
<script>
    var sec = 5;

    let sec5=function(){
        sec -= 1
        console.log(sec)
        document.getElementById("again5").innerText = "Back to {{GoogleOauth2By}}. Auto redirect in " + sec + " seconds."
        if (sec==0){
            location.href = "/member/logout/"
        }else{
            setTimeout(sec5, 1000);
        }
    }

    document.addEventListener("DOMContentLoaded", ()=>{
        sec5()
    })
</script>
{% endblock %}
logout

Member/views.py中的logout也多了許多程式碼,雖然Django的logout會直接清除當前Session的資料,但我們為求安全,還是先取消使用者的Google連結。

def logout(request):
    # logout
    if 'credentials' in request.session:
        credentials = request.session['credentials']

        result = revokeAccess(credentials)

    try:
        del request.session['credentials']
    except:
        pass

    auth.logout(request)

    redirect = request.GET.get("redirect")

    if redirect == "/member/login/":
        return HttpResponseRedirect('/member/login/')
    elif redirect == "/member/signup/":
        return HttpResponseRedirect('/member/signup/')

    return HttpResponseRedirect('/member/login/')

index

這邊就沒有修改了,因為這只是一個測試首頁。

@login_required(login_url='/member/login/')
def index(request):
    return HttpResponse("<style>body{background:black} a{color:white}</style><a href='/member/logout/'>logout</a>")

專案設定

接著開啟跟專案同名資料夾下的settings.py,並在檔案的最後貼上以下內容:

GOOGLE_SERECT = os.path.join(BASE_DIR, 'gptAlfredWiki/clientSerect.json')
GOOGLE_SCOPES = [
    'https://www.googleapis.com/auth/userinfo.email',
    'https://www.googleapis.com/auth/userinfo.profile',
    'openid'
]
GOOGLE_REDIRECT_URI = 'http://127.0.0.1:8000/member/google/callback/'
API_SERVICE_NAME = 'oauth2'
API_VERSION = 'v2'

os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' if DEBUG else '0'

並檢查專案同名資料夾下有沒有client_secret.json,如果沒有請參考<[教學]免費試用GCP&OAuth 2.0用戶端憑證申請 | Alfred’s Python Wiki>

請注意,這個檔案絕對不能外流,如果不確定有沒有外流,可以直接刪除原本的憑證,並根據教學重新設定。

另外,本篇文章的已授權的重新導向URIhttp://127.0.0.1:8000/member/google/callback/

完成以上步驟後請開啟terminal,並依序執行以下指令,讓我們的資料庫修改生效。

cd AlfredWiki
source ~/VENV/bin/activate
python manage.py makemigrations
python manage.py migrate

最後啟動測試伺服器。

python manage.py runserver

六、小結

在本文章中,我們進行了一次深度探討與教學,完整地手動實作了將Python Django Web框架與Google的OAuth 2.0登入認證進行串接。從OAuth 2.0的基本概念,到透過Google API Console進行必要設定,再到具體修改Django專案中的程式碼來實現這一機能——每一部分都被仔細地分解且解釋清楚。通過這個過程,我們不僅學習了如何實現安全的用戶登入系統,還對OAuth 2.0的工作原理與實踐有了更深層次的了解。

這次的學習不就僅僅是關於OAuth 2.0或是Django的深入知識,它更打開了一扇門,讓我們對開發現代化的網路應用具有更全面的視野。我們被鼓勵去進行更多的自主實作,這不僅可以增強我們的技術能力,同時也為我們日後可能會遇到的各種開發挑戰奠定了堅實的基礎。在資訊科技日新月異的今天,能夠把握這類核心技術,無疑對於任何一位開發者的職業生涯都是一大助益。

隨著網站和應用程序日益增加對於用戶身份驗證和資料保護的需求,掌握OAuth 2.0與Django等工具,可以確保我們的產品不僅能提供優質的用戶體驗、而且保持嚴格的安全標準。透過本教學,希望您能夠用這些知識打造出更優秀、更安全的Web應用。

One Reply to “Python Django 深度教學:OAuth 2.0 手動串接Google登入認證”

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *