""" 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/.env(config_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 后回源是明文 HTTP,request.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: # 这里刻意不给 leeway:cookie 是本进程自签自验的,没有跨机器时钟偏差问题, # 加了只会让每个会话白白多活 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 = ( '' '' '登录失败' '' '
' '

登录没能完成

' '

原因:{reason}

' '重新登录' '
' ).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 拒绝换发 token(HTTP {})'.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