feat(fam-edge): 统一登录从 NAS fam-core 迁入,回调失败不再对跳死循环

登录入口原先跑在 NAS 的 fam-core 里,NAS 或 frp 隧道一挂,
smart-camera.zichuan.xyz/login 直接 502——连登录页都打不开。登录是入口,
不该依赖家里那台机器,整体搬到甲骨文(前端静态文件和 auth-hub 本来就在这台)。

跟旧实现的三处关键差异:
- 换 token / 拉 JWKS 走 AUTH_HUB_INTERNAL_BASE(本机 :5300),不再跨公网 TLS。
  旧链路上 PyJWKClient 用 urllib + 系统 CA(群晖易 CERTIFICATE_VERIFY_FAILED)、
  两机时钟偏差会让 iat 显得来自未来,这两个坑一起消失;iss 校验和浏览器跳转
  仍用公网 issuer
- 会话改无状态 HS256 签名 cookie,服务重启不掉线(旧实现是进程内 token 表)
- 回调失败渲染错误页,不再 302 回 /login。旧实现失败即跳 /login,而 auth-hub
  只要还有会话就立刻再签一个 code 跳回来,两边对跳成死循环,浏览器只报
  「重定向次数过多」,既看不到登录页也看不到原因

fam-core 侧删除 auth.py 与 test_auth.py,鉴权改由甲骨文 Caddy 的 forward_auth
前置拦截(打 /api/auth/verify)。

测试:fam-edge 新增 16 个用例,含「失败分支绝不 302」回归测试、
「服务端走内网但 iss 按公网校验」、「cookie 无状态」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-09-13 08:02:59 +08:00
parent 0238030128
commit dc284f4bf5
6 changed files with 533 additions and 541 deletions

View File

@@ -0,0 +1,285 @@
"""
Auth - 摄像头系统统一登录2026-09-12 从 NAS fam-core 迁到甲骨文 fam-edge
背景:登录流程原先跑在 NAS 的 fam-core 里NAS 一挂或 frp 隧道一断,
`smart-camera.zichuan.xyz/login` 直接 502——连登录页都进不去。登录是入口
不该依赖家里那台机器,所以整体搬到甲骨文:前端静态文件和 auth-hub 本来就在这台,
搬完之后 NAS 只剩纯数据接口。
跟旧实现fam-core/auth.py已删除的三处关键差异
1. **服务端调用 auth-hub 走本机**:换 token 和拉 JWKS 用 `AUTH_HUB_INTERNAL_BASE`
(默认 http://127.0.0.1:5300不再走公网 TLS。旧链路是 NAS 跨公网访问
https://auth.zichuan.xyz踩过两个坑`jwt.PyJWKClient` 用 urllib + 系统 CA
(不像 requests 自带 certifi群晖上很容易 CERTIFICATE_VERIFY_FAILED
以及两台机器时钟偏差会让 id_token 的 iat 看起来"来自未来"
但 `iss` 校验和浏览器跳转仍然用公网 `AUTH_HUB_ISSUER`——id_token 里的 iss
是 auth-hub 自己配的公网地址,浏览器也只能跳公网地址。
2. **登录态改成无状态签名 cookie**HS256 签发,密钥 `FAM_SESSION_SECRET`。
旧实现是进程内 token 表服务一重启所有人被踢下线fam-edge 是视频分析进程,
重启比 fam-core 更频繁,进程内会话在这里完全不成立。
3. **回调失败渲染错误页,不再 302 回 /login**:旧实现每个失败分支都跳 /login
而 auth-hub 侧只要还有会话,/authorize 会立刻再签发一个 code 跳回来,
两边对跳成死循环——浏览器只报"重定向次数过多",既看不到登录页也看不到原因。
拦截不在本模块做Caddy 用 forward_auth 打到 `/api/auth/verify`
校验通过才把 `/api/*` 反代到 NAS 的 fam-core。
"""
import os
import secrets
import time
from base64 import urlsafe_b64encode
from hashlib import sha256
from urllib.parse import urlencode
import jwt
import requests
from flask import Blueprint, Response, jsonify, make_response, redirect, request
# 导入即加载 /opt/fam-edge/.envconfig_loader 模块级 _load_env_file
# 下面所有 os.environ.get 才读得到 AUTH_HUB_* / FAM_SESSION_SECRET。
from . import config_loader # noqa: F401
from .logger import setup_logger
logger = setup_logger('fam-edge.auth')
auth_bp = Blueprint('auth', __name__)
COOKIE_NAME = 'fam_session'
_SESSION_TTL = 2 * 3600 # 登录态有效期 2 小时
_PENDING_TTL = 10 * 60 # /login -> /api/auth/callback 之间的等待上限
_LEEWAY = 60 # 验 id_token 时容忍的跨机器时钟偏差(秒)
_pending = {} # state -> {verifier, expires}进程内PKCE 用)
_warned_unconfigured = False # 只在第一次拒绝登录时打一条警告,别刷屏
_jwks_client = None # jwt.PyJWKClient 单例,内建 JWKS 缓存
def _config():
"""接入参数全部来自环境变量(/opt/fam-edge/.env"""
issuer = os.environ.get('AUTH_HUB_ISSUER', '').rstrip('/')
return {
'issuer': issuer,
# 服务端直连 auth-hub 的地址;没配就退回公网 issuer本地开发用
'internal_base': (os.environ.get('AUTH_HUB_INTERNAL_BASE', '') or issuer).rstrip('/'),
'client_id': os.environ.get('AUTH_HUB_CLIENT_ID', ''),
'client_secret': os.environ.get('AUTH_HUB_CLIENT_SECRET', ''),
'redirect_uri': os.environ.get('AUTH_HUB_REDIRECT_URI', ''),
'session_secret': os.environ.get('FAM_SESSION_SECRET', ''),
}
def _require_config():
"""五个变量缺任何一个都拒绝登录fail closed。返回配置 dict 或 None。
本服务监听 0.0.0.0,没有"配置不全就退回某种默认放行"这种兜底。
"""
global _warned_unconfigured
cfg = _config()
if not all([cfg['issuer'], cfg['client_id'], cfg['client_secret'],
cfg['redirect_uri'], cfg['session_secret']]):
if not _warned_unconfigured:
logger.error(
"AUTH_HUB_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI 或 "
"FAM_SESSION_SECRET 未配置齐全,拒绝所有登录——"
"请在 /opt/fam-edge/.env 里补齐后 systemctl restart fam-edge")
_warned_unconfigured = True
return None
return cfg
def _get_jwks_client(jwks_uri):
global _jwks_client
if _jwks_client is None or _jwks_client.uri != jwks_uri:
_jwks_client = jwt.PyJWKClient(jwks_uri)
return _jwks_client
def _cleanup_pending():
now = time.time()
for state in [s for s, v in _pending.items() if v['expires'] < now]:
_pending.pop(state, None)
def _is_https():
"""Caddy 终止 TLS 后回源是明文 HTTPrequest.is_secure 恒为 False
所以 cookie 的 Secure 标志要看 X-Forwarded-Proto。"""
return request.headers.get('X-Forwarded-Proto', '') == 'https' or request.is_secure
# ---------------------------------------------------------------------------
# 会话(无状态签名 cookie
# ---------------------------------------------------------------------------
def _issue_session(secret, sub, username):
now = int(time.time())
return jwt.encode({'sub': sub, 'username': username,
'iat': now, 'exp': now + _SESSION_TTL},
secret, algorithm='HS256')
def current_user():
"""返回 cookie 里的用户信息dict未登录/过期/签名不对返回 None。"""
cfg = _config()
if not cfg['session_secret']:
return None
token = request.cookies.get(COOKIE_NAME, '')
if not token:
return None
try:
# 这里刻意不给 leewaycookie 是本进程自签自验的,没有跨机器时钟偏差问题,
# 加了只会让每个会话白白多活 60 秒。_LEEWAY 只用于 auth-hub 签发的 id_token。
return jwt.decode(token, cfg['session_secret'], algorithms=['HS256'])
except jwt.PyJWTError:
return None
def is_authed():
return current_user() is not None
def _error_page(reason, status=502):
"""回调失败时给一个看得懂的页面。
这里刻意不做自动跳转auth-hub 有会话时会立刻再发一个 code 回来,
自动跳转等于把用户关进死循环。让用户自己点"重新登录",最多再失败一次。
"""
html = (
'<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width,initial-scale=1">'
'<title>登录失败</title></head>'
'<body style="font-family:system-ui,-apple-system,sans-serif;background:#0b0e14;'
'color:#e6ebf2;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0">'
'<div style="max-width:34rem;padding:2rem">'
'<h1 style="font-size:1.25rem;margin:0 0 .75rem">登录没能完成</h1>'
'<p style="color:#9aa7b8;line-height:1.7;margin:0 0 1.5rem">原因:{reason}</p>'
'<a href="/login" style="display:inline-block;background:#5b8cff;color:#fff;'
'text-decoration:none;padding:.6rem 1.2rem;border-radius:.6rem">重新登录</a>'
'</div></body></html>'
).format(reason=reason)
return Response(html, status=status, mimetype='text/html; charset=utf-8')
# ---------------------------------------------------------------------------
# 端点
# ---------------------------------------------------------------------------
@auth_bp.route('/login', methods=['GET'])
def login():
"""不渲染登录表单,直接跳 auth-hub 走 Authorization Code + PKCE。"""
if is_authed():
return redirect('/')
cfg = _require_config()
if not cfg:
return _error_page('本服务的登录参数没配齐(管理员看 fam-edge 日志)', 503)
_cleanup_pending()
verifier = secrets.token_urlsafe(48)
challenge = urlsafe_b64encode(
sha256(verifier.encode('ascii')).digest()).rstrip(b'=').decode('ascii')
state = secrets.token_hex(24)
_pending[state] = {'verifier': verifier, 'expires': time.time() + _PENDING_TTL}
params = {
'response_type': 'code',
'client_id': cfg['client_id'],
'redirect_uri': cfg['redirect_uri'],
'scope': 'openid profile',
'state': state,
'code_challenge': challenge,
'code_challenge_method': 'S256',
}
# 浏览器要跳的是公网 issuer不是 internal_base
return redirect("{}/authorize?{}".format(cfg['issuer'], urlencode(params)))
@auth_bp.route('/api/auth/callback', methods=['GET'])
def callback():
cfg = _require_config()
if not cfg:
return _error_page('本服务的登录参数没配齐(管理员看 fam-edge 日志)', 503)
if request.args.get('error'):
logger.warning("auth-hub 拒绝授权: {}".format(request.args.get('error')))
return _error_page('auth-hub 拒绝了这次授权:{}'.format(request.args.get('error')), 403)
state = request.args.get('state', '')
code = request.args.get('code', '')
pending = _pending.pop(state, None)
if not pending or pending['expires'] < time.time() or not code:
logger.warning("回调 state 缺失/过期/重放,拒绝")
return _error_page('这次登录请求已过期或被重复使用,请重新登录', 400)
try:
resp = requests.post(
"{}/token".format(cfg['internal_base']),
data={
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': cfg['redirect_uri'],
'client_id': cfg['client_id'],
'client_secret': cfg['client_secret'],
'code_verifier': pending['verifier'],
}, timeout=(5, 15))
except requests.RequestException as e:
logger.error("auth-hub /token 请求失败: {}".format(e))
return _error_page('连不上 auth-hub 的 /token{}'.format(e))
if resp.status_code != 200:
logger.warning("auth-hub /token 拒绝: {} {}".format(resp.status_code, resp.text[:200]))
return _error_page('auth-hub 拒绝换发 tokenHTTP {}'.format(resp.status_code))
id_token = (resp.json() or {}).get('id_token', '')
try:
jwks_client = _get_jwks_client(
"{}/.well-known/jwks.json".format(cfg['internal_base']))
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
claims = jwt.decode(id_token, signing_key.key, algorithms=['RS256'],
audience=cfg['client_id'], issuer=cfg['issuer'],
leeway=_LEEWAY)
except jwt.PyJWTError as e:
logger.warning("id_token 验签/校验失败: {}".format(e))
return _error_page('id_token 校验失败({}'.format(e))
username = claims.get('preferred_username', '')
token = _issue_session(cfg['session_secret'], claims.get('sub', ''), username)
resp2 = make_response(redirect('/'))
resp2.set_cookie(COOKIE_NAME, token, max_age=_SESSION_TTL, httponly=True,
samesite='Lax', secure=_is_https(), path='/')
logger.info("登录成功 username={}".format(username))
return resp2
@auth_bp.route('/api/logout', methods=['POST'])
def logout():
"""只清本地 cookie不动 auth-hub 上的登录态(那是全站 SSO 会话)。
cookie 是无状态签名的,服务端没法单独作废某一张——真要立刻全员下线,
换掉 FAM_SESSION_SECRET 重启即可。
"""
resp = make_response(jsonify({"ok": True}))
resp.delete_cookie(COOKIE_NAME, path='/')
return resp
@auth_bp.route('/api/auth/check', methods=['GET'])
def check():
user = current_user()
return jsonify({"authed": user is not None,
"username": (user or {}).get('username', '')})
@auth_bp.route('/api/auth/verify', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
def verify():
"""给 Caddy forward_auth 用2xx 放行401 拦截。
Caddy 会把原始请求的 cookie 一起带过来401 的响应体会原样回给浏览器,
前端 api.js 看到 401 就会自动跳 /login。
"""
user = current_user()
if not user:
return jsonify({"error": "未登录", "code": 401}), 401
resp = make_response('', 204)
resp.headers['X-Auth-User'] = user.get('username', '')
return resp