2024 iThome 鐵人賽2024 iThome 鐵人賽

這是 Django Ninja 系列教學的第 28 篇。

歡迎來到第七章!本章總共有兩篇內容:

  • 卷 28:身分認證——Session 認證與全域設定
  • 卷 29:單元測試——使用 Test Client 與 pytest 測試 API

這些主題的核心功能,並非由 Django Ninja 實作,但框架仍提供了一定程度的整合。並且,這些功能對於任何 Django 專案來說,都至關重要。

本文介紹幾乎所有 API 專案都需要的——身分認證Authentication)。

我們將探討如何在 Django Ninja 中利用 Django 內建的 session-based 認證,實現完整的登入驗證功能,並進一步說明如何設定全域認證,以減少程式碼的重複。

本文所有的程式碼改動,可參考這個 PR


認證的兩個層次

進入實作前,我們要先了解,所謂的身分認證,究竟代表什麼。

以「帳號密碼 + session 認證」為例,身分認證的範圍主要涵蓋兩個階段

首先,當使用者透過帳號密碼進行登入時,系統會檢查這些內容、確認身分合法。登入成功後,系統會將使用者資訊(比如用戶 id)儲存至 session,以維持登入狀態

這是登入時的認證,也是我們最常說的認證。(狹義的認證

接著,當使用者嘗試存取受「認證保護」的 API 時,系統會檢查 session 並確認身分,確保每個 API 請求都來自合法登入的使用者。

簡言之:

  • 第一階段:初次登入時的身分確認。
  • 第二階段:後續請求時的身分確認。

兩個層次相輔相成、一體兩面,確保服務能夠在使用者登入後續操作中,提供適當的安全保障。


實作「使用者登入」API

了解了上述兩個層次後,我們要先來實作「狹義」的認證——也就是登入驗證本身。

我們將建立一個「使用者登入」API,並直接透過 Django 的authenticatelogin函式處理帳號密碼驗證登入狀態——非常方便!

authenticate用來驗證使用者輸入的帳號(username)和密碼是否正確,login則將使用者的登入狀態儲存至 session。

程式碼實作

先新增一個登入請求 Schema:

1
2
3
4
# user/schemas.py
class LoginRequest(Schema):
username: str = Field(examples=['Alice'])
password: str = Field(examples=['password123'])

然後是 view 函式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from django.contrib.auth import authenticate, login

from user.schemas import CreateUserRequest, LoginRequest
...

@router.post('/users/login/', summary='登入使用者')
def login_user(
request: HttpRequest, payload: LoginRequest
) -> dict[str, str]:
"""
登入使用者
"""
user = authenticate(
request,
username=payload.username,
password=payload.password
)
if user is not None:
login(request, user) # 將使用者登入狀態儲存至 session
return {'message': '登入成功'}
else:
raise HttpError(401, '帳號或密碼錯誤')

非常簡單!

附帶一提,我不太喜歡程式中有「不必要」的else,此時的寫法仍不盡理想——因為else完全可以省略

在最新的程式碼中,你可以看到我已改成:

1
2
3
4
5
6
user = authenticate(...)
if user is None:
raise HttpError(401, '帳號或密碼錯誤')

login(request, user) # 將使用者登入狀態儲存至 session
return {'message': '登入成功'}

這樣的做法即所謂的 Guard ClauseEarly Return(雖然這裡是 raise)。

簡評與重要補充

authenticatelogin的用法幾乎是固定的,很容易理解:

  • authenticate在驗證成功時會 return 對應的User物件,失敗時則返回None
  • login不會 return,但requestuser為必要的參數。

成功登入後,你會得到 200 回應,並獲得兩組 cookie:

這對於 API client(比如 Postman)使用者很重要,畢竟瀏覽器會自動幫你存,但這些工具可不會——好吧,我錯了,至少我用的 RapidAPI 會自動存儲、發送

(我測試 API 時還覺得奇怪,怎麼認證防護都失效了🤣)

如果工具沒有幫你做,記得自己在請求的 headers 加上:

1
2
3
4
POST /users/2/avatar/ HTTP/1.1
...
Cookie: csrftoken=...; sessionid=...
X-CSRFToken: ...

authenticate預設是以AbstractUserusername欄位和密碼作為認證基準,如果想用別的欄位,比如email,則要自己覆寫 Django 的認證後端。


為 API 加上「認證保護」

登入功能完成後,接下來要將「需要登入才能存取」的 API,分別加上認證保護,使用 Django Ninja 提供的django_auth——這是專門給 Django 內建的 session 認證使用。

我們以「上傳 avatar」API 為例:

1
2
3
4
5
6
7
8
from ninja.security import django_auth
...

@router.post(
path='/users/{int:user_id}/avatar/',
summary='上傳 avatar',
auth=django_auth # 加上這組參數
)

這個例子中,auth=django_auth確保只有「已登入的使用者」才能存取此 API,否則將得到 401 或 403 回應。


Django Ninja 的 request.auth

但你可能會想到:

光是驗證「已登入」還不夠吧?

「上傳 avatar」應該只能幫「自己」上傳,總不能幫「別人」上傳大頭照吧!

沒錯,所以我們在 view 函式內部,還要多一層驗證

Django 的request.user

傳統的 Django 專案,我們會透過函式的第一參數——request,用request.user來獲得當前使用者資訊,比如:(參考文件

1
2
3
4
5
6
if request.user.is_authenticated:
# Do something for authenticated users.
...
else:
# Do something for anonymous users.
...

具體來說:

  • 當使用者已登入,request.user會是一個User實例,代表當前登入的使用者
  • 未登入時,request.user則是一個AnonymousUser實例,代表未登入使用者

當使用者已登入,我們可以檢查request.user的屬性,比如request.user.id,來確認是否為「本人」

Django Ninja 的request.auth

但寫 Django Ninja 則需要使用它提供的request.auth,實作結果如下:

1
2
3
4
5
6
7
8
9
...
def upload_avatar(...) -> dict[str, str]:
"""
上傳 avatar
"""
# 檢查登入的使用者是否為「本人」
if request.auth.id != user_id:
raise HttpError(403, '無權限上傳其他使用者的 avatar')
...

測試一下,登入後在 URL path 打別人的 id 來呼叫此 API:

1
2
3
4
// 403 Forbidden
{
"detail": "無權限上傳其他使用者的 avatar"
}

非常好!


request.auth 解析

雖然這裡用request.auth來取代request.user,但其實兩者的內涵有很大的不同

在 Django Ninja 中,request.auth代表的是認證流程 return 的結果。此外,Django Ninja 允許你自定義認證方法,所以request.auth的內容是不固定的

讓我們深入了解一下。

認證結果

request.auth包含了當前認證方法返回的值。

  • 這個值可以是任何類型,取決於你如何實現認證邏輯
  • 這給了開發者極大的靈活性,它可以是User物件、字串、Python 字典等等。

認證方法與常見用例

  • 使用 Django 的 session 認證時,request.auth是 Django 的User物件。
  • 對於 API key 認證,request.auth可能是 API key 本身或與之相關的資訊。
  • 在 JWT 認證中,request.auth可能包含解碼後的 token 資訊。

總之,只要記得,想在 view 函式內進一步取得認證資訊,要透過request.auth


這樣就已經實作完認證了,但我們可以讓事情更「簡單」一點。

全域認證的設定與例外

一一對每個 API 設定認證保護,感覺有點繁瑣——尤其在 API 多的時候。

對此,Django Ninja 支援全域認證,讓所有 API 預設都直接受到保護,開發者只需在特定路由中進行例外處理,排除不想套用的 API 即可。

實作上非常簡單,Django Ninja 直接提供了SessionAuth認證類別,用來處理全域的 session-based 認證。

實作全域認證:使用SessionAuth

在專案的api.py中加入下面內容:

1
2
3
4
5
6
7
8
# NinjaForum/api.py
from ninja.security import SessionAuth
...

api = NinjaAPI(
auth=SessionAuth(), # 設定全域認證
...
)

如此一來,全部的 API 都預設擁有認證保護,你可以在特定 API 中排除,比如「登入使用者」:

1
@router.post(path='/users/login/', summary='登入使用者', auth=None)

在路由裝飾器中,把auth定義為None解除認證保護。


測試認證保護

我們來測試一下「有認證保護」的 API,你會發現在未登入的情況下,嘗試不同 HTTP 方法的 API,你將會得到不同的錯誤回應:

  • GET:401 Unauthorized
  • POST:403 Forbidden

所以前面才會說你會得到「401 或 403」回應。

測試「取得所有使用者」API

在我們的專案設計中,只有登入的使用者才能存取「取得所有使用者」API。

未登入的情況下,你會得到 401 回應:

1
2
3
4
// 401 Unauthorized
{
"detail": "Unauthorized"
}

測試「新增文章」API

未登入也無法存取「新增文章」API——這顯然非常合理,否則文章不就沒作者了😅

你會得到 403 回應:

1
2
3
4
// 403 Forbidden
{
"detail": "CSRF check Failed"
}

你心想:「奇怪?為什麼是 CSRF check Failed?」

這是 Django 的 CSRF 保護機制,因為我們的 API 是 POST 方法,所以 Django 會自動檢查 CSRF token,但我們沒有提供 CSRF token,所以就會出現這個錯誤。


小結與下一步

在這篇文章中,我們探討了 Django 的 session 認證與 Django Ninja 的整合,實作了「使用者登入」API,並為其他 API 加上認證保護。最後還示範了如何實現全域認證,讓整個流程更加簡單。

這個系列的最後實踐,我們要來為專案——寫測試

下一篇將探討,如何使用 test client 和 pytest 來為我們的 Django API 撰寫單元測試。這不僅能幫助我們驗證現有功能,還能為未來的開發和重構提供多一層的保障。