diff --git a/PROGRESS.md b/PROGRESS.md index dac90ed..5c9df9a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,17 +1,34 @@ # 项目进度追踪 -> 最后更新: 2026-08-28 +> 最后更新: 2026-08-31 ## 服务运行状态 | 服务 | 节点 | 地址 | 状态 | 验证结果 | |------|------|------|------|---------| -| FAM-Core | NAS | 0.0.0.0:8000 | ✅ 运行中 | health=ok, gunicorn 单 worker;Oracle-Sync + MotionNotifier 轮询 SS 事件推送(游标 DB 续用/失败重试);已部署事件时间轴删除功能;`/api/status`/`/api/ss/status` 500 bug 已修复 | +| FAM-Core | NAS | 0.0.0.0:8000 | ⚠️ 代码已完成,待生产部署 | health=ok, gunicorn 单 worker;Oracle-Sync + MotionNotifier 轮询 SS 事件推送(游标 DB 续用/失败重试);已部署事件时间轴删除功能;`/api/status`/`/api/ss/status` 500 bug 已修复;**登录改接 auth-hub 统一登录(OIDC),本地全链路验证通过,生产 client 注册 + NAS `.env` 配置 + 部署重启尚未执行** | | FAM-Edge | Oracle(新机 129.146.26.249) | 0.0.0.0:5000 | ✅ 运行中 | systemd 守护(fam-edge.service);素材→运动片段分割→只分析片段;问答改为转发 AI-Gateway;队列消费正常;已部署 `/api/oracle/video/delete`;FFmpeg 已补装;新增 DiskGuard 磁盘守护 | | **AI-Gateway** | Oracle | 0.0.0.0:5100 | ✅ 运行中 | systemd 守护(ai-gateway.service),2026-08-23 新增;独立仓库/独立部署;`/health` 正常,端到端问答实测成功(provider=nvidia) | | MariaDB | NAS | 127.0.0.1:3306 | ✅ 运行中 | 10.11.11, 6 张表, utf8mb4 | | Ollama | Oracle | 127.0.0.1:11434 | ✅ 运行中 | qwen2.5:7b,仅智能问答兜底(不参与视觉/融合);**被 AI-Gateway 调用,不再被 FAM-Edge 直接调用** | +## 2026-08-31 FAM-Core 接入 auth-hub 统一登录(OIDC) + +- **背景**:独立的统一登录/SSO 服务 [auth-hub](http://129.146.26.249:3000/ericwyuan/auth-hub)(跟本项目完全独立的仓库/数据库/部署,OAuth2 Authorization Code + PKCE + OIDC)已单独开发验证完成(本地全链路 47 单测通过),目标是自己的几个网站统一接到这一处身份服务,不用每个网站各自维护一份账号密码。FAM-Core 是第一个接入方。 +- **改造范围**(`fam-core/src/fam_core/auth.py` 全量重写,commit 待提交): + - 原来自己校验 `FAM_AUTH_USER`/`FAM_AUTH_PASS` 的账号密码逻辑、内置深色登录表单页全部移除;`GET /login` 改为直接 302 跳转 auth-hub `/authorize`(带 PKCE `code_challenge`/`state`,均存进程内 `_pending` 表,10 分钟过期) + - 新增 `GET /api/auth/callback`:收 auth-hub 回跳的 `code`,服务端到服务端 `POST /token` 换 `id_token`(用 `PyJWT` + `PyJWKClient` 拉 auth-hub JWKS 验 RS256 签名 + `iss`/`aud`),验证通过后种回原有的 `fam_session` HttpOnly cookie(**2 小时有效,机制不变**)——下游 `is_authed()`/`init_auth()` 全局拦截逻辑完全没动 + - 接入参数 `AUTH_HUB_ISSUER`/`AUTH_HUB_CLIENT_ID`/`AUTH_HUB_CLIENT_SECRET`/`AUTH_HUB_REDIRECT_URI` 走环境变量(NAS `.env`,沿用 `FAM_AUTH_*` 时代"未配置齐全直接拒绝所有登录"的 fail-closed 取舍),`AUTH_HUB_REDIRECT_URI` 必须与 auth-hub 端 `manage_clients create` 登记的 redirect_uri 逐字符一致 + - `requirements.txt` 新增 `PyJWT>=2.8.0`、`cryptography>=42.0.0` + - **访问控制取舍(用户明确决策)**:不在 fam-core 侧加用户名白名单——任何在 auth-hub 上(自助注册 + 管理员审批后)拥有账号的人登录后都能访问本系统;不保留 `FAM_AUTH_USER`/`FAM_AUTH_PASS` 作为备用登录方式,SSO 是唯一入口 + - 测试:`tests/test_auth.py` 全量重写(PKCE 生成、state 校验、token 交换成功/失败、id_token 验签失败、白名单、`before_request` 拦截),fam-core 全量 39/39 通过 +- **端到端验证(本地)**:本地起了一份 auth-hub 开发实例(:5300)+ 一个仅含 `auth_bp` 的最小 Flask 壳子(模拟 fam-core,跳过 MariaDB 依赖),用真实浏览器走完整 Authorization Code + PKCE 闭环——`/` 未登录 302 `/login` → auth-hub 登录页 → 登录成功回跳 `/api/auth/callback` → 换 token + 验签 → 种 cookie → 落地首页;`/api/auth/check` 确认 cookie 生效;`/api/logout` 确认清会话。验证完把临时创建的 OAuth client 和测试账号都从 auth-hub 本地库删掉了。 +- **未完成(需要生产部署,本次会话未执行)**: + 1. 在 Oracle 生产 auth-hub(`/opt/auth-hub`,:5300)上用 `python -m auth_hub.manage_clients create "FAM-Core" "http://129.146.26.249/api/auth/callback"` 注册正式 client,拿到生产 `client_id`/`client_secret` + 2. NAS `.env` 补上 `AUTH_HUB_ISSUER=http://129.146.26.249:5300`、`AUTH_HUB_CLIENT_ID`、`AUTH_HUB_CLIENT_SECRET`、`AUTH_HUB_REDIRECT_URI=http://129.146.26.249/api/auth/callback` + 3. 部署新代码到 NAS(tar 管道,见 `docs/DEPLOY.md` §3)并 `bash start_core.sh` 重启 + 4. 生产环境浏览器实测一遍完整登录闭环 + ## 2026-08-28 Oracle 迁移故障排查:FFmpeg 缺失 + 磁盘写满死循环 + DiskGuard - **触发**:用户反馈事件时间轴历史图片丢失、"谷歌同步是不是有问题"。逐层排查,发现的是三个叠在一起的独立问题,不是一个: diff --git a/README.md b/README.md index 30b8e6d..ddf4a41 100644 --- a/README.md +++ b/README.md @@ -320,8 +320,9 @@ chat_history 独立表(问答上下文摘要留存) | 端点 | 方法 | 说明 | |------|------|------| | `/health` | GET | 服务健康(**免登录**) | -| `/api/login` | POST | 登录:`{"username","password"}` → 校验 `FAM_AUTH_USER`/`FAM_AUTH_PASS`(NAS `.env` 配置,无硬编码默认值,未配置则拒绝所有登录)→ 种 HttpOnly cookie `fam_session`(2 小时) | -| `/api/logout` | POST | 退出登录(清 cookie) | +| `/login` | GET | 登录入口:302 跳转 auth-hub `/authorize`(Authorization Code + PKCE,**免登录**) | +| `/api/auth/callback` | GET | auth-hub 登录回跳:拿 `code` 换 token、验 `id_token` 签名后种 HttpOnly cookie `fam_session`(2 小时,**免登录**) | +| `/api/logout` | POST | 退出登录(清本地 cookie,不影响 auth-hub 上的登录态) | | `/api/auth/check` | GET | 登录态检查:`{"authed": true\|false}`(**免登录**) | | `/api/status` | GET | Oracle-Sync + MotionNotifier 状态(sync running/cursor;motion poll_enabled/pushed_total/heartbeat) | | `/api/chat/ask` | POST | 用户问答:`{"question","queried_person","queried_date"}` → `{"answer","context_summary","chat_id"}`(上下文来自 sync_events) | @@ -539,7 +540,7 @@ task_id=289(30s 测试片段)全链路打通:推送 5.7MB → Edge 分析 | 组件 | 节点 | 路径 | 启动 | |------|------|------|------| -| FAM-Core | NAS | `/volume1/web/sentinel-home-ai/fam-core/` | `bash start_core.sh`(gunicorn -w 1 :8000,source .env 注入 DSM_*/ORACLE_SYNC_TOKEN/FAM_AUTH_*) | +| FAM-Core | NAS | `/volume1/web/sentinel-home-ai/fam-core/` | `bash start_core.sh`(gunicorn -w 1 :8000,source .env 注入 DSM_*/ORACLE_SYNC_TOKEN/AUTH_HUB_*) | | FAM-UI | NAS | `/volume1/web/sentinel-home-ai/fam-ui/dist/` | Vue3 构建产物,由 FAM-Core `static_app.py` 托管(无需独立进程);本地改代码后 `npm run build` 并 tar 部署 dist | | FAM-Edge | Oracle | `/opt/fam-edge/` | **systemd `fam-edge.service` 守护**(Restart=always);部署代码后 `sudo systemctl restart fam-edge`(勿手动 setsid,会端口冲突) | | **AI-Gateway** | Oracle | `/opt/ai-gateway/` | **systemd `ai-gateway.service` 守护**(Restart=always),独立 venv(Python 3.8);gunicorn 绑定 `0.0.0.0:5100`(对外直接开放,非仅本机);部署代码后 `sudo systemctl restart ai-gateway` | @@ -547,9 +548,9 @@ task_id=289(30s 测试片段)全链路打通:推送 5.7MB → Edge 分析 > **外网访问(frp 内网穿透)**:NAS 跑 `frpc`(`/etc/frp/frpc.toml`,S99frpc.sh 守护),映射到云服务器 `129.146.26.249`(frps :7000): > - `3000` → NAS Gitea、`8500` → NAS WordPress(8088)、**`8000` → NAS FAM-Core(本系统)** -> - 前端入口 `http://129.146.26.249/`(Caddy 托管 FAM-UI,/api 经 frp 隧道反代回 NAS FAM-Core :8000),**需登录**(见 §5.2 `/api/login`) +> - 前端入口 `http://129.146.26.249/`(Caddy 托管 FAM-UI,/api 经 frp 隧道反代回 NAS FAM-Core :8000),**需登录**(见 §5.2 `/login`) > -> **登录校验(2026-08-22 新增,2026-08-23 改为 fail-closed)**:FAM-Core 全站拦截(`auth.py`)——页面未登录 302 `/login`(内置深色登录页,SPA 零改动),`/api/*` 未登录 401;凭据 `FAM_AUTH_USER`/`FAM_AUTH_PASS`(NAS `.env` 配置,**无硬编码默认值**——这两个变量跟 NAS SSH 密码是同一个值,公网入口不能有"没配置就退回已知密码"的兜底,`.env` 没配好这两项时直接拒绝所有登录);白名单免登录:`/login` `/api/login` `/api/logout` `/api/auth/check` `/health` `/assets/*`(`/api/ss/webhook` 已随端点一起移除,2026-08-25)。登录态为进程内 token + HttpOnly cookie(**2 小时**),过期或重启 fam-core 需重新登录。 +> **登录校验(2026-08-22 新增,2026-08-23 改为 fail-closed,2026-08-31 接入 auth-hub 统一登录)**:FAM-Core 不再自己保存/校验密码,全权委托给独立部署的 [auth-hub](http://129.146.26.249:5300)(OAuth2 Authorization Code + PKCE / OIDC,同一 Oracle 主机 :5300,与本系统完全独立部署)——`/login` 302 跳到 auth-hub `/authorize`,登录后带 `code` 跳回本服务 `/api/auth/callback`,服务端换 token、验完 `id_token` 签名(JWKS)后种本地会话 cookie。全站拦截仍在 `auth.py`:页面未登录 302 `/login`,`/api/*` 未登录 401;接入参数 `AUTH_HUB_ISSUER`/`AUTH_HUB_CLIENT_ID`/`AUTH_HUB_CLIENT_SECRET`/`AUTH_HUB_REDIRECT_URI`(NAS `.env` 配置,**无硬编码默认值**,任一没配置直接拒绝所有登录,fail closed);`AUTH_HUB_REDIRECT_URI` 必须与 auth-hub 用 `manage_clients create` 登记的 redirect_uri 逐字符一致(auth-hub 只做精确匹配,不做前缀/子串匹配)。白名单免登录:`/login` `/api/auth/callback` `/api/logout` `/api/auth/check` `/health` `/assets/*`(`/api/ss/webhook` 已随端点一起移除,2026-08-25)。本地会话仍是进程内 token + HttpOnly cookie(**2 小时**),过期或重启 fam-core 需重新走一遍 auth-hub 登录;auth-hub 侧注册是任何人都能自助注册(管理员审批后才能登录),**未对 fam-core 侧再加用户名白名单**——任何在 auth-hub 上审批通过的账号登录后都能访问本系统,这是有意识的取舍(自用场景,见项目记忆)。 > Oracle 部署方式:本地 git 提交 push Gitea → tar 管道到 `/opt/fam-edge`(`--strip-components=1` 解临时目录再 cp,避免动 data/venv/gdrive_videos)。**AI-Gateway 是独立 git 仓库**(http://192.168.50.64:3000/ericwyuan/ai-gateway,见 §10.4),同样 tar 管道部署到 `/opt/ai-gateway`,互不影响。 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 3a63377..db550da 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -21,7 +21,17 @@ pip install -r requirements.txt cp config/config.yaml.example config/config.yaml # 编辑 config.yaml:数据库密码、oracle_sync.base_url/token、motion_notifier(DSM 凭据走 .env) -# 仓库根 .env 提供 DSM_ACCOUNT/DSM_PASSWORD/ORACLE_SYNC_TOKEN(start_core.sh 会 source) +# 仓库根 .env 提供 DSM_ACCOUNT/DSM_PASSWORD/ORACLE_SYNC_TOKEN/AUTH_HUB_*(start_core.sh 会 source) + +# 登录改接 auth-hub 统一登录(2026-08-31),先在 auth-hub 侧注册本站点 client: +# ssh 到 auth-hub 所在 Oracle 主机,cd /opt/auth-hub +# python -m auth_hub.manage_clients create "FAM-Core" "http://129.146.26.249/api/auth/callback" +# 输出的 client_id/client_secret 明文只显示这一次,立即抄进 .env +# 仓库根 .env 补齐四项(缺任一项 fam-core 直接拒绝所有登录,fail closed): +# AUTH_HUB_ISSUER=http://129.146.26.249:5300 +# AUTH_HUB_CLIENT_ID=<上一步输出> +# AUTH_HUB_CLIENT_SECRET=<上一步输出> +# AUTH_HUB_REDIRECT_URI=http://129.146.26.249/api/auth/callback # 必须跟注册时的 redirect_uri 逐字符一致 # 启动(含 Oracle-Sync + MotionNotifier 轮询) cd /volume1/web/sentinel-home-ai && bash start_core.sh @@ -84,7 +94,7 @@ tar czf - --exclude=venv --exclude=__pycache__ --exclude=data --exclude=gdrive_v | NAS MariaDB | `mysql -u root -p -e "SHOW DATABASES"` | 包含 sentinel_home_ai | | NAS FAM-Core | `curl http://localhost:8000/health` | `{"status":"ok"}` | | NAS 运动监测 | `curl http://localhost:8000/api/ss/status` | `poll_enabled: true, running: true` | -| NAS FAM-UI | 浏览器访问 `http://192.168.50.64:8000` | Vue3 SPA(时间轴/人物管理/统计) | +| NAS FAM-UI | 浏览器访问 `http://192.168.50.64:8000` | 未登录被 302 到 auth-hub 登录页;登录(审批过的 auth-hub 账号)后跳回展示 Vue3 SPA(时间轴/人物管理/统计) | | Oracle FAM-Edge | `curl http://localhost:5000/health` | `{"status":"ok","queue_alive":true}` | | Oracle 运动事件 | `sqlite3 /opt/fam-edge/data/oracle.db "SELECT COUNT(*) FROM ss_motion_events"` | >0(NAS 推送) | | Oracle 运动片段 | `ls /opt/fam-edge/motion_clips/` | 存在 motion_*.mp4(素材分割产物) | diff --git a/fam-core/requirements.txt b/fam-core/requirements.txt index 6e2df22..78ee41a 100644 --- a/fam-core/requirements.txt +++ b/fam-core/requirements.txt @@ -3,3 +3,5 @@ gunicorn>=21.2.0 PyMySQL>=1.1.0 PyYAML>=6.0 requests>=2.31.0 +PyJWT>=2.8.0 +cryptography>=42.0.0 diff --git a/fam-core/src/fam_core/auth.py b/fam-core/src/fam_core/auth.py index 827c180..6318372 100644 --- a/fam-core/src/fam_core/auth.py +++ b/fam-core/src/fam_core/auth.py @@ -1,20 +1,28 @@ """ -Auth - FAM-Core 登录校验(2026-08-22 新增) +Auth - FAM-Core 登录校验(2026-08-22 新增;2026-08-31 接入 auth-hub 统一登录改造) 背景:NAS :8000(fam-core + FAM-UI)通过 frp 暴露到外网后,需要先登录才能访问。 -- 账号密码:环境变量 FAM_AUTH_USER / FAM_AUTH_PASS,由 NAS 的 start_core.sh - source .env 注入。2026-08-23 起不再提供硬编码默认值——这两个环境变量跟 - NAS SSH 密码是同一个值,公网入口用同一串密码兜底、还写死在代码里是双重 - 风险;.env 没配置好这两个变量时,直接拒绝所有登录(fail closed),而不是 - 悄悄退回一个大家都知道的密码。 +- 登录方式:不再自己校验账号密码,全权委托给独立部署的 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 说明)。 + 重启进程后需重新登录(可接受,见 config/auth 说明)。这一段跟接入 SSO + 之前完全一样,只是"怎么发这个 cookie"变了,下游(`is_authed()`/白名单/ + 拦截逻辑)不用改。 - 拦截策略(app.before_request 全局生效): * 页面路径未登录 -> 302 重定向 /login * /api/* 未登录 -> 401 JSON - 白名单免登录: - * /login /api/login /api/logout /api/auth/check —— 登录流程本身 + * /login /api/auth/callback /api/logout /api/auth/check —— 登录流程本身 * /health —— 内部健康检查 * /api/ss/webhook —— Surveillance Station 推送无法携带登录态 * /assets/*、/favicon.ico —— SPA 静态资源 @@ -22,9 +30,13 @@ Auth - FAM-Core 登录校验(2026-08-22 新增) import os import secrets import time +from base64 import urlsafe_b64encode +from hashlib import sha256 +from urllib.parse import urlencode -from flask import (Blueprint, Response, jsonify, make_response, - redirect, request) +import jwt +import requests +from flask import Blueprint, jsonify, make_response, redirect, request from .logger import setup_logger @@ -33,25 +45,43 @@ 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 _check_credential(username: str, password: str) -> bool: - """账号密码校验。凭据必须从环境变量读(.env 注入),不提供硬编码默认值—— - 这两个变量跟 NAS SSH 密码是同一个值,公网入口不能有"没配置就退回已知密码" - 这种兜底,宁可直接拒绝所有登录(fail closed),等运维发现并配置好 .env。""" + +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 - user = os.environ.get('FAM_AUTH_USER', '') - pwd = os.environ.get('FAM_AUTH_PASS', '') - if not user or not pwd: + cfg = _auth_hub_config() + if not all(cfg.values()): if not _warned_unconfigured: logger.error( - "FAM_AUTH_USER/FAM_AUTH_PASS 未配置,拒绝所有登录——" - "请在 .env 里设置这两个环境变量后重启 fam-core") + "AUTH_HUB_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI 未配置齐全," + "拒绝所有登录——请在 .env 里设置后重启 fam-core") _warned_unconfigured = True - return False - return (username or '') == user and (password or '') == pwd + 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: @@ -67,11 +97,24 @@ def is_authed() -> bool: 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/login', '/api/logout', '/api/auth/check', + '/login', '/api/auth/callback', '/api/logout', '/api/auth/check', '/health', '/favicon.ico', } _WHITELIST_PREFIX = ('/assets/',) @@ -103,24 +146,85 @@ def init_auth(app): # --------------------------------------------------------------------------- @auth_bp.route('/login', methods=['GET']) def login_page(): + """不再自己渲染登录表单,直接跳到 auth-hub 走 Authorization Code + PKCE。""" if is_authed(): return redirect('/') - return Response(_LOGIN_HTML, mimetype='text/html') + + 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/login', methods=['POST']) -def login(): - data = request.get_json(silent=True) or request.form - username = (data.get('username') or '').strip() - password = data.get('password') or '' - if not _check_credential(username, password): - return jsonify({"error": "账号或密码错误"}), 401 - tok = secrets.token_hex(24) - _sessions[tok] = time.time() + _SESSION_TTL - resp = make_response(jsonify({"ok": True})) - resp.set_cookie('fam_session', tok, max_age=_SESSION_TTL, - httponly=True, samesite='Lax', path='/') - return resp +@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']) @@ -136,86 +240,3 @@ def logout(): @auth_bp.route('/api/auth/check', methods=['GET']) def check(): return jsonify({"authed": is_authed()}) - - -# --------------------------------------------------------------------------- -# 登录页(内嵌 HTML,深色风格与 fam-ui 一致;前端 SPA 无需改动) -# --------------------------------------------------------------------------- -_LOGIN_HTML = """ - -
- - -