問題的起點:靜態網站能做身份驗證嗎?
Hugo 是一個純粹的靜態網站產生器——它將 Markdown 轉換成 HTML,部署後就是一堆靜態檔案。沒有資料庫、沒有 Session、沒有後端程式碼。
但有些內容就是不想公開。可能是內部研究筆記、可能是付費內容、也可能只是想給特定的人看。
這就引出了一個問題:在沒有後端的情況下,要怎麼做「登入後才能看」?
兩條路線的探索
經過研究,我找到了兩條可行的路線。
路線一:Cloudflare Access(交給第三方)
Cloudflare 提供了一個叫做 Zero Trust Access 的服務。它的原理是將你的網域 DNS 代管到 Cloudflare,讓所有流量先經過 Cloudflare 的節點。當有人要存取你指定的路徑時,Cloudflare 會彈出一個驗證頁面,要求輸入 Email 並收取驗證碼。
使用者訪問 kqlet.online/private/
↓
Cloudflare 攔截(DNS 層代理)
↓
「請輸入 Email」
↓
使用者收到驗證碼 → 輸入 → 通過
↓
Cloudflare 放行 → Nginx → 靜態檔案優點:
- 完全不需要修改網站程式碼
- 安全性極高(伺服器端攔截)
- 支援 Google、GitHub 等社群登入
- Free 方案支援 50 位使用者
缺點:
- DNS 必須託管到 Cloudflare
- 驗證邏輯完全由第三方掌控
- 無法深度客製化驗證流程
這個方案非常強大,但我更想要的是自己掌控整個驗證流程。
路線二:Nginx + Python(自己建造)
既然我的網站本來就部署在 Vultr 主機上,使用 Nginx 作為 Web Server,那能不能讓 Nginx 本身來擔任「守門人」?
答案是可以的。Nginx 有一個模組叫做 auth_request,它允許 Nginx 在處理請求之前,先去問一個外部的驗證服務:「這個人登入過了嗎?」
使用者訪問 /private/某篇文章
↓
Nginx 攔截(auth_request)
↓
內部詢問 Python 服務 /auth
├─ 回傳 200 → 放行,顯示文章
└─ 回傳 401 → 導向 /login 登入頁我選擇了這條路。因為驗證邏輯跑在自己的伺服器上,想改什麼就改什麼。
自建驗證代理的實作
Python 守門人腳本(Flask)
這個 Python 程式負責三件事:回應 Nginx 的驗證詢問、顯示登入頁面、驗證密碼並種下 Cookie。
from flask import Flask, request, make_response, render_template_string
import hashlib
app = Flask(__name__)
# --- 設定區 ---
PASSWORD = "your_secret_password" # 進入密碼
AUTH_COOKIE_NAME = "kqlet_auth_token" # Cookie 名稱
SECRET_SALT = "random_string_here" # 增加 Token 安全性的鹽值
# 將密碼 + 鹽值雜湊後作為合法 Token
# hashlib.sha256().hexdigest() 將字串進行 SHA-256 雜湊,產生固定長度的十六進位字串
# 這樣 Cookie 中不會出現明文密碼
VALID_TOKEN = hashlib.sha256((PASSWORD + SECRET_SALT).encode()).hexdigest()
@app.route('/auth')
def auth():
"""
Nginx 的 auth_request 會呼叫這個端點。
檢查瀏覽器是否帶有合法的 Cookie。
回傳 200 表示已登入,回傳 401 表示未登入。
"""
token = request.cookies.get(AUTH_COOKIE_NAME)
if token == VALID_TOKEN:
return "OK", 200
return "Unauthorized", 401
@app.route('/login')
def login_page():
"""顯示登入頁面"""
return render_template_string(LOGIN_HTML)
@app.route('/login_verify', methods=['POST'])
def login_verify():
"""
驗證使用者輸入的密碼。
成功:設定 httponly Cookie 並導向到受保護頁面。
失敗:回到登入頁面並顯示錯誤訊息。
"""
user_password = request.form.get('password')
if user_password == PASSWORD:
resp = make_response(
'<script>window.location.href="/private/";</script>'
)
# httponly=True:防止 JavaScript 讀取 Cookie,降低 XSS 攻擊風險
# samesite='Lax':僅允許同站請求攜帶 Cookie,防止 CSRF 攻擊
resp.set_cookie(
AUTH_COOKIE_NAME, VALID_TOKEN,
httponly=True, samesite='Lax'
)
return resp
else:
return render_template_string(LOGIN_HTML, error="密碼錯誤")
# 登入頁面 HTML(內嵌在 Python 中,方便單檔部署)
LOGIN_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>私密內容驗證</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #f4f4f9;
}
.card {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
width: 300px;
}
input {
width: 100%;
padding: 10px;
margin: 10px 0;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
button {
width: 100%;
padding: 10px;
background: #333;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.error { color: red; font-size: 0.8rem; }
</style>
</head>
<body>
<div class="card">
<h3>請輸入密碼訪問</h3>
<form method="POST" action="/login_verify">
<input type="password" name="password"
placeholder="密碼" required>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<button type="submit">登入</button>
</form>
</div>
</body>
</html>
"""
if __name__ == '__main__':
# 僅監聽本機迴路,不對外開放
# 外部流量由 Nginx 代理進來
app.run(host='127.0.0.1', port=5000)Nginx 設定
在 Nginx 設定檔中加入以下區塊,讓它對 /private/ 路徑啟用驗證:
server {
# ... 原有的 listen、server_name、root 等設定 ...
# ═══════════════════════════════════════════
# 受保護的目錄:訪問前必須通過驗證
# ═══════════════════════════════════════════
location /private/ {
auth_request /auth_check; # 先詢問守門人
error_page 401 = @login_redirect; # 未登入則跳轉
try_files $uri $uri/ =404;
}
# Nginx 內部子請求:詢問 Python 守門人
location = /auth_check {
internal; # 僅限 Nginx 內部呼叫
proxy_pass http://127.0.0.1:5000/auth;
proxy_pass_request_body off; # 不傳送 body(節省資源)
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
}
# 驗證失敗時的跳轉目標
location @login_redirect {
return 302 /login;
}
# 登入頁面與驗證表單,代理到 Python 服務
location /login {
proxy_pass http://127.0.0.1:5000/login;
}
location /login_verify {
proxy_pass http://127.0.0.1:5000/login_verify;
}
}部署步驟
# 1. 安裝 Flask
pip install flask
# 2. 將腳本放到主機上
# 建議路徑:/var/www/auth/gatekeeper.py
# 3. 測試執行
python3 /var/www/auth/gatekeeper.py
# 4. 檢查 Nginx 設定語法並重新載入
sudo nginx -t && sudo systemctl reload nginx
# 5. 正式環境建議用 systemd 管理(開機自動啟動)
# 建立 /etc/systemd/system/gatekeeper.service發現:這套架構叫做 Auth Proxy
當我把這個方案實作出來之後才意識到——這不正是我每次開發不同系統時很想拆離出來的身份驗證系統,而這在業界已經有明確名稱的架構模式。而且根據不同的視角和使用情境,它有好幾個不同的叫法。
Auth Proxy / Identity-Aware Proxy (IAP)
最貼切的名稱。Google Cloud 稱之為 IAP (Identity-Aware Proxy)。
核心理念:應用程式不應該自己處理登入邏輯。登入驗證是由邊緣的 Proxy 負責,應用程式只需要專注在內容和業務邏輯上。
Forward Auth(轉發驗證)
這是 Nginx auth_request 在技術上的通用名稱。在 Docker 和 Kubernetes 的生態系中極為流行。無論你的後端是 PHP、Node.js 還是靜態 HTML,只要掛上 Forward Auth,它們統統都會自動獲得身份驗證功能。
SSO Gateway(單一登入閘道)
如果想做到「跨站共用登入狀態」,這就進入了 SSO (Single Sign-On) 的領域。
原理:只要你的驗證服務種下的 Cookie 網域設定為 .your-domain.com(注意最前面的點),那麼所有子網域都能共用這個登入狀態:
驗證服務種下 Cookie:
Domain = .kqlet.online
以下子網域都能讀到這顆 Cookie:
blog.kqlet.online ✓
dashboard.kqlet.online ✓
api.kqlet.online ✓Zero Trust Architecture(零信任架構)
這是更宏觀的架構思維。核心原則是 「Never Trust, Always Verify」——不管請求來自內網還是外網,每一個請求都必須經過驗證。你的 Nginx + Python 守門人,就是這個架構中的 Policy Enforcement Point(政策執行點)。
這就是我一直在找的東西
回頭來看,我一直想要的其實很簡單:
把「註冊、登入、身份驗證」這整個模組從網站中拆離出來,讓它成為一個獨立的服務。任何網站只要接上這個服務,就自動擁有身份驗證功能。
這個想法在業界被稱為 Separation of Concerns(關注點分離),用在身份驗證上就是:
| 角色 | 職責 | 不需要關心 |
|---|---|---|
| 內容開發者 | 寫 Markdown、寫業務邏輯 | 登入怎麼實作的 |
| 驗證模組 | 密碼驗證、Token 管理、第三方登入 | 網站內容是什麼 |
| Nginx | 根據驗證結果決定放行或攔截 | 密碼對不對、內容是什麼 |
三者各司其職,互不干涉。
更進一步,驗證模組通過後,Nginx 可以透過 HTTP Header 把身份資訊傳給後端程式:
# Nginx 將驗證通過的使用者資訊附加到 Header
proxy_set_header X-User-Email $upstream_http_x_user_email;
proxy_set_header X-User-Role $upstream_http_x_user_role;這樣後端程式只要讀取 Header 就知道是誰在操作,完全不需要自己查資料庫或管理 Session。
現成的開源方案
如果不想從零開始寫,以下是幾個成熟的開源 Auth Proxy 專案,它們本質上就是「Python 守門人腳本」的完整版本:
| 專案 | 特色 | 適合對象 |
|---|---|---|
| Authelia | 輕量級、支援 2FA、LDAP | 個人與小型團隊 |
| Authentik | 功能極其強大、完整的 IdP | 中大型組織 |
| Vouch Proxy | 專為 Nginx 設計的 Forward Auth | 已有 Nginx 架構的人 |
結語
從「Hugo 靜態網站能不能做登入」這個簡單的問題出發,最終找到了 Auth Proxy 這個架構模式。它讓我理解到:身份驗證不應該是每個應用程式自己重複造的輪子,而是可以被獨立出來、統一管理、跨站共用的基礎設施。
這套架構一旦建立起來,未來不管再開發什麼新網站或新服務,只需要在 Nginx 加幾行設定,它們就自動成為受保護的應用——不需要寫一行登入相關的程式碼。
