☰
病案审核系统API的Token鉴权全链路落地:从登录到业务接口
2026/10/1 3:27:43 网站建设 项目流程

最近在给一家医院的信息科做病案审核系统的API对接,前后端联调的时候我发现一个规律:真正耗时间的从来不是写业务逻辑,而是登录鉴权这一环。前面两篇我们把框架骨架、数据库设计、路由结构都理清楚了,这篇集中把Token鉴权到业务接口落地的完整链路梳理一遍,涵盖登录接口的开发细节、鉴权中间件的拦截逻辑、刷新Token的续签机制,以及对接过程中最容易踩的坑。如果你手里也有一套系统要对外开放API,或者正在处理登录超时、Token失效这类问题,这篇可以直接当参考手册用。

病案审核系统API的Token鉴权链路,从登录到业务接口的完整落地

1. 为什么病案审核系统必须用Token:三个绕不开的现实约束

1.1 Session方案在这里撑不住的原因

很多做内部管理系统的人习惯用Session做登录态,客户端拿一个session_id,服务端在内存或数据库里存用户信息。这套玩法在纯Web页面里问题不大,但放到API场景下立刻露馅。

病案审核系统的使用场景很特殊:审核员可能在医院的HIS工作站上操作系统,也可能在科室的平板电脑上查看待审核列表,甚至偶尔用手机浏览器处理紧急病案。客户端形态有Web、有移动端、有第三方集成系统。Session机制要求客户端保存会话ID,并且在每次请求时把ID带回来,服务端还要维护一份会话状态表。一旦服务端有多台机器负载均衡,Session同步就成了麻烦事,要么引入共享存储,要么做粘性会话,要么想办法广播同步,都是额外的运维负担。

Token方案把用户身份信息编码进一串无状态字符串里,服务端持有密钥负责验签。客户端只需要在Header里带上Token,任何一台后端机器拿到之后都能独立验证,不需要查共享Session表。这一点在病案审核这种需要多人协作、多端接入的场景里,几乎是刚需。

1.2 接口清单:先把要对接的路由理清楚

动手写代码之前,我习惯先把所有接口按模块列一张清单出来。病案审核系统里最核心的无非是这几块:

模块路由功能说明鉴权要求
认证POST /api/auth/login账号密码登录,签发Token公开
认证POST /api/auth/refresh用refresh_token换取新的access_token携带refresh_token
认证POST /api/auth/logout注销登录,吊销Token携带access_token
用户GET /api/user/profile获取当前登录用户信息携带access_token
病案GET /api/medical-cases/pending获取待审核病案列表(分页)携带access_token
病案GET /api/medical-cases/{id}获取单个病案详情携带access_token
病案POST /api/medical-cases/{id}/review提交审核结论(通过/驳回)携带access_token

这里有个很容易忽略的细节:logout接口也需要鉴权。因为我们需要在退出时把Token加入黑名单,防止已经注销的Token继续使用。如果不处理,用户在公共电脑上退出后,别人拿着旧Token还是能调接口,这在医疗场景里是绝对不能接受的。

1.3 统一响应格式与错误码,避免对接期扯皮

接口联调最大的痛点之一就是前后端对返回结果的解析各执一词。我在这个项目里一开始就定了统一的响应结构,不管是成功还是失败,都遵循这个壳子:

{ "code": 0, "message": "ok", "data": {} }

code为0表示成功,非0表示各类错误。错误码从里到外分成两层:全局基础错误和业务错误。全局错误码比如:

错误码含义
40100未携带Token
40101Token已过期
40102Token无效/签名错误
40103Token已被吊销
40300权限不足
42900登录尝试过于频繁

业务错误码从自定义的50000段开始划分,比如病案不存在、审核状态不允许流转等等。这样一个Token过期和Token被吊销虽然HTTP状态码都是401,但业务层面可以区分成不同的code,客户端就能用不同的逻辑去处理,而不是笼统弹一个"登录失败"。

2. 登录接口开发全流程:参数校验、密码比对与JWT签发

2.1 登录入参设计与防暴力破解

登录接口是全套API里唯一对外公开的写接口,也是最容易被攻击者瞄上的地方。入参我建议只接受必需字段:username和password,可选字段加一个captcha用于验证码场景。不要在前端传什么"登录设备""登录来源"之类的参数,这些应该由服务端从请求头里自行识别,客户端传的设备标识很容易被伪造。

参数校验这一步很容易写得不耐烦,很多人图省事直接查数据库,把空的用户名和密码也带进SQL里。我是这样处理的:

public function login(Request $request) { $username = trim($request->post('username', '')); $password = $request->post('password', ''); if ($username === '' || $password === '') { return $this->json(40001, '用户名和密码不能为空'); } if (mb_strlen($password) < 8 || mb_strlen($password) > 32) { return $this->json(40002, '密码长度不合法'); } }

设计登录防暴力破解时有过一个教训。最初简单按IP限流,后来发现医院局域网天然是NAT出口,整个科室的电脑都共享同一个公网IP,按IP限制每分钟5次,结果一个科室的人轮流登录,第6个人就登录不上去了,信息科电话被打爆。后来改成了"用户名+IP"的组合维度限流,用Redis记录某个用户名某个来源IP的失败次数,5分钟内连续失败超过5次就锁定该组合15分钟。真正的用户改对密码之后能正常登录,攻击者用撞库的方式却很难绕过。

2.2 JWT签发细节:Header、Payload、Signature

登录成功后就要签发Token。我用的JWT库是firebase/php-jwt,但它的封装层太厚,新手容易用错,我建议至少手写一版通透的JWT结构,再决定用不用库。

一个JWT字符串分成三段,用点号隔开:header.payload.signature。Header里声明算法和类型,Payload里放业务数据,Signature是对前两段做签名的结果。

private function base64UrlEncode(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } private function createAccessToken(array $user): string { $header = json_encode(['alg' => 'HS256', 'typ' => 'JWT']); $payload = json_encode([ 'iss' => 'medical-record-system', 'sub' => $user['id'], 'username' => $user['username'], 'role' => $user['role'], 'iat' => time(), 'exp' => time() + 7200 ]); $base64Header = $this->base64UrlEncode($header); $base64Payload = $this->base64UrlEncode($payload); $signature = hash_hmac('sha256', $base64Header . '.' . $base64Payload, $this->getSecret(), true); $base64Signature = $this->base64UrlEncode($signature); return $base64Header . '.' . $base64Payload . '.' . $base64Signature; }

Payload里的iat是签发时间,exp是过期时间,这两项是鉴权中间件判断"是否过期"的依据。sub放用户ID,role放角色信息。这里要注意:不要往Payload里塞敏感信息,比如手机号、身份证号、住址。JWT的Payload只是Base64编码,不是加密的,任何人拿到Token都能用base64_decode解出来看。很多泄露事件就是把用户真实姓名和身份证号直接塞进Token导致的。

HS256签名用的是对称密钥,服务端和验证方共享同一个密钥。病案审核系统是自研系统,不存在第三方验签需求,所以HS256足够。如果将来要开放接口给外部合作伙伴,可考虑换成RS256非对称签名,私钥留在服务端,公钥交给第三方。

2.3 登录成功后的响应结构设计

登录成功后的响应体,很多人习惯只返回一个Token字段。我建议至少返回三个字段:access_token、refresh_token、expires_in。

{ "code": 0, "message": "ok", "data": { "access_token": "eyJ...", "refresh_token": "efG...", "expires_in": 7200 } }

access_token有效期我设了2小时。这个时长其实是根据病案审核的实际使用场景倒推的:审核员一般上午连续操作2到4小时,如果Token有效期太短,比如30分钟,用户在审一份长病案的时候突然Token过期,还得重新登录,页面状态还丢了,体验很糟糕。2小时可以覆盖大部分连续操作场景,真过期了也有refresh_token机制兜底。

refresh_token有效期设了7天。它和access_token的区别在于:access_token每次请求都带,所以有效期短、体积小、只在内存里存在;refresh_token只用来换取新Token,不参与业务请求,所以允许更长有效期。refresh_token本质上是"长期通行证",必须做好存储和吊销管理。我把refresh_token存在Redis里,key是refresh_token:{用户ID},value是token串,设置了7天过期。同时记录用户设备的标识,防止一个refresh_token被多个设备同时使用。

这会带来一个新的问题:refresh_token要不要存数据库?我之前见过有人把refresh_token存在MySQL表里,每次刷新都查一次,数据量大了以后没事就清理一次,完全没有必要。Redis天然适合干这个活,查询快,自带TTL过期机制,省心。如果项目里还没有Redis,可以考虑存内存缓存或数据库表,但一定要有清理策略。

3. 鉴权中间件的实现逻辑:Token校验、权限挂载与自动续签

3.1 中间件处理流程拆解

登录接口走通之后,真正决定整个API安全性的就是鉴权中间件。中间件要放在路由层和控制器层之间,确保所有受保护的接口在进入业务逻辑之前完成身份验证。

中间件的执行流程我总结成五步:读取Header、解析Token、验签、判过期、挂载用户信息。

public function handle(Request $request, Closure $next) { $authorization = $request->header('Authorization', ''); if (!preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches)) { return $this->json(40100, '未携带有效的Authorization头'); } $token = $matches[1]; try { $payload = $this->verifyToken($token); } catch (ExpiredException $e) { return $this->json(40101, 'Token已过期'); } catch (SignatureInvalidException $e) { return $this->json(40102, 'Token签名错误'); } catch (\Exception $e) { return $this->json(40102, 'Token无效'); } if ($this->isTokenBlacklisted($token)) { return $this->json(40103, 'Token已被吊销'); } $request->user = $this->getUserById($payload->sub); if (!$request->user) { return $this->json(40102, '用户不存在或已禁用'); } return $next($request); }

这个流程里有个顺序问题需要注意:应该先验签再查黑名单,还是先查黑名单再验签?我一开始是先查黑名单后验签,结果Redis里查出来的都是无效Token的哈希值,白占了不少内存。后来改成先验签再查黑名单,无效Token直接拒绝,只有有效Token才检查是否在吊销名单里,效率高多了。

3.2 角色权限控制:科室、病案室、管理员的分级

Token里带了role字段,中间件验证通过后,还需要一个权限校验层来决定当前用户能否访问某个接口。病案审核系统的角色划分比较典型:科室医生、病案室审核员、科室主任、系统管理员。

权限控制我用了最简单的路由级白名单方案:在每个受保护路由上标注允许访问的角色列表,中间件读取当前用户的role,判断是否在允许列表内。

// 路由注册示例 $router->get('/api/medical-cases/pending', 'MedicalCaseController@pending') ->middleware(['auth', 'role:reviewer,admin']);

这样做的好处是权限和路由一一对应,看代码就知道哪个角色能访问哪个接口,排查问题时不用来回翻配置。

这里有个血的教训:角色权限必须判断,不能只依赖前端隐藏入口。我之前见过一个项目,前端把某个管理按钮隐藏了就当权限控制完成,结果有人直接手工调用接口把数据改了。医疗系统里审核和分配权限是严肃的事,前端隐藏只是体验优化,后端权限校验才是防线。

3.3 refresh_token续签机制:减少用户重复登录

Access Token过期之后,如果每次都让用户重新输入账号密码登录,体验很差。refresh_token机制就是为了在Token过期后,用户无感获取新的access_token。

刷新接口的核心逻辑很清晰:接收客户端传来的refresh_token,验证它是有效且未过期的,然后删除旧refresh_token,签发新的access_token和refresh_token,返回给客户端。

public function refresh(Request $request) { $refreshToken = $request->post('refresh_token', ''); if ($refreshToken === '') { return $this->json(40100, '缺少refresh_token'); } $userId = $this->redis->get('refresh_token:' . $refreshToken); if (!$userId) { return $this->json(40104, 'refresh_token已失效,请重新登录'); } $user = $this->getUserById($userId); if (!$user) { return $this->json(40104, '用户不存在或已禁用'); } // 删除旧refresh_token,签发新的 $this->redis->del('refresh_token:' . $refreshToken); $newAccessToken = $this->createAccessToken($user); $newRefreshToken = $this->createRefreshToken($user); return $this->json(0, 'ok', [ 'access_token' => $newAccessToken, 'refresh_token' => $newRefreshToken, 'expires_in' => 7200 ]); }

关于refresh_token有一个安全上的细节必须提醒:一定要做轮换,即每次刷新时旧的refresh_token作废,生成新的。如果不做轮换,一个泄露的refresh_token可以无限期换取新Token,等于长期后门。轮换之后,攻击者用旧refresh_token刷新时,服务端直接判定失效,用户需要重新登录。

有一个容易被忽视的坑:刷新接口本身要不要鉴权。我见过有人把/api/auth/refresh也挂在了auth中间件后面,结果access_token过期后,想通过refresh_token换新Token,结果refresh接口自己也要求先通过auth认证,死循环了。正确的做法是:refresh接口单独使用一个轻量的中间件,只解析和验证refresh_token,不要求access_token。

4. 业务接口对接:病案审核列表、状态流转与操作审计

4.1 待审核列表接口:分页、筛选与权限隔离

鉴权链路通了之后,剩下的就是业务接口的活了。病案审核系统里最常用的接口是"待审核列表",医生提交的病案进入待审状态后,审核员要能在页面里分页查看,并按科室、病案类型、提交时间筛选。

接口参数设计如下:

参数类型必填说明
pageint否页码,默认1
page_sizeint否每页条数,默认20,最大100
department_idint否科室ID,按科室筛选
statusstring否状态:pending/reviewing
keywordstring否病案号/患者姓名模糊搜索

权限隔离必须做在SQL层,而不是在PHP层用foreach过滤。审核员只能看到自己科室范围内的待审病案,管理员的权限可以全量查看。我写的SQL大致是这种模式:

$query = "SELECT * FROM medical_cases WHERE status = :status "; $params = ['status' => 'pending']; if ($user['role'] !== 'admin') { $query .= "AND department_id = :dept_id "; $params['dept_id'] = $user['department_id']; } if (!empty($keyword)) { $query .= "AND (case_no LIKE :kw OR patient_name LIKE :kw) "; $params['kw'] = '%' . $keyword . '%'; } $query .= "ORDER BY created_at DESC LIMIT :limit OFFSET :offset";

这里值得展开说一下page_size限制的必要性。我遇到过某个第三方系统对接时,不传分页参数,一次拉全表,上万条病案记录直接灌回前端,页面卡死不说,数据库连接也被拖垮。后来我在参数校验里强制page_size不超过100,超过直接返回参数错误。客户端设计合理的话,根本不会触达这个上限。

4.2 审核提交接口:状态机是核心

审核提交接口是最容易出现逻辑漏洞的地方。病案从提交到审核完成,状态流转是有严格顺序的,一个病案不可能从"待审核"直接跳到"已归档",中间必须经过"审核中"或"审核完成"。

我定义的状态机如下:

  • draft:医生填写中,未提交
  • pending:已提交,待审核
  • reviewing:审核中,被某个审核员领取
  • approved:审核通过
  • rejected:审核驳回,需修改后重新提交
  • archived:已归档

状态流转规则用代码显式定义,控制器里不允许直接写status = 'approved'绕过状态机。

public function review(Request $request, int $caseId) { $action = $request->post('action', ''); $comment = $request->post('comment', ''); if (!in_array($action, ['approve', 'reject'])) { return $this->json(50001, '无效的审核操作'); } $caseRecord = $this->getMedicalCaseById($caseId); if (!$caseRecord) { return $this->json(50002, '病案不存在'); } if (!in_array($caseRecord['status'], ['pending', 'reviewing'])) { return $this->json(50003, '当前状态不允许审核'); } $newStatus = $action === 'approve' ? 'approved' : 'rejected'; // 操作日志 $this->auditLog->record( $request->user['id'], 'review_medical_case', $caseId, $action, $comment ); $this->updateCaseStatus($caseId, $newStatus, $comment); return $this->json(0, 'ok'); }

这个问题我强调了很多次:审核业务的状态流转必须让代码自己说了算。如果前端可以直接传status字段来修改病案状态,那意味着一个知道接口结构的普通用户就能把病案改成已审核,这已经不是技术问题而是合规问题了。

4.3 操作审计与关键敏感字段脱敏

既然是医疗数据,操作审计是必须做的。每一次审核操作、每一次查看病案详情,都要留下记录。审计日志字段要包含:操作人ID、操作人姓名、操作时间、操作类型、病案ID、请求IP、请求User-Agent。

响应数据里的脱敏处理也很重要。比如患者的手机号和身份证号,在病案审核列表里根本不需要完整展示,应该脱敏后再返回:

private function desensitizePatientInfo(array $caseRecord): array { if (isset($caseRecord['patient_phone'])) { $caseRecord['patient_phone'] = substr($caseRecord['patient_phone'], 0, 3) . '****' . substr($caseRecord['patient_phone'], -4); } if (isset($caseRecord['patient_id_card'])) { $caseRecord['patient_id_card'] = substr($caseRecord['patient_id_card'], 0, 6) . '********' . substr($caseRecord['patient_id_card'], -4); } return $caseRecord; }

脱敏逻辑集中在一个地方,不要散落在各控制器的业务代码里。否则前端不同页面需要的脱敏规则不一致,今天改一处明天改一处,迟早出事。

5. 对接期踩坑实录:Postman联调、401定位与Token失效排查

5.1 Postman模拟登录并自动携带Token

联调阶段,用Postman测试接口是每天的必做动作。很多新手最容易卡在"怎么模拟登录后调用业务接口"这一步。我分享一下我习惯的配置方式。

第一步,新建一个登录请求,POST到/api/auth/login,Body里填账号密码。第二步,在Tests标签页里写脚本,登录成功后自动把Token保存到环境变量:

var jsonData = pm.response.json(); if (jsonData.code === 0) { pm.environment.set("access_token", jsonData.data.access_token); pm.environment.set("refresh_token", jsonData.data.refresh_token); } else { console.log("登录失败: " + jsonData.message); }

第三步,在业务接口的Authorization标签页里,类型选Bearer Token,Token填{{access_token}}。这样每个业务请求都会自动带上登录拿回来的Token,不用每次手动复制粘贴。

Postman还有个实用的功能:集合级脚本。如果把登录请求放在集合的Before Run脚本里,可以做到跑整个集合前先自动登录一次,然后所有测试用例共享同一个Token。这在回归测试时能省大量时间。

5.2 401/403的快速定位方法

对接过程中最烦的就是一连串的401。我总结了一套快速定位方法,按顺序排查:

  1. 看请求头里Authorization是否存在,以及格式是否是Bearer <token>。很多人会漏掉Bearer前缀,写成Token eyJxxx,服务端正则匹配不上,直接401。
  2. 把Token复制到jwt.io解码,看Payload里的exp时间是否已经过期。JWT的时间戳都是Unix秒,记得换算成本地时间。
  3. 用同一个密钥在本地对Token重新签名,和服务端返回的signature片段比对,如果不同,大概率是两边密钥不一致。
  4. 查服务端日志,看具体是哪一层拦截的。我把鉴权中间件里的失败原因都写进了日志,包括是"Header缺失"、"验签失败"还是"Token被吊销",客户端报401时让联调方把服务端日志对应的Request ID发过来,比双方瞎猜效率高得多。

403的问题一般是权限不够,回顾上一节的角色路由白名单基本能定位。最常见的原因:测试账号的角色是doctor,却去访问只允许reviewer的接口。

5.3 三个隐蔽的Token失效场景

Token失效的三个隐蔽原因,如果没有人踩过坑很难想到。

第一个是服务器时间偏差。JWT的iat和exp都是Unix时间戳,如果应用服务器的系统时间比真实时间快了5分钟,签发出来的Token在客户端眼里有效期就缩短了5分钟。我遇到过一台服务器时间长了3分钟,导致用户总在操作到一半时突然过期。解决办法是部署服务时统一用NTP对时,这个不起眼但很关键。

第二个是多环境密钥不一致。本地开发环境、测试环境、生产环境各自生成了一套JWT密钥,结果测试人员用测试环境的Token去访问生产环境接口,直接401。排查了半天,最后发现是配置中心里三个环境的JWT_SECRET不一样。我现在的习惯是:密钥统一放在环境变量或配置中心管理,生成密钥后写入部署流水线,任何环境都不能从代码库里读密钥。

第三个是修改密码后旧Token依然有效。用户改了密码,之前签发的Token理论上应该立即作废,否则旧Token还是能操作账户。解决思路是在用户表加一个token_version字段,每次修改密码或管理员禁用用户时token_version加1,签发JWT时把token_version放进Payload,鉴权时拿Payload里的版本和数据库里的最新版本比对,不一致就拒绝。这个机制成本低、见效快,强烈建议做进去。

5.4 跨域与联调日志

医院的系统有时候前端页面部署在和API不同的域名下,Web页面调用接口时会触发跨域。PHP处理跨域的核心是CORS中间件,处理逻辑如下:

public function handle(Request $request, Closure $next) { $response = $next($request); $response->header('Access-Control-Allow-Origin', $this->getAllowOrigin($request)); $response->header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); $response->header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); $response->header('Access-Control-Allow-Credentials', 'true'); return $response; }

有个细节:Access-Control-Allow-Origin不要写死成*,因为带Authorization头的请求使用通配符域名时会出问题。需要根据请求的Origin动态判断,白名单里通过了才返回对应的Origin。

跨域预检请求OPTIONS也必须正常返回200。很多对接方问我为什么浏览器报跨域,检查后发现OPTIONS请求根本没有到达PHP,被Nginx拦了或返回了错误状态码。Nginx层要对OPTIONS请求放行,这是排查跨域问题第一个要看的地方。

日志这块再说一句:对接期间只要遇到问题,我第一步就是看请求日志。请求日志必须带上Request ID、用户ID、接口路径、请求参数摘要、状态码、耗时。有了这些信息,很多问题一看就能定位。不要等到出问题了再去加日志,那样只能靠猜。

6. 收尾前最后一件事:把登录接口的性能和安全再压一遍

登录接口和鉴权中间件作为所有业务请求的入口,性能上有个很实际的问题需要评估:每次请求验签时hash_hmac的耗时。实测下来,一次HS256验签大约在0.1毫秒以内,对常规业务系统来说无感知。但这不意味着可以忽视数据库查询,中间件里每次请求要查一次用户表,如果用户表很大且没走索引,高并发时压力会明显。

我在用户表给id和username建了主键和唯一索引,中间件里查userId用的就是主键查询,性能没问题。但对于登录接口,username列的唯一索引是必须的,否则查询账号时会全表扫描。

压测方面,我用一个简单的并发脚本模拟了50个用户同时登录,每个会话反复请求待审核列表。观察到的结论是:瓶颈不在JWT验签,而在数据库连接数和业务查询复杂度上。这也给了我们一个方向——如果将来病案量大了,列表接口必须走合理的数据库索引,status和department_id都要建立联合索引,否则审核高峰期会出现慢查询。

安全上还要补两个点:一是登录接口要防止SQL注入,所有参数必须走PDO预处理,不要用字符串拼SQL;二是所有涉及病案数据的响应体,响应头应当加上Cache-Control: no-store,防止浏览器缓存敏感数据。这两个细节不复杂,但出事时都是大事。

做完整套对接,我最大的感触是:Token鉴权本身并不难,难的是把鉴权和业务需求、合规要求揉在一起设计。病案审核系统数据敏感、权限层级多、使用场景复杂,每一条设计决策都得考虑操作人员会不会被卡脖子,数据会不会被不该看的人看到。如果你也想在自己的系统里做这套API鉴权,建议先从登录接口和中间件跑通,再加上refresh_token和角色权限,一步一步来,每一步都验证过再往下走。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询