一次一密临时票据:医疗跨系统SSO的安全设计方案

在医疗信息化建设中,跨系统集成(例如 HIS/EMR 嵌入移动护理系统的体温单、评估单等页面)是一个极具代表性的业务场景。然而,如何安全、优雅、轻量化地实现系统间的单点登录与数据隔离,往往是研发过程中的难点。

本文将结合具体项目实践,探讨一种基于 "一次性临时票据(Short-lived Single-use Ticket)" 的跨系统安全集成设计方案。


1. 痛点:明文凭证 URL 拼接的"硬伤"

在许多传统的系统集成设计中,最简单粗暴的做法是直接在 Iframe 嵌入链接中拼接明文的用户名与密码:

javascript 复制代码
// ❌ 不推荐的安全隐患设计
const targetUrl = `${url}common/login?user=cs&psd=123&hiId=3&bingquId=101&components=SchemaDemo&visitno=P00182&readonly=1`;

这种方案虽然能够快速跑通业务,但在生产环境中会带来一系列重大安全漏洞:

  1. 敏感凭证泄露 :明文密码 psd=123 暴露在 URL 中,会永久驻留在浏览器的历史记录、代理网关(如 Nginx)日志、以及 HTTP 请求的 Referer 头中。
  2. 纵向/横向越权(URL 篡改) :恶意用户或患者可以直接通过修改 URL 中的 visitno(住院号)或 readonly=0 权限,绕过鉴权直接查看或篡改其他患者的医疗敏感数据。
  3. 缺乏精细化审计:使用公用的弱口令账户,在日志和审计中无法溯源到底是第三方系统的哪位医护人员进行了访问和操作。

2. 核心设计:基于 Redis 的"用后即焚"票据机制

为了从根本上消除上述风险,我们引入了动态临时票据(Ticket)机制 。其核心理念是:"一次一密,限时失效,用后即焚"

2.1 架构设计与时序图

整个单点登录过程由第三方系统(HIS/EMR后端)移动护理后端(NIS Server) 、**浏览器(Iframe)移动护理前端(NIS Client)**协同完成:
移动护理前端 (React App) 浏览器 (Iframe) 移动护理服务端 (NIS API) 第三方系统 (HIS/EMR 服务端) 移动护理前端 (React App) 浏览器 (Iframe) 移动护理服务端 (NIS API) 第三方系统 (HIS/EMR 服务端) 步骤 1:后台会话初始化与票据生成 服务端双向鉴权 (AppKey/Secret 校验) 步骤 2:前端安全加载与会话建立 步骤 3:票据校验与"用后即焚" alt [Ticket 存在且未失效] [Ticket 不存在或已失效] 步骤 4:直连重定向 POST /api/auth/getShareTicket (携带当前操作人、患者ID、目标组件、权限等) 1 生成随机 Ticket UUID 缓存至 Redis (TTL = 60s) 2 返回票据 ticket 3 渲染 Iframe,传入 ticket `common/login?ssoTicket=UUID` 4 加载登录承载页 5 POST /api/auth/loginByTicket (验证票据) 6 从 Redis 读取 ticket 数据 7 立即从 Redis 删除该 Ticket 8 为操作用户生成本地 Session/JWT 登录态 9 返回登录成功 + 原始加密透传参数 10 返回 401 认证失败 11 路由跳转至目标组件页面 `/inpNurse/SchemaDemo?visitno=P00182&readonly=1` 12


3. 前后端代码改造指南

3.1 后端改造:票据的生命周期管理

后端需实现两个核心接口。

接口一:票据初始化生成 (getShareTicket)

该接口仅允许两端服务端通信(HIS 后台 -> NIS 后台),需要对 HIS 的 AppKey 和 Signature 进行严苛校验。

java 复制代码
// 模拟 Java Spring Boot 控制器伪代码
@PostMapping("/api/auth/getShareTicket")
public ResponseEntity<?> getShareTicket(@RequestBody ShareTicketRequest request, @RequestHeader("Authorization") String authHeader) {
    // 1. 签名与安全校验
    if (!authService.verifyThirdPartySignature(authHeader, request)) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid signature");
    }

    // 2. 生成 32 位随机 UUID 票据
    String ticket = UUID.randomUUID().toString().replace("-", "");

    // 3. 将集成的业务上下文参数存入 Redis,设置 60 秒有效期
    String redisKey = "nis:share_ticket:" + ticket;
    redisTemplate.opsForValue().set(redisKey, request, 60, TimeUnit.SECONDS);

    // 4. 返回票据给 HIS
    return ResponseEntity.ok(new ShareTicketResponse(ticket));
}
接口二:票据换取 Token (loginByTicket)

该接口由浏览器端(Iframe 内的移动护理前端)发起,验证票据有效性,并在成功后立即作废票据。

java 复制代码
@PostMapping("/api/auth/loginByTicket")
public ResponseEntity<?> loginByTicket(@RequestBody Map<String, String> body) {
    String ticket = body.get("ticket");
    String redisKey = "nis:share_ticket:" + ticket;

    // 1. 从 Redis 读取
    ShareTicketRequest ticketContext = (ShareTicketRequest) redisTemplate.opsForValue().get(redisKey);
    if (ticketContext == null) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("票据无效或已失效");
    }

    // 2. 关键:"用后即焚",防止重放攻击
    redisTemplate.delete(redisKey);

    // 3. 登录授权逻辑(免密为指定用户生成 Session 或 JWT 令牌)
    UserSession session = authService.createSessionForUser(ticketContext.getUser());

    // 4. 返回登录凭证及原先保存在票据中的业务参数
    Map<String, Object> result = new HashMap<>();
    result.put("userMessage", session);
    result.put("params", ticketContext); // 透传业务参数:visitno, components 等

    return ResponseEntity.ok(result);
}

3.2 前端改造:React 单点登录分发器

在移动护理的前端入口登录组件(如 login/index.jsx)中,当路由匹配到 ssoTicket 时,自动拦截并执行单点认证逻辑:

javascript 复制代码
// login/index.jsx 周期改造
componentDidMount() {
  const params = getUrlOptions();

  // 检测到第三方单点登录票据
  if (params && params['ssoTicket']) {
    this.props.loginByTicket({ ticket: params['ssoTicket'] })
      .then(res => {
        if (!res) {
          this.props.history.push('/common/userNull');
          return;
        }

        const { userMessage, params: ssoParams } = res;

        // 1. 本地存储会话(避免对标准主应用缓存产生污染,建议存入集成专用 Key)
        localStorage.setItem('userMessage360', JSON.stringify(userMessage));
        localStorage.removeItem('psw');

        // 2. 并行拉取字典和病区数据
        this.props.getAllDicAndDepart({ inHospitalStatus: 0 }).then(dicRes => {
          if (dicRes) {
            Dictionary.setAllDic(dicRes.dictionaryResponse);
            Dictionary.setNurseList(dicRes.nurseDepartResponses);
            Dictionary.setDepart(dicRes.departResponse);
          }

          // 3. 构建附加透传参数
          let extraUrlStr = '';
          for (let key in ssoParams) {
            if (!['user', 'hiId', 'bingquId', 'components', 'visitno', 'readonly'].includes(key)) {
              extraUrlStr += `&${key}=${ssoParams[key]}`;
            }
          }

          // 4. 动态路由分发
          const { components, bingquId, visitno, readonly } = ssoParams;
          const basePath = `/inpNurse/${components}`;
          
          if (visitno) {
            // 公共评估单组件特殊路径处理
            if (components.indexOf("@") > -1 || components.indexOf("*") > -1) {
              let menu = components.split("-");
              this.props.history.push(`/inpNurse/${menu[0]}?components=${menu[1]}&bingquId=${bingquId}&visitno=${visitno}&readonly=${readonly}${extraUrlStr}`);
            } else {
              this.props.history.push(`${basePath}?components=${components}&bingquId=${bingquId}&visitno=${visitno}&readonly=${readonly}${extraUrlStr}`);
            }
          } else {
            this.props.history.push(`${basePath}?bingquId=${bingquId}&readonly=${readonly}${extraUrlStr}`);
          }
        });
      })
      .catch(err => {
        console.error('SSO Ticket Auth Error:', err);
        this.props.history.push('/common/userNull');
      });
  }
}

3.3 内部多 Iframe 渲染的联动优化

在多组件嵌套预览页面(如 nursingPreview/index.jsx)中,原本菜单切换会高频加载 /common/login?user=cs&psd=123... 去反复做自动登录。

在方案一落地后,由于主 Iframe 已经被注入了 userMessage360 缓存,子级多组件 iframe 无需再次走任何登录接口。可以直接修改为直接的组件路由访问:

diff 复制代码
- // 改造前 (含有明文 user & psd)
- const targetUrl = `${url}common/login?user=cs&psd=123&hiId=3&bingquId=${params.bingquId}&components=SchemaDemo&visitno=${params.visitno}&readonly=1&source=1&irtCode=${item.irtCode}`;

+ // 改造后 (同域直连业务路由,完全不携带登录凭证,浏览器共享 Cookie/Storage 登录态)
+ const targetUrl = `${url}inpNurse/SchemaDemo?components=SchemaDemo&bingquId=${params.bingquId}&visitno=${params.visitno}&readonly=1&source=1&irtCode=${item.irtCode}`;

4. 方案的安全性深度剖析

通过上述改造后,该方案具备了以下几项极强的安全保障:

攻击类型 传统明文方案 临时票据方案 (Ticket) 防护原理说明
URL 参数篡改 ❌ 容易 ✅ 免疫 即使攻击者在 iframe url 中篡改参数,由于后端校验只认可 Redis 里票据绑定的初始值,篡改亦无效。
重放/暴力破解 ❌ 极易破解 ✅ 免疫 票据由 Redis 托管并实施了 Read & Delete 机制。即使票据在网络传输中被拦截,由于它已经被消费一次并被物理删除,第二次再提交已失效。
持久泄露 ❌ 长期暴露在网络日志中 ✅ 免疫 只有 60 秒有效期,即便历史日志记录了 Ticket UUID,过时后也是一串无用的随机数。

5. 总结

在医疗、金融等对安全性要求极高的行业中,明文或静态口令的传输是红线。本文提供的基于临时票据的单点登录方案,既保证了第三方系统无需感知复杂的 OAuth2 全套认证协议,又实现了"用后即焚"的安全防御,为跨系统应用集成提供了一种优雅、安全的工业级标准解决方案。

相关推荐
Likeadust12 小时前
私有化视频会议系统/智能会议管理系统EasyDSS集群通话助力各行业安全高效远程协作
安全
审判长烧鸡14 小时前
【Go工具】go-playground是什么组织?官方的?
开发语言·安全·go
JiaWen技术圈14 小时前
网站用户注册行为验证码方案
运维·安全
百度智能云技术站14 小时前
百度 Agent 安全中心:构筑企业智能体的安全底座
人工智能·安全·dubbo
视觉&物联智能15 小时前
【杂谈】-企业人工智能超越实验:安全拓展的实践路径
人工智能·安全·aigc·agent·agi
KnowSafe16 小时前
2026年SSL证书市场便宜且安全的SSL证书调研
网络协议·安全·ssl
@insist12316 小时前
信息安全工程师-云计算安全核心知识框架
安全·云计算·软考·信息安全工程师·软件水平考试
GMH7896617 小时前
1600W防水型对流电散热器,实用又安全吗?
安全·冀明昊暖气片·暖气片厂家·河北暖气片厂家·对流电散热器
志栋智能18 小时前
超自动化巡检:为智能运维(AIOps)铺平道路
运维·安全·自动化