""" 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 = ( '
' '' '