問題的起點:靜態網站能做身份驗證嗎?

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 加幾行設定,它們就自動成為受保護的應用——不需要寫一行登入相關的程式碼。


延伸閱讀