diff --git a/README.md b/README.md index 6a78b36..9e37bbc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 基卫 CHIS 上报数据项目。项目目标是提供稳定的后端 API,供第三方系统提交体检数据;`chisup` 负责校验、转换、维护 CHIS 登录会话,并把符合 CHIS 接口格式的数据提交到基卫 CHIS。 -当前仓库处于 MVP 起步阶段:已有 Flask application factory 地基、health check、基础配置、pytest 测试入口和架构包目录。当前已确认可参考 `D:\hans\chupd\chis\login_client_v2.py` 迁移 CHIS 登录链路,public key 由配置提供;访问 CHIS 支持可选 `CHIS_PROXY` SOCKS5 代理;保存上报前先实现只读体检详情查询来验证登录、会话、加密和通用请求。 +当前仓库处于 MVP 起步阶段:已有 Flask application factory 地基、health check、基础配置、pytest 测试入口、CHIS 登录基础链路和架构包目录。当前已参考 `D:\hans\chupd\chis\login_client_v2.py` 迁移 `myRoles -> myApps -> cookie` 登录流程,public key 由配置提供;访问 CHIS 支持可选 `CHIS_PROXY` SOCKS5 代理;保存上报前先实现只读体检详情查询来验证登录、会话、加密和通用请求。 ## 文档入口 @@ -31,6 +31,7 @@ - Flask 结构:使用 application factory 模式,T-001 实现 `create_app(config_object=None)`。 - 依赖管理:T-001 创建 `requirements.txt`,使用 `python -m pip install -r requirements.txt` 安装到当前系统 Python 3.8 环境。 - 测试入口:T-003 固定 `pytest==8.3.5`,使用 `python -m pytest`。 +- CHIS 登录:T-101 已迁移登录基础链路到 `app/chis/auth.py`,SM2 加密在 `app/chis/crypto.py`。 - 计划使用 Redis 保存 CHIS 会话缓存。 - 访问 CHIS 支持可选 `CHIS_PROXY`:为空直连,有值时 CHIS 登录、查询和保存请求走 SOCKS5 代理。 - 当前已有资料:`reverse_file/` 下的 CHIS 前端脚本、schema 和 HAR。 diff --git a/app/chis/auth.py b/app/chis/auth.py new file mode 100644 index 0000000..ca2b963 --- /dev/null +++ b/app/chis/auth.py @@ -0,0 +1,207 @@ +import time +from dataclasses import dataclass +from http.cookies import SimpleCookie +from typing import Mapping, Optional, Sequence +from urllib.parse import quote, urlencode, urlparse + +import requests + +from app.chis.crypto import sm2_encrypt + + +DEFAULT_ALLOWED_ROLES = ("责任医生助理", "责任医生") + + +class ChisLoginError(Exception): + def __init__(self, code, message): + super().__init__(message) + self.code = code + + +@dataclass +class ChisLoginSession: + cookie: str + jsessionid: str + role_id: str + role_name: str + user_name: str + roles_result: dict + apps_result: dict + + +class RequestsTransport: + def __init__(self, proxy="", timeout=(10, 30)): + self.timeout = timeout + self.session = requests.Session() + if proxy: + self.session.proxies.update({"http": proxy, "https": proxy}) + + def post(self, url, headers, payload): + response = self.session.post( + url=url, + headers=headers, + json=payload, + timeout=self.timeout, + ) + response_headers = dict(response.headers) + if response.status_code != 200: + return { + "code": response.status_code, + "msg": response.text, + "data": None, + }, response_headers + if "json" in response.headers.get("Content-Type", ""): + return response.json(), response_headers + return response.text, response_headers + + +class ChisLoginClient: + def __init__( + self, + base_url, + public_key, + transport=None, + encrypt=None, + now_millis=None, + allowed_roles: Sequence[str] = DEFAULT_ALLOWED_ROLES, + ): + self.base_url = _normalize_chis_base_url(base_url) + self.public_key = public_key + self.transport = transport or RequestsTransport() + self.encrypt = encrypt or sm2_encrypt + self.now_millis = now_millis or _now_millis + self.allowed_roles = tuple(allowed_roles) + self.host = urlparse(self.base_url).netloc + self.headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML,like Gecko) Chrome/95.0.4638.54 Safari/537.36", + "encoding": "utf-8", + "Content-Type": "application/json", + "Accept": "application/json", + "Host": self.host, + "Referer": f"{self.base_url}/index.html", + } + + @classmethod + def from_config(cls, config): + return cls( + base_url=config.get("CHIS_BASE_URL", ""), + public_key=config.get("CHIS_PUBLIC_KEY", ""), + transport=RequestsTransport(proxy=config.get("CHIS_PROXY", "")), + ) + + def login(self, username, password): + roles_result, jsessionid = self.get_my_roles(username, password) + token = self._select_role(roles_result) + apps_result, jsessionid = self.get_my_apps( + username=username, + password=password, + role_id=str(token["id"]), + jsessionid=jsessionid, + ) + if not jsessionid or len(jsessionid) < 3: + raise ChisLoginError("chis_login_failed", "CHIS login did not return JSESSIONID") + + role_name = token["roleName"] + cookie = self._build_cookie(username, role_name, jsessionid) + return ChisLoginSession( + cookie=cookie, + jsessionid=jsessionid, + role_id=str(token["id"]), + role_name=role_name, + user_name=token.get("userName", ""), + roles_result=roles_result, + apps_result=apps_result, + ) + + def get_my_roles(self, username, password): + payload = { + "url": "logon/myRoles", + "uid": username, + "pwd": self.encrypt(self.public_key, password), + "d": self.encrypt(self.public_key, str(self.now_millis())), + } + result, response_headers = self.transport.post( + url=f"{self.base_url}/logon/myRoles", + headers=self._headers_without_cookie(), + payload=payload, + ) + self._ensure_chis_ok(result, "chis_login_failed", "CHIS myRoles failed") + jsessionid = _extract_jsessionid(response_headers) + if not jsessionid: + raise ChisLoginError("chis_login_failed", "CHIS myRoles did not return JSESSIONID") + return result, jsessionid + + def get_my_apps(self, username, password, role_id, jsessionid): + query = urlencode( + { + "urt": role_id, + "uid": username, + "pwd": self.encrypt(self.public_key, password), + "deep": "3", + "d": self.encrypt(self.public_key, str(self.now_millis())), + } + ) + headers = self._headers_without_cookie() + headers["Cookie"] = f"JSESSIONID={jsessionid}" + result, response_headers = self.transport.post( + url=f"{self.base_url}/logon/myApps?{query}", + headers=headers, + payload={"url": f"logon/myApps?{query}", "httpMethod": "POST"}, + ) + self._ensure_chis_ok(result, "chis_login_failed", "CHIS myApps failed") + return result, _extract_jsessionid(response_headers) or jsessionid + + def _select_role(self, roles_result): + tokens = roles_result.get("body", {}).get("tokens", []) + for token in tokens: + if token.get("roleName") in self.allowed_roles: + return token + roles = " / ".join(self.allowed_roles) + raise ChisLoginError("chis_role_not_allowed", f"CHIS account has no allowed role: {roles}") + + def _headers_without_cookie(self): + return {key: value for key, value in self.headers.items() if key.lower() != "cookie"} + + def _build_cookie(self, username, role_name, jsessionid): + cookie_map = { + "JSESSIONID": jsessionid, + "sessionId1": jsessionid, + username: f"{quote(role_name)}@photo/default.jpg", + } + return "; ".join([f"{key}={value}" for key, value in cookie_map.items()]) + + def _ensure_chis_ok(self, result, code, prefix): + if not isinstance(result, dict): + raise ChisLoginError(code, f"{prefix}: invalid response") + if result.get("code") != 200: + message = result.get("msg") or result.get("message") or "unknown error" + raise ChisLoginError(code, f"{prefix}: {message}") + + +def _normalize_chis_base_url(base_url): + if not base_url: + raise ChisLoginError("chis_config_missing", "CHIS_BASE_URL is not configured") + normalized = base_url.rstrip("/") + if "://" not in normalized: + normalized = f"http://{normalized}" + if normalized.endswith("/chis"): + return normalized + return f"{normalized}/chis" + + +def _now_millis(): + return int(time.time() * 1000) + + +def _extract_jsessionid(headers: Mapping[str, str]) -> Optional[str]: + for key, value in headers.items(): + if key.lower() != "set-cookie": + continue + cookie = SimpleCookie() + cookie.load(value) + if "JSESSIONID" in cookie: + return cookie["JSESSIONID"].value + for item in value.split(";"): + if "JSESSIONID" in item: + return item.split("=", 1)[1].strip() + return None diff --git a/app/chis/crypto.py b/app/chis/crypto.py new file mode 100644 index 0000000..5f8282e --- /dev/null +++ b/app/chis/crypto.py @@ -0,0 +1,24 @@ +import binascii + +from gmssl import sm2 + + +class ChisCryptoError(Exception): + """Raised when CHIS crypto cannot encrypt a required field.""" + + +def sm2_encrypt(public_key, raw_text): + if not public_key: + raise ChisCryptoError("CHIS public key is not configured") + + key = public_key + if len(key) > 64 * 2: + key = key[-64 * 2 :] + + try: + text_bytes = str(raw_text).encode("utf-8") + crypt_sm2 = sm2.CryptSM2(public_key=key, private_key="", mode=0) + encrypted = crypt_sm2.encrypt(text_bytes) + return "04" + binascii.hexlify(encrypted).decode("utf-8") + except Exception as exc: + raise ChisCryptoError("CHIS SM2 encryption failed") from exc diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 05e63e2..4f663e4 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -9,9 +9,9 @@ | 运行时 | 当前系统 Python 3.8 | 已定 | 用户指定,不创建虚拟环境 | | Web 框架 | Flask 3.0.3 + application factory | 已定 | 用户指定 Flask 版本;factory 模式便于配置注入和测试 | | 数据校验 | 待定,建议 Pydantic v1 或 Marshmallow | 待定 | Python 3.8 下需注意版本兼容 | -| HTTP 客户端 | 待定,建议 `requests.Session` + SOCKS 支持 | 待定 | CHIS 会话 cookie 管理简单稳定;`CHIS_PROXY` 有值时走 SOCKS5 代理 | +| HTTP 客户端 | `requests[socks]` 2.32.4 | 已定 | CHIS 会话 cookie 管理简单稳定;`CHIS_PROXY` 有值时走 SOCKS5 代理 | | Redis 客户端 | 待定,建议 `redis-py` | 待定 | 保存 CHIS 会话、幂等和短期状态 | -| SM2 加密 | 待定,建议 `gmssl` | 待定 | 需兼容 `hans_chis.sm2.sm2_encrypt` 的 `CryptSM2(mode=0)` 行为 | +| SM2 加密 | `gmssl` 3.2.2 | 已定 | 兼容 `hans_chis.sm2.sm2_encrypt` 的 `CryptSM2(mode=0)` 行为 | | 日志 | Python logging + TimedRotatingFileHandler | 已定 | `logs/` 每天一个综合日志文件,保留 1 年 | | 请求归档 | 本地 JSON 文件 | 已定 | `archives/` 每个接口请求一个完整归档文件,部署在内网前置机,不脱敏 | | 数据库 | 待定 | 待定 | MVP 可先只用 Redis;上报流水可能需要 MySQL / SQLite / PostgreSQL | @@ -28,9 +28,10 @@ - 运行日志保存到 `logs/`,每天一个综合日志文件,包含 INFO/WARNING/ERROR/EXCEPTION 等级摘要,保留 1 年。 - 接口请求归档保存到 `archives/`,一个 API 请求一个 JSON archive 文件,文件内容保存完整 API 与 CHIS 请求 / 响应,不脱敏;该目录仅用于内网前置机本地审计排查,不提交 git。 - CHIS public key 从配置文件或环境变量读取;当前不依赖 `/chis/logon/publicKey` 动态获取。 +- `CHIS_BASE_URL` 支持完整地址如 `http://host:port/chis`,也兼容旧项目的 host 写法如 `host:port`,代码会补齐为 `http://host:port/chis`。 - CHIS 外呼请求支持可选代理:`CHIS_PROXY` 为空时直连;有值时只作用于访问 CHIS 的 HTTP client,不影响第三方调用 `chisup` 的入站请求。 -- `CHIS_PROXY` 推荐格式为 `socks5h://127.0.0.1:1080`;后续实现 `requests.Session` 时需同步加入 `requests[socks]` 或 `PySocks` 依赖,保证 SOCKS5 可用。 -- CHIS 登录代码来自 Django 项目参考实现,迁移到 Flask 时只复用登录链路、SM2 算法和请求形状,不复用 Django model/cache。 +- `CHIS_PROXY` 推荐格式为 `socks5h://127.0.0.1:1080`;`requests[socks]==2.32.4` 已固定到依赖,保证 SOCKS5 可用。 +- CHIS 登录代码来自 Django 项目参考实现,迁移到 Flask 时只复用登录链路、SM2 算法和请求形状,不复用 Django model/cache;当前登录实现位于 `app/chis/auth.py`,SM2 实现位于 `app/chis/crypto.py`。 - 当前不引入异步队列,先跑通同步单条上报闭环;批量和重试队列放到 V2。 - 当前不引入管理后台,先保证 API、会话和转换稳定。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 0d75e73..fbf9b8d 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -83,6 +83,7 @@ API / service / CHIS client - 负责 Redis 会话缓存、清理和重登。 - 登录链路参考 `D:\hans\chupd\chis\login_client_v2.py`,但不能原样依赖 Django model/cache。 - CHIS public key 从 Flask 配置或环境变量读取,不从代码硬编码。 +- 当前 T-101 已落地 `app/chis/auth.py:ChisLoginClient`:负责 `myRoles`、角色选择、`myApps`、JSESSIONID 提取和 cookie 组装;Redis 会话缓存与有效性验证留给 T-102 / T-103。 **CHIS client** @@ -285,7 +286,7 @@ archives/ | 难点 | 说明 | 应对 | | --- | --- | --- | -| CHIS 登录链路 | 可能涉及公钥、加密、角色、应用、机构上下文 | 等用户提供现有代码后接入,先写 auth 边界 | +| CHIS 登录链路 | 涉及公钥、SM2 加密、角色、应用、JSESSIONID 和 cookie 组装 | T-101 已迁移为 `ChisLoginClient`,后续用真实账号做联调 | | CHIS 网络代理 | 部署在内网前置机时,访问 CHIS 可能必须走 SOCKS5 | 使用可选 `CHIS_PROXY` 配置;空值直连,有值时为 `http` / `https` 同时设置 requests proxies | | 会话有效性 | cookie 存在不代表 CHIS 会话仍有效 | 用账号信息查询接口确认,失败则清理缓存并重登 | | 只传体检 id 查询详情 | `getHMNIListOfHTML` 还需要 `phrId` 和 `idCard` | 先要求调用方传全参数,后续验证反查链路 | diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 3fcc77b..f00917f 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -26,7 +26,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | -| T-101 | 迁移 CHIS 登录链路 | T-002 | 参考 `D:\hans\chupd\chis\login_client_v2.py`;登录逻辑进入 `app/chis/auth.py` 或等价模块;public key 从配置读取;`CHIS_PROXY` 有值时登录请求走代理,空值直连;账号密码不落日志;失败返回明确错误 | TODO | +| T-101 | 迁移 CHIS 登录链路 | T-002 | 参考 `D:\hans\chupd\chis\login_client_v2.py`;登录逻辑进入 `app/chis/auth.py` 或等价模块;public key 从配置读取;`CHIS_PROXY` 有值时登录请求走代理,空值直连;账号密码不落日志;失败返回明确错误 | DONE | | T-102 | 接入账号信息查询验证会话 | T-101 | 能用已有 cookie 查询当前账号信息;失败可判断会话无效 | TODO | | T-103 | 实现 Redis 会话缓存 | T-102 | 会话对象包含 cookies、账号、角色/机构、过期时间;有效会话复用;无效会话清理 | TODO | | T-104 | 实现失效重登策略 | T-103 | CHIS 返回未登录时清缓存、重登一次、重试一次;不会无限重试 | TODO | diff --git a/docs/api.md b/docs/api.md index 8a8f0e1..894144c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -146,6 +146,38 @@ ## 内部模块合约 +### `ChisLoginClient.from_config(config).login(username, password)` + +输入: + +```python +username: str +password: str +``` + +输出: + +```python +ChisLoginSession( + cookie=str, + jsessionid=str, + role_id=str, + role_name=str, + user_name=str, + roles_result=dict, + apps_result=dict, +) +``` + +职责: + +- 从 `CHIS_BASE_URL`、`CHIS_PUBLIC_KEY`、`CHIS_PROXY` 创建登录客户端。 +- 调用 `logon/myRoles`,使用 SM2 加密密码和时间戳 `d`。 +- 优先选择 `责任医生助理` 或 `责任医生` 角色。 +- 调用 `logon/myApps` 获取应用上下文。 +- 从 `Set-Cookie` 提取 `JSESSIONID`,组装 CHIS cookie。 +- 登录失败、缺少允许角色、缺少 cookie 时抛出 `ChisLoginError`,错误对象包含稳定 `code`。 + ### `ChisSessionManager.ensure_session(account_ref)` 输入: diff --git a/docs/current-state.md b/docs/current-state.md index 1b6f32c..14c5813 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,9 +6,9 @@ - 日期:2026-07-04 - 阶段:MVP 起步 / Phase 0 地基已完成 -- 技术栈:当前系统 Python 3.8、Flask 3.0.3 application factory、pytest 8.3.5;不创建虚拟环境 -- 生产代码:`app/__init__.py`、`app/api/health.py`、`app/config.py`、`run.py`,以及 `app/middleware/`、`app/services/`、`app/mappers/`、`app/chis/`、`app/repositories/`、`app/validators/` 包目录 -- 测试:`tests/test_app_factory.py`、`tests/test_config_and_structure.py`、`tests/test_project_setup.py` +- 技术栈:当前系统 Python 3.8、Flask 3.0.3 application factory、pytest 8.3.5、requests[socks] 2.32.4、gmssl 3.2.2;不创建虚拟环境 +- 生产代码:`app/__init__.py`、`app/api/health.py`、`app/config.py`、`run.py`、`app/chis/auth.py`、`app/chis/crypto.py`,以及 `app/middleware/`、`app/services/`、`app/mappers/`、`app/chis/`、`app/repositories/`、`app/validators/` 包目录 +- 测试:`tests/test_app_factory.py`、`tests/test_config_and_structure.py`、`tests/test_project_setup.py`、`tests/test_chis_auth.py` - 数据 / 资料:`reverse_file/` 下已有 CHIS HAR、前端脚本和 schema;`D:\hans\chupd\chis\login_client_v2.py` 可作为登录链路参考 - 标准启动路径:`python run.py` - 标准验证路径:`python -m pytest` @@ -23,7 +23,7 @@ | `reverse_file/20260704_query_health_check.har` | 已有 | 体检详情只读查询 HAR,包含 `getHMNIListOfHTML` 和 `getCheckInfoDetail` | | `D:\hans\chupd\chis\login_client_v2.py` | 外部参考 | CHIS 登录、SM2 加密、角色选择、cookie 拼接、`getLanderInfo` 会话验证 | | `app/` | 已有 | Flask application factory 和 health check API | -| `app/chis/` | 已有 | CHIS integration layer 包目录 | +| `app/chis/` | 已有 | CHIS integration layer;T-101 已包含登录客户端与 SM2 加密 | | `app/mappers/` | 已有 | 数据转换层包目录 | | `app/middleware/` | 已有 | 中间件包目录 | | `app/repositories/` | 已有 | 存储 / 缓存包目录 | @@ -31,28 +31,30 @@ | `app/validators/` | 已有 | 请求校验包目录 | | `tests/` | 已有 | pytest 可收集的测试 | | `pytest.ini` | 已有 | pytest 测试入口配置,限制测试目录为 `tests` | -| `requirements.txt` | 已有 | Python 依赖,固定 `Flask==3.0.3` 和 `pytest==8.3.5` | +| `requirements.txt` | 已有 | Python 依赖,固定 `Flask==3.0.3`、`pytest==8.3.5`、`requests[socks]==2.32.4`、`gmssl==3.2.2` | | `.env.example` | 已有 | 环境变量占位示例,不含真实密钥 | ## 任务看板状态 任务状态以 [06-tasks.md](06-tasks.md) 为准。 -- 已完成:DOC-001 建立 harness coding 项目文档;DOC-002 更新 CHIS 登录与只读查询验证路线;DOC-003 明确使用系统 Python 3.8;DOC-004 明确 Flask factory 模式;DOC-005 明确日志与请求归档方案;DOC-006 明确 CHIS SOCKS5 代理配置;T-001 初始化 Flask 项目骨架;T-002 建立基础配置与目录;T-003 建立最小测试框架。详见 [../progress.md](../progress.md)。 +- 已完成:DOC-001 建立 harness coding 项目文档;DOC-002 更新 CHIS 登录与只读查询验证路线;DOC-003 明确使用系统 Python 3.8;DOC-004 明确 Flask factory 模式;DOC-005 明确日志与请求归档方案;DOC-006 明确 CHIS SOCKS5 代理配置;T-001 初始化 Flask 项目骨架;T-002 建立基础配置与目录;T-003 建立最小测试框架;T-101 迁移 CHIS 登录链路。详见 [../progress.md](../progress.md)。 - 正在进行:无。 -- 下一个可领取任务:T-101 迁移 CHIS 登录链路。 +- 下一个可领取任务:T-102 接入账号信息查询验证会话。 ## 已确认技术事实 - CHIS public key 可通过配置文件或环境变量提供。 - 使用当前系统 Python 3.8 环境,不创建虚拟环境;T-001 创建 `requirements.txt` 固定依赖。 - pytest 8.3.5 已固定到 `requirements.txt`;标准测试命令为 `python -m pytest`。 +- requests[socks] 2.32.4 和 gmssl 3.2.2 已固定到 `requirements.txt`,用于 CHIS 登录请求、SOCKS5 代理和 SM2 加密。 - Flask 应用使用 application factory 模式,`app/__init__.py` 暴露 `create_app(config_object=None)`。 - 基础配置通过环境变量读取:`CHIS_BASE_URL`、`CHIS_PUBLIC_KEY`、`CHIS_PROXY`、`REDIS_URL`、`LOG_DIR`、`ARCHIVE_DIR`。 +- `CHIS_BASE_URL` 兼容完整地址和旧项目 host 写法;未带 scheme 时按 `http:///chis` 处理。 - CHIS 外呼支持可选 SOCKS5 代理配置:`CHIS_PROXY` 为空直连;有值时后续 CHIS client 对登录、会话验证、查询和保存请求统一走代理。 - `.env.example` 只保留占位符和非敏感默认值,不写真实密钥。 -- 登录链路参考 `login_client_v2.py`:`myRoles` -> 选择 `责任医生助理` / `责任医生` -> `myApps` -> 拼接 cookie。 -- SM2 加密参考 `hans_chis.sm2.sm2_encrypt`:`gmssl.sm2.CryptSM2(mode=0)`,返回带 `04` 前缀密文。 +- 登录链路已迁移到 `app/chis/auth.py`:`myRoles` -> 选择 `责任医生助理` / `责任医生` -> `myApps` -> 提取 `JSESSIONID` -> 拼接 cookie。 +- SM2 加密已迁移到 `app/chis/crypto.py`,兼容 `hans_chis.sm2.sm2_encrypt`:`gmssl.sm2.CryptSM2(mode=0)`,返回带 `04` 前缀密文。 - 会话验证可用 `chis.myPageService / getLanderInfo`。 - 体检详情查询第一版可用 `healthCheck + phrId + idCard` 调 `getHMNIListOfHTML`,用 `idCard + checkDate` 调 `getCheckInfoDetail`。 - 当前不能证明只传 `healthCheck` 就能查完整详情。 diff --git a/progress.md b/progress.md index 2e733c9..cca3988 100644 --- a/progress.md +++ b/progress.md @@ -106,3 +106,12 @@ - 阻塞:无。 - 决策:T-003 后标准测试入口改为 `python -m pytest`;保留既有 unittest 风格测试,pytest 可正常收集运行。 - 下一步:T-101 迁移 CHIS 登录链路。 + +## 2026-07-04 T-101 迁移 CHIS 登录链路 + +- 状态:DONE +- 变更:新增 `app/chis/auth.py` 和 `app/chis/crypto.py`;新增 `tests/test_chis_auth.py`;`requirements.txt` 增加 `requests[socks]==2.32.4`、`gmssl==3.2.2`;更新任务看板、技术栈、架构、API 合约、当前状态和根目录任务总览。 +- 验证:先执行 `python -m pytest tests/test_chis_auth.py` 看到 `ModuleNotFoundError: No module named 'app.chis.auth'`;新增依赖测试后执行 `python -m pytest tests/test_project_setup.py` 看到 `requests[socks]==2.32.4` 未固定;补充 host 兼容测试时先看到 `assert 'chis.example.test:9002/chis' == 'http://chis.example.test:9002/chis'`;实现后执行 `python -m pytest tests/test_chis_auth.py` 结果 `6 passed`,执行 `python -m pytest tests/test_project_setup.py` 结果 `1 passed`,执行 `python -m pip install -r requirements.txt` 显示 Flask、pytest、requests、gmssl 均已满足,执行 `python -m pytest` 结果 `10 passed`,临时启动 `python run.py` 后请求 `/health` 返回 `{"ok":true}`,执行 `git diff --check` 未发现空白错误。 +- 阻塞:无。 +- 决策:T-101 只迁移登录链路,不接 Redis,不做会话有效性查询;登录客户端从 `CHIS_BASE_URL`、`CHIS_PUBLIC_KEY`、`CHIS_PROXY` 创建,完成 `myRoles`、角色选择、`myApps`、JSESSIONID 提取和 cookie 组装。 +- 下一步:T-102 接入账号信息查询验证会话。 diff --git a/requirements.txt b/requirements.txt index ba5e990..cc5eca8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ Flask==3.0.3 pytest==8.3.5 +requests[socks]==2.32.4 +gmssl==3.2.2 diff --git a/tasks.md b/tasks.md index 1ececdc..a8df609 100644 --- a/tasks.md +++ b/tasks.md @@ -13,7 +13,7 @@ MVP 起步。当前仓库已具备逆向资料、项目文档、Flask applicatio | T-001 | 初始化 Flask 项目骨架 | DONE | | T-002 | 建立基础配置与目录 | DONE | | T-003 | 建立最小测试框架 | DONE | -| T-101 | 迁移 CHIS 登录链路 | TODO | +| T-101 | 迁移 CHIS 登录链路 | DONE | | T-102 | 接入账号信息查询验证会话 | TODO | | T-103 | 实现 Redis 会话缓存 | TODO | | T-201 | 实现通用 CHIS jsonRequest client | TODO | diff --git a/tests/test_chis_auth.py b/tests/test_chis_auth.py new file mode 100644 index 0000000..2a35190 --- /dev/null +++ b/tests/test_chis_auth.py @@ -0,0 +1,152 @@ +import pytest + +from app.chis.auth import ChisLoginClient, ChisLoginError + + +class FakeTransport: + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + + def post(self, url, headers, payload): + self.calls.append({"url": url, "headers": headers, "payload": payload}) + return self.responses.pop(0) + + +def fake_encrypt(public_key, raw_text): + if raw_text == "secret": + return "encrypted-password" + if raw_text == "1234567890": + return "encrypted-time" + return "encrypted-value" + + +def test_login_gets_role_apps_and_cookie_without_leaking_password(): + transport = FakeTransport( + [ + ( + { + "code": 200, + "body": { + "tokens": [ + {"roleName": "其他角色", "id": "1", "userName": "ignored"}, + {"roleName": "责任医生", "id": "role-2", "userName": "doctor"}, + ] + }, + }, + {"Set-Cookie": "JSESSIONID=abc123; Path=/chis; HttpOnly"}, + ), + ({"code": 200, "body": {"apps": []}}, {}), + ] + ) + + client = ChisLoginClient( + base_url="http://chis.example.test/chis", + public_key="public-key", + transport=transport, + encrypt=fake_encrypt, + now_millis=lambda: 1234567890, + ) + + session = client.login(username="u001", password="secret") + + assert session.cookie == "JSESSIONID=abc123; sessionId1=abc123; u001=%E8%B4%A3%E4%BB%BB%E5%8C%BB%E7%94%9F@photo/default.jpg" + assert session.role_id == "role-2" + assert session.role_name == "责任医生" + assert session.user_name == "doctor" + assert transport.calls[0]["url"] == "http://chis.example.test/chis/logon/myRoles" + assert transport.calls[0]["payload"] == { + "url": "logon/myRoles", + "uid": "u001", + "pwd": "encrypted-password", + "d": "encrypted-time", + } + assert "secret" not in str(transport.calls) + assert transport.calls[1]["url"].startswith("http://chis.example.test/chis/logon/myApps?") + assert transport.calls[1]["headers"]["Cookie"] == "JSESSIONID=abc123" + assert "urt=role-2" in transport.calls[1]["url"] + + +def test_login_raises_clear_error_when_roles_fail(): + transport = FakeTransport([({"code": 501, "msg": "PasswordNotRight"}, {})]) + client = ChisLoginClient( + base_url="http://chis.example.test/chis", + public_key="public-key", + transport=transport, + encrypt=fake_encrypt, + ) + + with pytest.raises(ChisLoginError) as exc_info: + client.login(username="u001", password="secret") + + assert exc_info.value.code == "chis_login_failed" + assert "PasswordNotRight" in str(exc_info.value) + assert "secret" not in str(exc_info.value) + + +def test_login_raises_clear_error_when_required_role_missing(): + transport = FakeTransport( + [ + ( + { + "code": 200, + "body": { + "tokens": [ + {"roleName": "其他角色", "id": "1", "userName": "ignored"}, + ] + }, + }, + {"Set-Cookie": "JSESSIONID=abc123; Path=/chis"}, + ), + ] + ) + client = ChisLoginClient( + base_url="http://chis.example.test/chis", + public_key="public-key", + transport=transport, + encrypt=fake_encrypt, + ) + + with pytest.raises(ChisLoginError) as exc_info: + client.login(username="u001", password="secret") + + assert exc_info.value.code == "chis_role_not_allowed" + assert "责任医生助理" in str(exc_info.value) + assert "责任医生" in str(exc_info.value) + + +def test_requests_transport_uses_proxy_when_configured(): + client = ChisLoginClient.from_config( + { + "CHIS_BASE_URL": "http://chis.example.test/chis", + "CHIS_PUBLIC_KEY": "public-key", + "CHIS_PROXY": "socks5h://127.0.0.1:1080", + } + ) + + assert client.transport.session.proxies["http"] == "socks5h://127.0.0.1:1080" + assert client.transport.session.proxies["https"] == "socks5h://127.0.0.1:1080" + + +def test_requests_transport_direct_when_proxy_empty(): + client = ChisLoginClient.from_config( + { + "CHIS_BASE_URL": "http://chis.example.test/chis", + "CHIS_PUBLIC_KEY": "public-key", + "CHIS_PROXY": "", + } + ) + + assert client.transport.session.proxies == {} + + +def test_login_client_accepts_plain_host_config(): + client = ChisLoginClient( + base_url="chis.example.test:9002", + public_key="public-key", + transport=FakeTransport([]), + encrypt=fake_encrypt, + ) + + assert client.base_url == "http://chis.example.test:9002/chis" + assert client.host == "chis.example.test:9002" diff --git a/tests/test_project_setup.py b/tests/test_project_setup.py index 0354ed4..7efcceb 100644 --- a/tests/test_project_setup.py +++ b/tests/test_project_setup.py @@ -6,3 +6,5 @@ def test_requirements_pin_test_framework(): assert "Flask==3.0.3" in requirements assert "pytest==8.3.5" in requirements + assert "requests[socks]==2.32.4" in requirements + assert "gmssl==3.2.2" in requirements