Compare commits
3 Commits
988c63a8f9
...
feb096ff3d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
feb096ff3d | ||
|
|
5e1230d9b0 | ||
|
|
8120d2ae6a |
21
PROGRESS.md
21
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)注册正式 client `FAM-Core`(client_id/secret 已生成,明文只显示过一次,未写入仓库,需要的话找 auth-hub 管理后台或 `manage_clients list` 核对 client_id)
|
||||
2. ⏳ **NAS `.env` 待补**(本次会话没有 NAS SSH 免密权限,未执行):`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
|
||||
|
||||
- **触发**:用户反馈事件时间轴历史图片丢失、"谷歌同步是不是有问题"。逐层排查,发现的是三个叠在一起的独立问题,不是一个:
|
||||
|
||||
21
README.md
21
README.md
@@ -198,9 +198,9 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级:Gemini → NVIDIA N
|
||||
| Model-Adapters | `model_adapters/` | `BaseModelAdapter.analyze_video(video_path, known_members_context, event_start_time)`;Gemini(Files API)/ NVIDIA(整视频 `video_url`)——**只有视觉分析用,2026-08-23 起不再含 Ollama/文字模型**,问答模型完全移交 AI-Gateway |
|
||||
| QA-Proxy | `qa.py` | **2026-08-23 重写为 HTTP 转发客户端**(原来自己遍历适配器 `chat()` 做 NVIDIA→Gemini→Ollama 三级降级的逻辑已整个搬到 AI-Gateway):调 AI-Gateway `/v1/chat/completions`,把 OpenAI 兼容响应翻译回原有 `run_qa`/`run_qa_stream` 契约,`api_gateway.py` 和 FAM-Core 调用方零改动 |
|
||||
|
||||
### 3.3 FAM-UI(NAS 端)
|
||||
### 3.3 FAM-UI(云服务器端,Caddy 托管)
|
||||
|
||||
Vue3 + Vite + Tailwind SPA(`fam-ui/src/views/*.vue`,构建产物 `fam-ui/dist/` 由 FAM-Core `static_app.py` 托管,Vue Router history 模式),页面均读本地同步镜像:
|
||||
Vue3 + Vite + Tailwind SPA(`fam-ui/src/views/*.vue`,构建产物 `fam-ui/dist/` 部署到甲骨文云服务器由 Caddy 静态托管,Vue Router history 模式;`/api/*` 经 frp 隧道反代回 NAS FAM-Core),页面均读本地同步镜像:
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
@@ -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,24 +540,24 @@ 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-UI | NAS | `/volume1/web/sentinel-home-ai/fam-ui/dist/` | Vue3 构建产物,由 FAM-Core `static_app.py` 托管(无需独立进程);本地改代码后 `npm run build` 并 tar 部署 dist |
|
||||
| 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 | 云服务器(甲骨文,129.146.26.249) | `/var/www/fam-ui/` | Vue3 构建产物,由 Caddy :80 静态托管(无需独立进程);本地改代码后 `npm run build`,`dist/` rsync/tar 到云服务器 |
|
||||
| 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` |
|
||||
| Ollama | Oracle | systemd 托管 | 环境变量 `OLLAMA_KEEP_ALIVE=-1`;**被 AI-Gateway 调用,不再被 FAM-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`,互不影响。
|
||||
|
||||
### 8.2 依赖
|
||||
|
||||
- **FAM-Core(NAS, Python 3.10 venv)**:Flask, Gunicorn, **PyMySQL**(45KB 纯 Python 替代 19MB mysql-connector), PyYAML, requests
|
||||
- **FAM-UI(NAS, Node)**:Vue3 + Vite + Tailwind(`fam-ui/`,构建产物 dist 不入库);**不再依赖 Streamlit**
|
||||
- **FAM-UI(云服务器, Node)**:Vue3 + Vite + Tailwind(`fam-ui/`,构建产物 dist 不入库);**不再依赖 Streamlit**
|
||||
- **FAM-Edge(Oracle, Python venv)**:Flask, Gunicorn, requests, PyYAML, opencv-python, numpy, **openai**(NVIDIA NIM 兼容 OpenAI API 规范,仅视觉分析用);Gemini 用 requests 直调 REST;**问答不再直接调模型,只用 requests 转发到 AI-Gateway**
|
||||
- **AI-Gateway(Oracle, Python 3.8 venv,独立部署单元)**:Flask, Gunicorn, requests, PyYAML, **openai**(NVIDIA 问答模型用)
|
||||
- **系统级**:FFmpeg(两端;Oracle 端用于运动片段分割 + 帧图/头像)、Ollama + qwen2.5:7b(Oracle,**被 AI-Gateway 调用**)、MariaDB 10.11(NAS)、rclone(Oracle,同步 Google Drive 素材)
|
||||
@@ -732,7 +733,7 @@ cd fam-ui && npm run build # 产物 fam-ui/dist/
|
||||
|
||||
## 10. 服务器访问信息
|
||||
|
||||
### 10.1 Synology NAS(FAM-Core + FAM-UI + MariaDB)
|
||||
### 10.1 Synology NAS(FAM-Core + MariaDB)
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 部署指南(v3 运动事件驱动架构,2026-08-22 同步)
|
||||
|
||||
## 1. NAS 端部署 (FAM-Core + FAM-UI + MariaDB)
|
||||
## 1. NAS 端部署 (FAM-Core + MariaDB;FAM-UI 见 §1.3,部署在云服务器)
|
||||
|
||||
### 1.1 MariaDB
|
||||
```bash
|
||||
@@ -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(时间轴/人物管理/统计) |
|
||||
| 云服务器 FAM-UI | 浏览器访问 `http://129.146.26.249/`(Caddy 托管) | 未登录被 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(素材分割产物) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = """<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>家庭监控 · 登录</title>
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body {
|
||||
min-height:100vh; display:flex; align-items:center; justify-content:center;
|
||||
background: radial-gradient(1200px 600px at 20% -10%, #1e293b 0%, #0b1120 55%, #0b1120 100%);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color:#e2e8f0;
|
||||
}
|
||||
.card {
|
||||
width:360px; padding:40px 36px 32px; border-radius:16px;
|
||||
background: rgba(15,23,42,.75); border:1px solid rgba(148,163,184,.18);
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,.45);
|
||||
}
|
||||
.logo { font-size:26px; font-weight:700; letter-spacing:.5px; margin-bottom:6px; }
|
||||
.logo span { color:#38bdf8; }
|
||||
.sub { font-size:13px; color:#94a3b8; margin-bottom:28px; }
|
||||
label { display:block; font-size:12px; color:#94a3b8; margin:14px 0 6px; }
|
||||
input {
|
||||
width:100%; padding:10px 12px; border-radius:8px; font-size:14px; color:#e2e8f0;
|
||||
background:#0f172a; border:1px solid #334155; outline:none; transition:border .15s;
|
||||
}
|
||||
input:focus { border-color:#38bdf8; }
|
||||
button {
|
||||
width:100%; margin-top:24px; padding:11px; border:none; border-radius:8px;
|
||||
font-size:14px; font-weight:600; color:#0b1120; background:#38bdf8; cursor:pointer;
|
||||
transition:background .15s, transform .05s;
|
||||
}
|
||||
button:hover { background:#0ea5e9; }
|
||||
button:active { transform: scale(.98); }
|
||||
.err { margin-top:14px; font-size:13px; color:#f87171; min-height:18px; text-align:center; }
|
||||
.foot { margin-top:22px; text-align:center; font-size:11px; color:#475569; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">家庭监控 <span>FAM</span></div>
|
||||
<div class="sub">请输入账号密码登录后访问</div>
|
||||
<form id="f">
|
||||
<label for="u">账号</label>
|
||||
<input id="u" name="username" autocomplete="username" autofocus required>
|
||||
<label for="p">密码</label>
|
||||
<input id="p" name="password" type="password" autocomplete="current-password" required>
|
||||
<button type="submit">登 录</button>
|
||||
</form>
|
||||
<div class="err" id="err"></div>
|
||||
<div class="foot">Sentinel Home AI</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('f').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const err = document.getElementById('err');
|
||||
err.textContent = '';
|
||||
try {
|
||||
const r = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('u').value.trim(),
|
||||
password: document.getElementById('p').value
|
||||
})
|
||||
});
|
||||
if (r.ok) { location.href = '/'; return; }
|
||||
const d = await r.json().catch(() => ({}));
|
||||
err.textContent = d.error || ('登录失败(' + r.status + ')');
|
||||
} catch (ex) {
|
||||
err.textContent = '网络错误,请重试';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@@ -1,51 +1,61 @@
|
||||
import os
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
from fam_core import auth
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env_and_sessions(monkeypatch):
|
||||
"""每个用例前清掉环境变量和进程内 session 表,用例之间不互相污染。"""
|
||||
monkeypatch.delenv('FAM_AUTH_USER', raising=False)
|
||||
monkeypatch.delenv('FAM_AUTH_PASS', raising=False)
|
||||
def _clean_env_and_state(monkeypatch):
|
||||
"""每个用例前清掉环境变量和进程内状态,用例之间不互相污染。"""
|
||||
for var in ('AUTH_HUB_ISSUER', 'AUTH_HUB_CLIENT_ID',
|
||||
'AUTH_HUB_CLIENT_SECRET', 'AUTH_HUB_REDIRECT_URI'):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
auth._sessions.clear()
|
||||
auth._pending.clear()
|
||||
auth._warned_unconfigured = False
|
||||
auth._jwks_client = None
|
||||
yield
|
||||
auth._sessions.clear()
|
||||
auth._pending.clear()
|
||||
|
||||
|
||||
def test_check_credential_fails_closed_when_unconfigured():
|
||||
"""核心诉求: .env 没配置 FAM_AUTH_USER/FAM_AUTH_PASS 时必须拒绝所有登录,
|
||||
不能退回任何硬编码默认账号密码(这两个变量跟 NAS SSH 密码是同一个值,公网
|
||||
入口不能有"没配置就用已知密码兜底"这种行为)。"""
|
||||
assert auth._check_credential('ericwyuan', 'iLoveJava5') is False
|
||||
assert auth._check_credential('anything', 'anything') is False
|
||||
assert auth._check_credential('', '') is False
|
||||
def _set_auth_hub_env(monkeypatch):
|
||||
monkeypatch.setenv('AUTH_HUB_ISSUER', 'http://auth-hub.example')
|
||||
monkeypatch.setenv('AUTH_HUB_CLIENT_ID', 'fam-core')
|
||||
monkeypatch.setenv('AUTH_HUB_CLIENT_SECRET', 'sekret')
|
||||
monkeypatch.setenv('AUTH_HUB_REDIRECT_URI', 'http://fam.example/api/auth/callback')
|
||||
|
||||
|
||||
def test_check_credential_succeeds_with_matching_configured_values(monkeypatch):
|
||||
monkeypatch.setenv('FAM_AUTH_USER', 'testuser')
|
||||
monkeypatch.setenv('FAM_AUTH_PASS', 'testpass')
|
||||
assert auth._check_credential('testuser', 'testpass') is True
|
||||
# ---------------------------------------------------------------------------
|
||||
# 配置齐全性校验(fail closed)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_require_auth_hub_config_fails_closed_when_unconfigured():
|
||||
assert auth._require_auth_hub_config() is None
|
||||
|
||||
|
||||
def test_check_credential_rejects_wrong_password_when_configured(monkeypatch):
|
||||
monkeypatch.setenv('FAM_AUTH_USER', 'testuser')
|
||||
monkeypatch.setenv('FAM_AUTH_PASS', 'testpass')
|
||||
assert auth._check_credential('testuser', 'wrongpass') is False
|
||||
assert auth._check_credential('wronguser', 'testpass') is False
|
||||
def test_require_auth_hub_config_fails_closed_when_partially_configured(monkeypatch):
|
||||
monkeypatch.setenv('AUTH_HUB_ISSUER', 'http://auth-hub.example')
|
||||
monkeypatch.setenv('AUTH_HUB_CLIENT_ID', 'fam-core')
|
||||
assert auth._require_auth_hub_config() is None
|
||||
|
||||
|
||||
def test_check_credential_fails_closed_when_only_one_var_set(monkeypatch):
|
||||
"""只配了一半(比如账号忘配密码)也要 fail closed,不能退化成"密码随便"。"""
|
||||
monkeypatch.setenv('FAM_AUTH_USER', 'testuser')
|
||||
assert auth._check_credential('testuser', '') is False
|
||||
assert auth._check_credential('testuser', 'anything') is False
|
||||
def test_require_auth_hub_config_succeeds_when_fully_configured(monkeypatch):
|
||||
_set_auth_hub_env(monkeypatch)
|
||||
cfg = auth._require_auth_hub_config()
|
||||
assert cfg == {
|
||||
'issuer': 'http://auth-hub.example',
|
||||
'client_id': 'fam-core',
|
||||
'client_secret': 'sekret',
|
||||
'redirect_uri': 'http://fam.example/api/auth/callback',
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 本地会话(fam_session cookie)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_session_ttl_is_two_hours():
|
||||
assert auth._SESSION_TTL == 2 * 3600
|
||||
|
||||
@@ -91,8 +101,18 @@ def test_is_authed_false_without_cookie():
|
||||
auth.request = monkeypatch_request
|
||||
|
||||
|
||||
def test_create_session_registers_token_with_ttl():
|
||||
before = time.time()
|
||||
tok = auth._create_session()
|
||||
assert tok in auth._sessions
|
||||
assert auth._sessions[tok] >= before + auth._SESSION_TTL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 白名单
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_whitelist_exact_paths():
|
||||
for path in ('/login', '/api/login', '/api/logout', '/api/auth/check',
|
||||
for path in ('/login', '/api/auth/callback', '/api/logout', '/api/auth/check',
|
||||
'/health', '/favicon.ico'):
|
||||
assert auth._is_whitelisted(path) is True
|
||||
|
||||
@@ -104,3 +124,176 @@ def test_whitelist_assets_prefix():
|
||||
def test_whitelist_rejects_protected_paths():
|
||||
for path in ('/', '/api/ui/people', '/api/chat/ask', '/api/ss/status'):
|
||||
assert auth._is_whitelisted(path) is False
|
||||
|
||||
|
||||
def test_whitelist_no_longer_includes_removed_password_login_endpoint():
|
||||
"""账号密码登录接口已随 SSO 改造下线,不应再出现在白名单里。"""
|
||||
assert auth._is_whitelisted('/api/login') is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 通过 Flask test_client 打完整流程
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def app():
|
||||
from flask import Flask, jsonify as _jsonify
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(auth.auth_bp)
|
||||
|
||||
@app.route('/some-protected-page')
|
||||
def _protected_page():
|
||||
return 'ok'
|
||||
|
||||
@app.route('/api/protected')
|
||||
def _protected_api():
|
||||
return _jsonify({"ok": True})
|
||||
|
||||
auth.init_auth(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def test_login_redirects_to_auth_hub_authorize_with_pkce(client, monkeypatch):
|
||||
_set_auth_hub_env(monkeypatch)
|
||||
resp = client.get('/login')
|
||||
assert resp.status_code == 302
|
||||
location = resp.headers['Location']
|
||||
assert location.startswith('http://auth-hub.example/authorize?')
|
||||
assert 'code_challenge=' in location
|
||||
assert 'code_challenge_method=S256' in location
|
||||
assert 'client_id=fam-core' in location
|
||||
assert len(auth._pending) == 1
|
||||
|
||||
|
||||
def test_login_rejects_when_unconfigured(client):
|
||||
resp = client.get('/login')
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
def test_login_redirects_home_when_already_authed(client, monkeypatch):
|
||||
_set_auth_hub_env(monkeypatch)
|
||||
tok = auth._create_session()
|
||||
client.set_cookie('fam_session', tok)
|
||||
resp = client.get('/login')
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers['Location'] == '/'
|
||||
|
||||
|
||||
def test_callback_rejects_unknown_or_expired_state(client, monkeypatch):
|
||||
_set_auth_hub_env(monkeypatch)
|
||||
resp = client.get('/api/auth/callback?state=nope&code=abc')
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers['Location'] == '/login'
|
||||
assert 'fam_session' not in resp.headers.get('Set-Cookie', '')
|
||||
|
||||
|
||||
def test_callback_rejects_idp_error(client, monkeypatch):
|
||||
_set_auth_hub_env(monkeypatch)
|
||||
resp = client.get('/api/auth/callback?error=access_denied&state=x')
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers['Location'] == '/login'
|
||||
|
||||
|
||||
def test_callback_exchanges_code_and_sets_session_cookie(client, monkeypatch):
|
||||
_set_auth_hub_env(monkeypatch)
|
||||
auth._pending['thestate'] = {'verifier': 'v', 'expires': time.time() + 600}
|
||||
|
||||
id_token = jwt.encode(
|
||||
{'iss': 'http://auth-hub.example', 'aud': 'fam-core', 'sub': '1',
|
||||
'preferred_username': 'ericwyuan', 'exp': time.time() + 300, 'iat': time.time()},
|
||||
'unused', algorithm='HS256') # 签名算法在这里不重要,被下面的 mock 绕过验签
|
||||
|
||||
class _FakeResp:
|
||||
status_code = 200
|
||||
text = ''
|
||||
def json(self):
|
||||
return {'id_token': id_token, 'access_token': 'x', 'token_type': 'Bearer'}
|
||||
|
||||
monkeypatch.setattr(auth.requests, 'post', lambda *a, **k: _FakeResp())
|
||||
|
||||
class _FakeSigningKey:
|
||||
key = 'unused'
|
||||
|
||||
class _FakeJwksClient:
|
||||
uri = 'http://auth-hub.example/.well-known/jwks.json'
|
||||
def get_signing_key_from_jwt(self, token):
|
||||
return _FakeSigningKey()
|
||||
|
||||
monkeypatch.setattr(auth, '_get_jwks_client', lambda uri: _FakeJwksClient())
|
||||
monkeypatch.setattr(auth.jwt, 'decode', lambda *a, **k: {
|
||||
'preferred_username': 'ericwyuan', 'sub': '1'})
|
||||
|
||||
resp = client.get('/api/auth/callback?state=thestate&code=abc')
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers['Location'] == '/'
|
||||
assert 'fam_session=' in resp.headers['Set-Cookie']
|
||||
assert 'thestate' not in auth._pending
|
||||
assert len(auth._sessions) == 1
|
||||
|
||||
|
||||
def test_callback_rejects_when_token_exchange_fails(client, monkeypatch):
|
||||
_set_auth_hub_env(monkeypatch)
|
||||
auth._pending['thestate'] = {'verifier': 'v', 'expires': time.time() + 600}
|
||||
|
||||
class _FakeResp:
|
||||
status_code = 400
|
||||
text = 'invalid_grant'
|
||||
|
||||
monkeypatch.setattr(auth.requests, 'post', lambda *a, **k: _FakeResp())
|
||||
|
||||
resp = client.get('/api/auth/callback?state=thestate&code=abc')
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers['Location'] == '/login'
|
||||
assert len(auth._sessions) == 0
|
||||
|
||||
|
||||
def test_callback_rejects_when_id_token_verification_fails(client, monkeypatch):
|
||||
_set_auth_hub_env(monkeypatch)
|
||||
auth._pending['thestate'] = {'verifier': 'v', 'expires': time.time() + 600}
|
||||
|
||||
class _FakeResp:
|
||||
status_code = 200
|
||||
text = ''
|
||||
def json(self):
|
||||
return {'id_token': 'bogus'}
|
||||
|
||||
monkeypatch.setattr(auth.requests, 'post', lambda *a, **k: _FakeResp())
|
||||
|
||||
def _boom(uri):
|
||||
raise jwt.PyJWTError("boom")
|
||||
monkeypatch.setattr(auth, '_get_jwks_client', _boom)
|
||||
|
||||
resp = client.get('/api/auth/callback?state=thestate&code=abc')
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers['Location'] == '/login'
|
||||
assert len(auth._sessions) == 0
|
||||
|
||||
|
||||
def test_logout_clears_session(client):
|
||||
tok = auth._create_session()
|
||||
client.set_cookie('fam_session', tok)
|
||||
resp = client.post('/api/logout')
|
||||
assert resp.status_code == 200
|
||||
assert tok not in auth._sessions
|
||||
|
||||
|
||||
def test_before_request_guard_redirects_unauthed_page_to_login(client):
|
||||
resp = client.get('/some-protected-page')
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers['Location'] == '/login'
|
||||
|
||||
|
||||
def test_before_request_guard_401s_unauthed_api(client):
|
||||
resp = client.get('/api/protected')
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_before_request_guard_allows_authed_requests(client):
|
||||
tok = auth._create_session()
|
||||
client.set_cookie('fam_session', tok)
|
||||
assert client.get('/some-protected-page').status_code == 200
|
||||
assert client.get('/api/protected').status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user