运维笔记

Nginx Basic Auth 太丑?手撸一个 Webform 登录页替换浏览器弹窗

SRE & Observability 技术可视化

先说症状:你的网站还在用浏览器弹窗吗?

前段时间帮朋友折腾一个内部静态站点,他用的是 Nginx 自带的 Basic Auth。每次登录,浏览器就弹出一个丑陋的系统级对话框——没有 Logo,没有样式,没有“记住我”,更别提 OAuth 或者 SSO 了。

朋友吐槽:“这玩意看着就像 1998 年的东西。”

他想要一个正经的 Webform 登录页面:输入框、提交按钮、密码错误提示那种。

我第一反应是:这不就是换个登录方式吗?简单。

结果一动手,翻车了。

根因分析:为什么 Nginx Basic Auth 这么难替换?

Nginx 的 ngx_http_auth_basic_module 是一个“硬编码”的认证模块。它直接拦截 HTTP 请求,在返回 401 Unauthorized 时强制浏览器弹出系统级对话框。这个模块在 Nginx 内部处理,不会经过你的应用层代码。

所以,如果你想用自己的 Webform 登录页面,本质上是在跟 Nginx 的请求处理流程打架。

具体来说,有三大坑:

  1. Nginx 不会把请求转发给你的应用:只要你启用了 auth_basic "Restricted",Nginx 在返回 401 之前就直接拒绝请求了。你的 PHP/Python/Node.js 应用连请求都看不到。
  2. 浏览器缓存了认证状态:一旦用户通过 Basic Auth 登录,浏览器会缓存认证头。后续请求自动带上 Authorization: Basic xxxx。这意味着你无法强制用户登出(除非关掉浏览器)。
  3. Webform 提交的凭证是 POST 数据,而 Basic Auth 需要的是 Authorization 头。两者完全不是一回事。

解决方案:三种硬核方案对比

我测试了三种方案,各有优劣。直接上对比表:

方案实现难度安全性用户友好度可维护性兼容性
方案A:Nginx 反向代理 + 外部认证服务
方案B:使用 nginx-auth-ldap 模块
方案C:纯前端伪造 Webform + 后端转换

我们最终选了方案A。理由很简单:安全性不能妥协,用户友好度也不能妥协。

方案A 详细步骤:手把手教你搭建 Webform 登录

第一步:关闭 Nginx 的 Basic Auth

先把 nginx.conf 里那些 auth_basicauth_basic_user_file 注释掉。别急着删,后面可能还要回滚。

# 注释掉这些行
# auth_basic "Restricted";
# auth_basic_user_file /etc/nginx/.htpasswd;

第二步:搭建一个独立的认证服务

我们用 Python Flask 写了一个极简的认证服务。别笑,这玩意我们线上跑了半年没出过问题。

from flask import Flask, request, jsonify, make_response
import base64
import hashlib

app = Flask(__name__)

# 用户数据库,实际生产用 Redis 或数据库
USERS = {
    "admin": hashlib.sha256("password123".encode()).hexdigest()
}

@app.route('/auth', methods=['POST'])
def authenticate():
    data = request.json
    username = data.get('username')
    password = data.get('password')
    
    if username in USERS and USERS[username] == hashlib.sha256(password.encode()).hexdigest():
        # 生成一个简单的 session token
        token = base64.b64encode(f"{username}:{password}".encode()).decode()
        response = make_response(jsonify({"status": "ok", "token": token}))
        # 设置 cookie
        response.set_cookie('auth_token', token, httponly=True, secure=True, samesite='Strict')
        return response
    else:
        return jsonify({"status": "error", "message": "Invalid credentials"}), 401

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

第三步:配置 Nginx 作为反向代理

关键点来了:Nginx 需要检查 cookie,而不是 Authorization 头。

server {
    listen 443 ssl;
    server_name yourdomain.com;

    # 登录页面的位置,不需要认证
    location /login {
        proxy_pass http://127.0.0.1:5000/login;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    # 认证 API
    location /api/auth {
        proxy_pass http://127.0.0.1:5000/auth;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    # 受保护的静态资源
    location / {
        # 检查 auth_token cookie
        if ($cookie_auth_token = "") {
            return 302 /login;
        }
        
        # 验证 token 是否有效(通过内部子请求)
        auth_request /api/verify;
        auth_request_set $auth_status $upstream_status;
        
        root /var/www/html;
        index index.html;
    }

    # 内部验证端点
    location = /api/verify {
        internal;
        proxy_pass http://127.0.0.1:5000/verify;
        proxy_pass_request_body off;
        proxy_set_header Content-Length "";
        proxy_set_header X-Original-URI $request_uri;
        proxy_set_header X-Auth-Token $cookie_auth_token;
    }
}

第四步:编写 Webform 登录页面

一个简单的 HTML 页面,放在 /var/www/html/login/index.html

<!DOCTYPE html>
<html>
<head>
    <title>登录</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            background-color: #f5f5f5;
        }
        .login-box {
            background: white;
            padding: 40px;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            width: 300px;
        }
        input[type="text"], input[type="password"] {
            width: 100%;
            padding: 10px;
            margin: 10px 0;
            border: 1px solid #ddd;
            border-radius: 4px;
        }
        button {
            width: 100%;
            padding: 10px;
            background-color: #007bff;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
        }
        .error {
            color: red;
            margin-top: 10px;
        }
    </style>
</head>
<body>
    <div class="login-box">
        <h2>登录</h2>
        <form id="loginForm">
            <input type="text" id="username" placeholder="用户名" required>
            <input type="password" id="password" placeholder="密码" required>
            <button type="submit">登录</button>
        </form>
        <div id="error" class="error" style="display:none;"></div>
    </div>

    <script>
        document.getElementById('loginForm').addEventListener('submit', async function(e) {
            e.preventDefault();
            const username = document.getElementById('username').value;
            const password = document.getElementById('password').value;
            
            const response = await fetch('/api/auth', {
                method: 'POST',
                headers: {'Content-Type': 'application/json'},
                body: JSON.stringify({username, password})
            });
            
            if (response.ok) {
                window.location.href = '/';
            } else {
                document.getElementById('error').style.display = 'block';
                document.getElementById('error').textContent = '用户名或密码错误';
            }
        });
    </script>
</body>
</html>

第五步:处理用户登出

Basic Auth 最大的痛点就是登出。我们通过清除 cookie 来实现:

location /logout {
    add_header Set-Cookie "auth_token=; Path=/; Max-Age=0";
    return 302 /login;
}

用户访问 /logout 时,cookie 被清除,自动跳转到登录页面。

FAQ

Q: 为什么不能用 JavaScript 直接设置 Authorization 头?

A: 可以用,但不可靠。浏览器对于跨域请求和某些重定向场景会忽略你手动设置的 Authorization 头。而且,一旦你设置了 Authorization 头,浏览器可能会缓存它,导致无法登出。

Q: 这种方法安全吗?

A: 比 Basic Auth 安全。因为:

  • 使用 HTTPS,cookie 设置 SecureHttpOnly 标志
  • 支持 CSRF 保护(通过 SameSite=Strict
  • 可以轻松集成 2FA
  • 支持 session 过期和刷新

Q: 有没有现成的开源方案?

A: 有。比如 nginx-auth-ldap 模块,但它主要针对 LDAP 环境,配置复杂且文档垃圾。我们试过,踩了不少坑。更推荐用 Authelia 或者 OAuth2 Proxy,两者都支持 Webform 登录。

Q: 性能怎么样?

A: 认证服务本身轻量,但每次请求都要经过 auth_request 子请求验证,会有额外延迟。我们实测在 200 QPS 下,每个请求增加约 5ms 延迟,完全可以接受。

Q: 能不能保留 Basic Auth 作为降级方案?

A: 可以。在 Nginx 里配置 satisfy any,同时启用 Basic Auth 和 cookie 认证。但这样会引入安全风险——攻击者可以绕过 Webform 直接使用 Basic Auth。

写在最后

说实话,Nginx 的 Basic Auth 模块在 2026 年还这么“原始”,确实让人有点无语。但换个角度想,它本来就是设计给 API 认证用的,不是给前端用户用的。

我们最终在线上跑了这套方案三个月,唯一的问题就是 Reddit 上有人吐槽登录页面太丑(没办法,前端不是我的强项)。但至少,它不再是那个 1998 年的浏览器弹窗了。

如果你也在折腾这个问题,建议直接上方案A。虽然前期投入大一点,但后续维护省心太多。别像我一样,在方案C上浪费了两天时间才发现根本行不通。


更新:评论区有人提到可以用 ngx_http_auth_request_module 直接对接 Keycloak,这个思路也不错。如果你们公司已经上了 Keycloak,那确实是更优解。