最近剛用 python django 寫了一個經典的縮網址服務,並且使用 django-allauth 套件實作 Google 以及 Facebook 登入。
在開發以及過程中遭遇到一些問題,這邊主要記錄一些我印象深刻的問題。
# 克服 Django-allauth 開發問題
# App 簡介
這個 app 希望有以下功能:
- 使用者可以使用 Google or Facebook 登入。
- 登入後可以建立縮網址。
- 縮網址可以前往原本的網址。
- 使用者可以看到自己縮網址的點擊成效。
後端我主要使用以下套件
- django: python web framework
- django-allauth: 與 django 搭配的 auth 套件,可以用來實作第三方登入
- django-rest-framework: 開發 django restful api 的套件
- drf-spectacular: 產生 openAPI yaml 作為 API 文件
- django-decouple: 讀取 .env file 的環境變數
- celery: 非同步執行,用以加快回應速度
前端則是參考 django-allauth spa 官方範例,使用 react 開發。
原始碼可以參考 GitHub
# 開發遇到的問題
# 1. CSRF Token Missing
剛開始開發第三方登入時遇到一個嚴重的問題,因為 django-allauth 會使用 django csrf middleware 作為驗證,所以所有非安全的 http 請求都要帶 csrf token。我有兩種方式取得 csrf token,第一是從 django 產生的頁面中找到帶有 csrfmiddlewaretoken 的元素,第二就是在 django server 的某些請求的回應中會帶有 set-cookies。
因為我採用 spa 的開發模式,所以無法直接從 django 產生的頁面拿到 csrftoken。而在 react 中頁面一開啟都會打 /_allauth//_allauth/browser/v1/config
而這個 endpoint 回傳都會在 set-cookies header 帶 csrftoken。本來以為在這樣的狀況下,只要從 docuement.cookie
就可以達到 csrftoken,沒想到事情沒有那麼簡單。
原因在於在開發環境,我的前端 host 是 localhost:3000,但是後端是 localhost:8000,這不符合 cookie 的 same site,所以前端無法從 document.cookie
得到 csrftoken。我在參考 cookie 的 secure, same site, httpOnly 等的設定後,幾經修改都無法讓前端順利拿到 csrftoken,於是我直接在開發環境使用 traefik 作為 reverse proxy,這樣在基本的 django 設定之下我就可以成功在前端拿到 csrftoken,畢竟在通過 reverse proxy 之後,這就算是同一個網站了。原本 reverse proxy 是想要在部署時才使用的,現在就在開發時一併使用。
# 2. OAuth2 Redirect URL Mismatch
2-1 Redirect Url Settings
Facebook 還有 Google 的 OAuth2 都有 redirect_url 的參數,這可以告知 OAuth2 server 當驗證完之後要將請求導回到 app。而這個 redirect_url 需要事先從開發者 console 設定,這邊的說明跳過如何建立 app,已經有很多教學文了。
Facebook (2025-01)
- 從 developer 頁面 進入
My Apps
- 點擊要設定的 app
- 左側選單中選擇
Use Cases
- 找到要設定的功能(我這邊是Authenticate and request data from users with Facebook Login),點擊 Customize。
- 進入功能設定頁面後,左側在 permissions 底下有 settings,點擊 settings
- 進入 OAuth client 設定,可以在這邊設定並且驗證 redirect_url。
Google (2025-01)
- 從 Google Auth Platform Clients 頁面
- 點擊要設定的 client id
- 進入頁面即可在設定頁面左下角找到 redirect_url 的設定區域
在開發時我依照 django-allauth 文件,將 redirect 設定為 http://localhost:10000/accounts/{provider}/login/callback/
。若是沒有設定,或者 redirect_url 與後台的設定不相符,會出現 OAuth2 server 會回傳 400 http status code。
在設定完之後,我的 app 就可以使用 Google 或者 Facebook 登入了。
2-2 Reverse Proxy Settings
開發的時候一切都好,不過當我部署到對外伺服器的時候又發生問題了。
我使用我自己家中的舊電腦做伺服器,上面已經使用了 nginx 作為 reverse proxy,我打算將 url shortener 也掛在 nginx 後面,整體會像下圖:
當我把所有服務都使用 docker compose 架好,並且設定好 nginx reverse proxy 之後,當我嘗試使用第三方登入時,我又出現 redirect_url mismatch 了。我細看錯誤訊息,發現 redirect_url 被設定為 http://localhost:10000/account/provider/callback
,而不是我網站的 domain。
我當下覺得應該是 nginx 有問題,在查找了各式資料以及問過 AI 之後,我找到問題來源,主要原因是使用 reverse proxy。
使用 reverse proxy,當 request 經過 proxyt 時會被再一次送出,而這個過程中部分 request header 會改變,包含辨識 host 的 header。
但是這些 header 的改變又與 redirect_url 有什麼關係?這就要去看 django-allauth 的 source code。
django-allauth 使用第三方的流程大致上是對自己的 api /_allauth/browser/v1/auth/provider/redirect
發送 post request,而 view 就會依照 request payload 產生 redirect_url 等第三方登入需要的 payload,然後回傳 302 將使用者導向第三方驗證頁面。
可以參照 django-allauth/utils.py#L285
def build_absolute_uri(request, location, protocol=None):
"""request.build_absolute_uri() helper
Like request.build_absolute_uri, but gracefully handling
the case where request is None.
"""
from .account import app_settings as account_settings
if request is None:
if not app_settings.SITES_ENABLED:
raise ImproperlyConfigured(
"Passing `request=None` requires `sites` to be enabled."
)
from django.contrib.sites.models import Site
site = Site.objects.get_current()
bits = urlsplit(location)
if not (bits.scheme and bits.netloc):
uri = "{proto}://{domain}{url}".format(
proto=account_settings.DEFAULT_HTTP_PROTOCOL,
domain=site.domain,
url=location,
)
else:
uri = location
else:
uri = request.build_absolute_uri(location) # THIS LINE
# NOTE: We only force a protocol if we are instructed to do so
# (via the `protocol` parameter, or, if the default is set to
# HTTPS. The latter keeps compatibility with the debatable use
# case of running your site under both HTTP and HTTPS, where one
# would want to make sure HTTPS links end up in password reset
# mails even while they were initiated on an HTTP password reset
# form.
if not protocol and account_settings.DEFAULT_HTTP_PROTOCOL == "https":
protocol = account_settings.DEFAULT_HTTP_PROTOCOL
# (end NOTE)
if protocol:
uri = protocol + ":" + uri.partition(":")[2]
return uri
L285 uri 一般狀況會是用 request 的 method。而這個 request 是 django HttpRequest,這邊就可以看 django/http/request.py#L148
class HttpRequest:
### other properties
def _get_raw_host(self):
"""
Return the HTTP host using the environment or request headers. Skip
allowed hosts protection, so may return an insecure host.
"""
# We try three options, in order of decreasing preference.
if settings.USE_X_FORWARDED_HOST and ("HTTP_X_FORWARDED_HOST" in self.META):
host = self.META["HTTP_X_FORWARDED_HOST"]
elif "HTTP_HOST" in self.META:
host = self.META["HTTP_HOST"]
else:
# Reconstruct the host using the algorithm from PEP 333.
host = self.META["SERVER_NAME"]
server_port = self.get_port()
if server_port != ("443" if self.is_secure() else "80"):
host = "%s:%s" % (host, server_port)
return host
可以看到 request 會根據條件去判斷使用 X-Forwarded-Host, Http-Host, Server-Name 其中一個作為 host 去組成 absolute url。在這邊因為我是在 reverse proxy 的環境中部署,根據文件要在 django 設定 USE_X_FORWARDED_HOST = True
。
所以我先設定好 django,並且在我的 nginx.conf 中把 headers 設定好。
nginx.conf
server {
listen 443 ssl http2;
server_name my.short.url;
ssl_certificate /data/short.url.crt;
ssl_certificate_key /data/short.url.key;
location / {
proxy_pass http://localhost:10000;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Host $host;
proxy_set_header Http-Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
}
}
不過當我設定好了之後,我的 redirect_url domain 依舊是 localhost,在核對 nginx 以及 django 設定之後,我轉向 traefik 去確認可能的問題在哪裡。在這邊我找到traefik entry points forwarded headers 設定,這邊我先使用設定 insecure mode,注意,這不是推薦的用法,正確用法是要設定好 proxy trusted ips,否則會有潛在資安問題。
traefik.toml
[entryPoints]
[entryPoints.web]
address = ":10000"
# use this in a secure reverse proxy environment
[entryPoints.web.forwardedHeaders]
insecure = true
當我再次點擊要登入,又出現 redirect_url mismatch,仔細看這次 domain 對了,不過 redirect_url 的 protocol 是 http,但是我在後台設定的是 https,依舊不一樣。而這裡則又要回去看 django request 是怎麼取得 scheme, django/http/request.py#L290
class HttpRequest:
### other properties
def scheme(self):
if settings.SECURE_PROXY_SSL_HEADER:
try:
header, secure_value = settings.SECURE_PROXY_SSL_HEADER
except ValueError:
raise ImproperlyConfigured(
"The SECURE_PROXY_SSL_HEADER setting must be a tuple containing "
"two values."
)
header_value = self.META.get(header)
if header_value is not None:
header_value, *_ = header_value.split(",", 1)
return "https" if header_value.strip() == secure_value else "http"
return self._get_scheme() # return "http"
可以看到如果沒有設定 SECURE_PROXY_SSL_HEADER
或者設定不正確,就只會回傳 http,這邊也是回頭找 django 文件,在這邊設定好 header 的 key, value,並且再去設定 nginx.conf
server {
listen 443 ssl http2;
server_name my.short.url;
ssl_certificate /data/short.url.crt;
ssl_certificate_key /data/short.url.key;
location / {
proxy_pass http://localhost:10000;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Host $host;
proxy_set_header Http-Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; # add this line
}
}
在設定完這些之後,我終於可以在我的縮網服務使用第三方登入!
# 結語
這些問題是我在開發上花最多時間解決的問題,雖然有些問題自己追到後面發現文件已經寫了要怎麼設定,不過我自己是還滿喜歡這樣追原始碼搞清楚他們到底在做什麼的。
關於作者
我是好人