零信任架构实战:身份感知代理保护内网服务

零信任架构的核心是「永不信任,始终验证,最小权限」——不再默认内网可信、也不再依赖 IP 白名单,而是对每一次访问都做身份校验与权限判定。落地最容易见效的一步,是在反向代理层部署身份感知代理(Identity-Aware Proxy,IAP):所有请求先过统一身份认证,认证通过后才放行到后端,管理后台从此不再直接暴露在公网。本教程用 oauth2-proxy + Nginx auth_request 给自建管理后台套上零信任访问控制,并给出伪造身份头的攻防验证。

适用场景

  • 自建 Kibana、Jenkins、Grafana、Nextcloud、内部 OA 等后台,目前只靠 IP 白名单或干脆裸奔在公网;
  • 多个后台各有一套账号体系,人员离职后账号清理不彻底;
  • 需要满足等保 2.0 对”身份鉴别””访问控制””最小权限”的要求,且不想逐个系统改造登录逻辑。

前置条件

  • 一台具备公网域名的 Nginx 入口服务器,已配置有效 TLS 证书;
  • 一个 OAuth 2.0 / OIDC 身份源:可用 GitHub、Google 等外部提供商,也可用自建 Keycloak;
  • 后端服务监听在 127.0.0.1,不直接对外暴露端口(这是本方案的前提)。

原理说明

传统边界防护假设”内网可信”,只要进了内网就等于获得访问权,一旦边界被突破就全线失守。零信任不再用网络位置做信任判断,而是把身份作为唯一凭据:请求到达入口时先问”你是谁”,再问”你有没有权限访问这个资源”,两个问题都通过才转发。

身份感知代理正是这个判断点的实现载体,工作流程分四步:

  • 拦截:Nginx 用 auth_request 把每个请求先转发给 oauth2-proxy 的 /oauth2/auth 做子请求校验;
  • 认证:未携带有效会话 Cookie 时,oauth2-proxy 返回 401,Nginx 通过 error_page 把用户 302 跳到身份提供商的登录页;
  • 鉴权:登录成功后 oauth2-proxy 校验邮箱域名或用户组,不在允许列表内直接拒绝;
  • 身份注入:通过 auth_request_set 把用户邮箱写入 X-Auth-Request-Email 后端可读的请求头,配合 --set-xauthrequest 让后端做细粒度授权。

要害在于最后一跳:必须确保客户端自带的同名请求头被覆盖,否则攻击者只要伪造 X-Auth-Request-Email: admin@example.com 就能绕过整套架构。

与传统 VPN 和 IP 白名单相比,本方案有三点不同:粒度更细,权限绑定到具体邮箱与用户组,而不是整个网段;可审计,每次放行都记录身份与访问资源,天然形成访问日志;无需客户端,浏览器即入口,外包与远程办公人员不必安装 VPN 客户端。代价是必须维护一个可靠的身份源,且身份源故障会直接影响全部后台的登录。

操作步骤

1. 启动 oauth2-proxy

# 生成强随机会话密钥
COOKIE_SECRET=$(openssl rand -base64 32)

./oauth2-proxy \
  --provider=github \
  --client-id=<客户端ID> \
  --client-secret=<客户端密钥> \
  --cookie-secret="$COOKIE_SECRET" \
  --cookie-secure=true \
  --cookie-expire=8h \
  --email-domain=example.com \
  --allowed-group=ops-team \
  --http-address=127.0.0.1:4180 \
  --upstream=static://200 \
  --reverse-proxy=true \
  --set-xauthrequest=true \
  --skip-provider-button=true

关键参数说明:--reverse-proxy=true 表示运行在反向代理之后,信任 X-Forwarded-Proto;--set-xauthrequest=true 才会下发 X-Auth-Request-* 系列头;--email-domain 与 --allowed-group 共同构成最小权限的白名单。生产环境务必加 --session-store-type=redis,否则多实例间会话不共享。

2. 配置 Nginx 认证子请求

# oauth2-proxy 自身的回调与登录入口
location /oauth2/ {
    proxy_pass http://127.0.0.1:4180;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Scheme $scheme;
    proxy_set_header X-Auth-Request-Redirect $request_uri;
}

# 仅用于 auth_request 子请求,不对外提供内容
location = /oauth2/auth {
    proxy_pass http://127.0.0.1:4180;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Scheme $scheme;
    proxy_set_header Content-Length "";
    proxy_pass_request_body off;
}

# 受保护的后端:所有进入 /admin/ 的请求先做身份校验
location /admin/ {
    auth_request /oauth2/auth;
    error_page 401 = /oauth2/sign_in;

    # 把认证结果映射成变量后再下发,客户端伪造的同名头会被覆盖
    auth_request_set $auth_user $upstream_http_x_auth_request_user;
    auth_request_set $auth_email $upstream_http_x_auth_request_email;

    proxy_set_header X-User $auth_user;
    proxy_set_header X-Email $auth_email;
    proxy_set_header X-Real-IP $remote_addr;

    proxy_pass http://127.0.0.1:8080/;
}

# 健康检查等无需认证的路径可显式放行
location = /healthz {
    auth_request off;
    proxy_pass http://127.0.0.1:8080/healthz;
}

注意:proxy_set_header X-Email $auth_email; 写在 auth_request_set 之后,Nginx 会用上游返回的变量值覆盖客户端传入的同名头,这是防伪造的关键。

3. 最小权限收敛

# 方案 A:静态邮箱白名单文件(每行一个地址)
cat > /etc/oauth2-proxy/allowlist.txt <<'EOF'
ops@example.com
devops-lead@example.com
EOF
# 启动参数追加:--authenticated-emails-file=/etc/oauth2-proxy/allowlist.txt

# 方案 B:按路径做差异化授权(只读用户在 /admin/view/ 下放行)
location /admin/view/ {
    auth_request /oauth2/auth;
    error_page 401 = /oauth2/sign_in;
    auth_request_set $auth_email $upstream_http_x_auth_request_email;
    proxy_set_header X-Email $auth_email;
    proxy_pass http://127.0.0.1:8080/view/;
}

4. 会话持久化到 Redis

./oauth2-proxy \
  --provider=github --client-id= --client-secret= \
  --cookie-secret="$COOKIE_SECRET" --cookie-secure=true \
  --email-domain=example.com --reverse-proxy=true --set-xauthrequest=true \
  --http-address=127.0.0.1:4180 --upstream=static://200 \
  --session-store-type=redis \
  --redis-connection-url=redis://:<强随机密码>@127.0.0.1:6379/0 \
  --redis-use-sentinel=false

5. 退出登录与会话吊销

# 统一的退出入口:清理本地会话 Cookie
location = /oauth2/sign_out {
    proxy_pass http://127.0.0.1:4180;
    proxy_set_header Host $host;
}

仅清理本地 Cookie 并不够——上游身份提供商的授权状态仍然有效。人员离职或设备丢失时,必须在上游后台同时吊销授权,并删除 Redis 中对应的会话键:

# 查看并清理指定会话
redis-cli -a '<强随机密码>' --scan --pattern 'oauth2proxy*'
redis-cli -a '<强随机密码>' DEL "oauth2proxy_session_<哈希值>"

把”离职即吊销上游授权 + 清理 Redis 会话”写入人员流程,才算满足最小权限中”权限随身份失效而立即回收”的要求。

配置验证

# 1. 未认证请求应被 302 到身份提供商,绝不返回 200
curl -sI https://ops.example.com/admin/ | head -n 5
# 预期:HTTP/2 302,Location 指向 /oauth2/start 或登录页

# 2. 伪造身份头必须无效:仍应返回 302 而非 200
curl -sI -H "X-Auth-Request-Email: admin@example.com" \
     https://ops.example.com/admin/ | head -n 1
# 预期:HTTP/2 302(若返回 200 说明头被透传,配置有误)

# 3. 认证后请求应返回 200,且后端能读到真实身份
curl -sI -b "cookies.txt" https://ops.example.com/admin/ | head -n 1

# 4. 确认后端服务未在公网监听
ss -lntp | grep 8080
# 预期:仅 127.0.0.1:8080,无 0.0.0.0:8080

预期结果:未认证访问返回 302,伪造 X-Auth-Request-Email 同样返回 302,携带合法会话 Cookie 后返回 200,后端端口只在回环地址监听。

常见问题(FAQ)

Q1:登录成功后陷入无限重定向循环

九成是协议头没透传完整。oauth2-proxy 需要根据 X-Forwarded-Proto 判断回调地址,若该头缺失或值为 http,它会生成 http 回调地址,与用户的 https 会话比对不上从而反复跳转。解决办法是在外层 Nginx 的 location /oauth2/ 中显式下发 proxy_set_header X-Scheme $scheme;,并确认 --cookie-secure=true 与 https 访问一致。另一个原因是 --redirect-url 里写的是内网地址,需改成对外域名。

Q2:攻击者伪造 X-Auth-Request-Email 就能进后台吗

只要按上文写法把该头由 auth_request_set 映射后再用 proxy_set_header 下发,Nginx 会用上游返回的真实值覆盖客户端传入的同名头,伪造无效——验证方法就是文中的第 2 步 curl -H 注入测试,必须仍返回 302。真正的风险是把这个头直接交给其后端应用做权限判断后又让应用暴露在公网,因此后端服务必须只监听 127.0.0.1,并增加一道 allow 127.0.0.1; deny all; 兜底。

Q3:Cookie 过大导致 400/494 报错,或扩容后频繁掉登录

Cookie 过大通常是会话里缓存了过多用户信息或组列表,可加 --cookie-refresh=1h 缩短缓存、并用 --session-store-type=redis 把会话下沉到 Redis,Cookie 只保留会话 ID。扩容后掉登录则是多实例各自持有内存会话、负载均衡轮询到不同实例所致,改用 Redis 共享会话存储即可解决,同时建议在入口层开启会话粘滞作为过渡。

总结

用身份感知代理落地零信任,本质是把”网络位置决定权限”换成”身份决定权限”:Nginx 用 auth_request 做统一入口校验,oauth2-proxy 负责 OAuth 认证与最小权限判定,后端服务彻底退到回环地址。三项配置缺一不可——协议头透传避免重定向循环、身份头覆盖防伪造、回环监听做最后兜底。验证清单固定为四条命令:未认证 302、伪造头仍 302、认证后 200、后端端口只在 127.0.0.1。部署完成后,新后台接入只需要复制一段 location 配置,无需再改一行应用代码。