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:
@@ -1,242 +0,0 @@
|
||||
"""
|
||||
Auth - FAM-Core 登录校验(2026-08-22 新增;2026-08-31 接入 auth-hub 统一登录改造)
|
||||
|
||||
背景:NAS :8000(fam-core + FAM-UI)通过 frp 暴露到外网后,需要先登录才能访问。
|
||||
|
||||
- 登录方式:不再自己校验账号密码,全权委托给独立部署的 auth-hub(OAuth2
|
||||
Authorization Code + PKCE / OIDC)。/login 直接 302 跳到 auth-hub 的
|
||||
/authorize,用户在 auth-hub 上登录后带 code 跳回本服务的
|
||||
/api/auth/callback,服务端拿 code 换 token、验完 id_token 签名后建立本地
|
||||
会话——本服务自身不再保存、也不再校验任何密码。
|
||||
- 接入参数从环境变量读(.env 注入,由 start_core.sh source):
|
||||
`AUTH_HUB_ISSUER` / `AUTH_HUB_CLIENT_ID` / `AUTH_HUB_CLIENT_SECRET` /
|
||||
`AUTH_HUB_REDIRECT_URI`(必须跟在 auth-hub 用 manage_clients 注册时登记的
|
||||
redirect_uri 逐字符一致,auth-hub 只做精确匹配)。四个变量任一没配置时直接
|
||||
拒绝登录(fail closed)——跟之前账号密码时代的取舍一致:本服务监听
|
||||
0.0.0.0,没有"配置不全就退回某种默认放行"这种兜底。
|
||||
- 登录态:进程内 token 表 + HttpOnly cookie(fam_session),2 小时有效;
|
||||
重启进程后需重新登录(可接受,见 config/auth 说明)。这一段跟接入 SSO
|
||||
之前完全一样,只是"怎么发这个 cookie"变了,下游(`is_authed()`/白名单/
|
||||
拦截逻辑)不用改。
|
||||
- 拦截策略(app.before_request 全局生效):
|
||||
* 页面路径未登录 -> 302 重定向 /login
|
||||
* /api/* 未登录 -> 401 JSON
|
||||
- 白名单免登录:
|
||||
* /login /api/auth/callback /api/logout /api/auth/check —— 登录流程本身
|
||||
* /health —— 内部健康检查
|
||||
* /api/ss/webhook —— Surveillance Station 推送无法携带登录态
|
||||
* /assets/*、/favicon.ico —— SPA 静态资源
|
||||
"""
|
||||
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, jsonify, make_response, redirect, request
|
||||
|
||||
from .logger import setup_logger
|
||||
|
||||
logger = setup_logger('fam-core.auth')
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
_SESSION_TTL = 2 * 3600 # cookie 有效期 2 小时
|
||||
_PENDING_TTL = 10 * 60 # /login -> /api/auth/callback 之间的等待上限
|
||||
_sessions = {} # token -> 过期时间戳(进程内;重启需重新登录)
|
||||
_pending = {} # state -> {verifier, expires}(进程内;PKCE 用)
|
||||
_warned_unconfigured = False # 只在第一次拒绝登录时打一条警告日志,别刷屏
|
||||
|
||||
_jwks_client = None # jwt.PyJWKClient 单例,内建 JWKS 缓存
|
||||
|
||||
|
||||
def _auth_hub_config():
|
||||
"""auth-hub 接入参数,四个变量都是必需的。"""
|
||||
return {
|
||||
'issuer': os.environ.get('AUTH_HUB_ISSUER', ''),
|
||||
'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', ''),
|
||||
}
|
||||
|
||||
|
||||
def _require_auth_hub_config():
|
||||
"""校验四个环境变量齐全,缺任何一个都拒绝(fail closed)。返回配置 dict 或 None。"""
|
||||
global _warned_unconfigured
|
||||
cfg = _auth_hub_config()
|
||||
if not all(cfg.values()):
|
||||
if not _warned_unconfigured:
|
||||
logger.error(
|
||||
"AUTH_HUB_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI 未配置齐全,"
|
||||
"拒绝所有登录——请在 .env 里设置后重启 fam-core")
|
||||
_warned_unconfigured = True
|
||||
return None
|
||||
return cfg
|
||||
|
||||
|
||||
def _get_jwks_client(jwks_uri: str):
|
||||
global _jwks_client
|
||||
if _jwks_client is None or _jwks_client.uri != jwks_uri:
|
||||
_jwks_client = jwt.PyJWKClient(jwks_uri)
|
||||
return _jwks_client
|
||||
|
||||
|
||||
def is_authed() -> bool:
|
||||
tok = request.cookies.get('fam_session')
|
||||
if not tok:
|
||||
return False
|
||||
exp = _sessions.get(tok)
|
||||
if not exp:
|
||||
return False
|
||||
if time.time() > exp:
|
||||
_sessions.pop(tok, None)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _create_session() -> str:
|
||||
tok = secrets.token_hex(24)
|
||||
_sessions[tok] = time.time() + _SESSION_TTL
|
||||
return tok
|
||||
|
||||
|
||||
def _cleanup_pending():
|
||||
now = time.time()
|
||||
expired = [s for s, v in _pending.items() if v['expires'] < now]
|
||||
for s in expired:
|
||||
_pending.pop(s, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 白名单(免登录)
|
||||
# ---------------------------------------------------------------------------
|
||||
_WHITELIST_EXACT = {
|
||||
'/login', '/api/auth/callback', '/api/logout', '/api/auth/check',
|
||||
'/health', '/favicon.ico',
|
||||
}
|
||||
_WHITELIST_PREFIX = ('/assets/',)
|
||||
|
||||
|
||||
def _is_whitelisted(path: str) -> bool:
|
||||
if path in _WHITELIST_EXACT:
|
||||
return True
|
||||
return any(path.startswith(p) for p in _WHITELIST_PREFIX)
|
||||
|
||||
|
||||
def init_auth(app):
|
||||
"""注册全局登录拦截。需在注册完所有蓝图后调用。"""
|
||||
|
||||
@app.before_request
|
||||
def _guard():
|
||||
path = request.path
|
||||
if _is_whitelisted(path):
|
||||
return None
|
||||
if is_authed():
|
||||
return None
|
||||
if path.startswith('/api/'):
|
||||
return jsonify({"error": "未登录", "code": 401}), 401
|
||||
return redirect('/login')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 端点
|
||||
# ---------------------------------------------------------------------------
|
||||
@auth_bp.route('/login', methods=['GET'])
|
||||
def login_page():
|
||||
"""不再自己渲染登录表单,直接跳到 auth-hub 走 Authorization Code + PKCE。"""
|
||||
if is_authed():
|
||||
return redirect('/')
|
||||
|
||||
cfg = _require_auth_hub_config()
|
||||
if not cfg:
|
||||
return jsonify({"error": "登录服务未配置"}), 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',
|
||||
}
|
||||
return redirect(f"{cfg['issuer']}/authorize?{urlencode(params)}")
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/callback', methods=['GET'])
|
||||
def auth_callback():
|
||||
cfg = _require_auth_hub_config()
|
||||
if not cfg:
|
||||
return jsonify({"error": "登录服务未配置"}), 503
|
||||
|
||||
if request.args.get('error'):
|
||||
logger.warning(f"auth-hub 登录被拒绝: {request.args.get('error')}")
|
||||
return redirect('/login')
|
||||
|
||||
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("auth-hub 回调 state 缺失/过期/重放,拒绝")
|
||||
return redirect('/login')
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{cfg['issuer']}/token",
|
||||
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=(10, 15))
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"auth-hub /token 请求失败: {e}")
|
||||
return redirect('/login')
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"auth-hub /token 拒绝: {resp.status_code} {resp.text[:200]}")
|
||||
return redirect('/login')
|
||||
|
||||
id_token = (resp.json() or {}).get('id_token', '')
|
||||
try:
|
||||
jwks_client = _get_jwks_client(f"{cfg['issuer']}/.well-known/jwks.json")
|
||||
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'])
|
||||
except jwt.PyJWTError as e:
|
||||
logger.warning(f"id_token 验签/校验失败: {e}")
|
||||
return redirect('/login')
|
||||
|
||||
username = claims.get('preferred_username', '')
|
||||
tok = _create_session()
|
||||
resp2 = make_response(redirect('/'))
|
||||
resp2.set_cookie('fam_session', tok, max_age=_SESSION_TTL,
|
||||
httponly=True, samesite='Lax', path='/')
|
||||
logger.info(f"登录成功 username={username}")
|
||||
return resp2
|
||||
|
||||
|
||||
@auth_bp.route('/api/logout', methods=['POST'])
|
||||
def logout():
|
||||
tok = request.cookies.get('fam_session')
|
||||
if tok:
|
||||
_sessions.pop(tok, None)
|
||||
resp = make_response(jsonify({"ok": True}))
|
||||
resp.delete_cookie('fam_session', path='/')
|
||||
return resp
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/check', methods=['GET'])
|
||||
def check():
|
||||
return jsonify({"authed": is_authed()})
|
||||
Reference in New Issue
Block a user