第三方系统登录 — 完整实现方案笔记

lishihuan大约 18 分钟

第三方系统登录 — 完整实现方案笔记

本文档基于 legacy-cloud 项目实际代码整理。

场景定义:本系统作为主系统(认证提供方),已登录本系统的用户,可通过 Ticket 凭证免登录跳转到第三方系统,第三方系统通过校验 Ticket 获取用户信息后完成自身登录。

文档末尾附《对接方接入说明文档》,可直接交给第三方对接人员使用。


目录


1. 整体架构说明

本系统(主系统)                                  第三方系统
┌─────────────────────────────┐                ┌────────────────────────────┐
│  前端                        │                │  前端                       │
│  - 已登录用户点击跳转菜单      │                │  - 接收带 ticket 的跳转      │
│  - 调用 getUserTicket 获取凭证│                │  - 将 ticket 传给自身后端    │
└──────────┬──────────────────┘                └──────────┬─────────────────┘
           │                                              │
           ▼                                              ▼
┌─────────────────────────────┐                ┌────────────────────────────┐
│  后端(sys-system 模块)      │                │  后端                       │
│  TicketController            │◀───────────────│  - 调用 /ticket/validate    │
│  - getUserTicket             │  校验请求        │  - 获取用户信息              │
│  - validate                  │───────────────▶│  - 完成自身登录              │
└──────────┬──────────────────┘  返回用户信息    └────────────────────────────┘
           │
           ▼
┌─────────────────────────────┐
│  基础设施                    │
│  - Redis:存储加密 Ticket    │
│    TTL = 60s                │
│  - MySQL:存储第三方应用信息  │
│    表:sys_third_client_accredit │
└─────────────────────────────┘

涉及模块:

模块路径职责
sys-gatewaysys-gateway/网关鉴权、将 /ticket/validate 加入白名单放行
sys-systemsys-modules/sys-system/Ticket 凭证生成与校验、第三方应用授权管理

2. 完整实现流程

2.1 时序流程图

本系统用户(已登录)      本系统后端(TicketController)      第三方系统后端
       │                           │                              │
       │ ① GET /ticket/getUserTicket│                              │
       │  (需携带 Authorization)   │                              │
       │──────────────────────────▶│                              │
       │                           │ 从 SecurityContext 取 OAuth2 Token
       │                           │ 组装明文:token,时间,userId
       │                           │ SM4-ECB 加密
       │                           │ 生成 UUID → 存 Redis(TTL 60s)
       │◀── 返回 UUID(即 ticket) ──│
       │                           │                              │
       │ ② 浏览器跳转第三方系统地址   │                              │
       │  URL 携带 ?ticket=UUID      │                              │
       │──────────────────────────────────────────────────────▶  │
       │                           │                              │
       │                           │ ③ GET /system/ticket/validate │
       │                           │     ?ticket=UUID&appId=xxx   │
       │                           │◀─────────────────────────────│
       │                           │                              │
       │                           │ step1 appId 非空校验           │
       │                           │ step2 ticket 非空校验          │
       │                           │ step3 查库验证 appId 是否启用   │
       │                           │ step4 Redis get(ticket) 非空  │
       │                           │ step5 SM4 解密,获取明文        │
       │                           │ step6 TokenStore 验证 Token   │
       │                           │       是否仍有效               │
       │                           │ step7 查询用户信息              │
       │                           │─────────────────────────────▶│
       │                           │  返回 {userName,name,deptId,  │
       │                           │   deptName,businessDeptName}  │
       │                           │                              │
       │                           │              ④ 第三方系统完成自身登录

2.2 数据库设计

表:sys_third_client_accredit(第三方应用授权表)

CREATE TABLE `sys_third_client_accredit` (
  `ID`          BIGINT(20)    NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `APP_ID`      VARCHAR(64)   NOT NULL COMMENT '应用ID(UUID自动生成,颁发给第三方)',
  `APP_NAME`    VARCHAR(100)  NOT NULL COMMENT '应用名称',
  `STATUS`      CHAR(1)       NOT NULL DEFAULT '1' COMMENT '状态(0:停用 1:正常)',
  `REMARK`      VARCHAR(500)  DEFAULT NULL COMMENT '备注说明',
  `DEL_FLAG`    CHAR(1)       NOT NULL DEFAULT '0' COMMENT '删除标志(0:在用 1:删除)',
  `CREATE_BY`   VARCHAR(64)   DEFAULT NULL COMMENT '创建人ID',
  `CREATE_DATE` DATETIME      DEFAULT NULL COMMENT '创建日期',
  `UPDATE_BY`   VARCHAR(64)   DEFAULT NULL COMMENT '更新人ID',
  `UPDATE_DATE` DATETIME      DEFAULT NULL COMMENT '最后修改日期',
  PRIMARY KEY (`ID`),
  UNIQUE KEY `uk_app_id` (`APP_ID`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方系统授权表';

APP_ID 在新增时由后端自动调用 UuidUtils.generateUuid() 生成,前端无需传入,新增完成后将 appId 告知第三方对接人员。

2.3 后端实现代码

2.3.1 实体类 SysThirdClientAccredit

// 路径:sys-modules/sys-system/src/main/java/cn/semdo/system/domain/SysThirdClientAccredit.java
package cn.semdo.system.domain;

import cn.semdo.common.core.web.domain.BaseEntity;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import java.util.Date;

/**
 * 第三方系统授权实体
 * 对应数据库表 sys_third_client_accredit
 */
public class SysThirdClientAccredit extends BaseEntity {
    private static final long serialVersionUID = 1L;

    /** 主键 */
    private Long id;

    /**
     * 应用ID(UUID,由后端自动生成,颁发给第三方)
     * 新增时不需要前端传,saveData 方法会自动生成
     */
    @Size(max = 64)
    private String appId;

    /** 应用名称 */
    @NotBlank(message = "应用名称不能为空")
    @Size(max = 100)
    private String appName;

    /**
     * 状态:0=停用,1=正常
     * 注意:当前 getAppStatusByAppId 以 delFlag=0 作为可用判断
     * 如需 status 生效,需在 Service 层额外判断
     */
    @NotBlank(message = "状态不能为空")
    private String status;

    /** 删除标志:0=在用,1=已删除(逻辑删除) */
    @NotBlank(message = "删除标志不能为空")
    private String delFlag;

    private Date createDate;
    private Date updateDate;

    // getter/setter 省略...
}

2.3.2 Mapper 接口与 XML

Mapper 接口(SysThirdClientAccreditMapper.java):

// 路径:sys-modules/sys-system/src/main/java/cn/semdo/system/mapper/SysThirdClientAccreditMapper.java
public interface SysThirdClientAccreditMapper {
    SysThirdClientAccredit selectSysThirdClientAccreditById(@Param("id") Long id);
    List<SysThirdClientAccredit> selectSysThirdClientAccreditList(SysThirdClientAccredit sysThirdClientAccredit);
    int insertSysThirdClientAccredit(SysThirdClientAccredit sysThirdClientAccredit);
    int updateSysThirdClientAccredit(SysThirdClientAccredit sysThirdClientAccredit);
    int deleteSysThirdClientAccreditById(@Param("id") Long id, @Param("delFlag") String delFlag);
    int deleteSysThirdClientAccreditByIds(@Param("ids") Long[] ids, @Param("delFlag") String delFlag);
}

Mapper XML(SysThirdClientAccreditMapper.xml):

<!-- 路径:sys-modules/sys-system/src/main/resources/mapper/system/SysThirdClientAccreditMapper.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.semdo.system.mapper.SysThirdClientAccreditMapper">

    <resultMap type="SysThirdClientAccredit" id="SysThirdClientAccreditResult">
        <result property="id"         column="ID"/>
        <result property="appId"      column="APP_ID"/>
        <result property="appName"    column="APP_NAME"/>
        <result property="status"     column="STATUS"/>
        <result property="remark"     column="REMARK"/>
        <result property="delFlag"    column="DEL_FLAG"/>
        <result property="createBy"   column="CREATE_BY"/>
        <result property="createDate" column="CREATE_DATE"/>
        <result property="updateBy"   column="UPDATE_BY"/>
        <result property="updateDate" column="UPDATE_DATE"/>
    </resultMap>

    <sql id="selectSysThirdClientAccreditVo">
        select a.ID, a.APP_ID, a.APP_NAME, a.STATUS, a.REMARK,
               a.DEL_FLAG, a.CREATE_BY, a.CREATE_DATE, a.UPDATE_BY, a.UPDATE_DATE
        from sys_third_client_accredit a
    </sql>

    <!-- 列表查询,支持按 appId / appName / status 过滤 -->
    <select id="selectSysThirdClientAccreditList"
            parameterType="SysThirdClientAccredit"
            resultMap="SysThirdClientAccreditResult">
        <include refid="selectSysThirdClientAccreditVo"/>
        <where>
            a.del_flag = #{delFlag}
            <if test="appId != null and appId != ''">#{appId},</if>
            <if test="appName != null and appName != ''">#{appName},</if>
            <if test="status != null and status != ''">#{status},</if>
            <if test="remark != null">#{remark},</if>
            <if test="delFlag != null and delFlag != ''">#{delFlag},</if>
            <if test="createBy != null">#{createBy},</if>
            <if test="createDate != null">#{createDate},</if>
            <if test="updateBy != null">#{updateBy},</if>
            <if test="updateDate != null">#{updateDate},</if>
        </trim>
    </insert>

    <update id="updateSysThirdClientAccredit" parameterType="SysThirdClientAccredit">
        update sys_third_client_accredit
        <trim prefix="SET" suffixOverrides=",">
            <if test="appName != null and appName != ''">APP_NAME = #{appName},</if>
            <if test="status != null and status != ''">STATUS = #{status},</if>
            <if test="remark != null">REMARK = #{remark},</if>
            <if test="updateBy != null">UPDATE_BY = #{updateBy},</if>
            <if test="updateDate != null">UPDATE_DATE = #{updateDate},</if>
        </trim>
        where ID = #{id}
    </update>

    <!-- 逻辑删除:将 del_flag 更新为 1 -->
    <delete id="deleteSysThirdClientAccreditById">
        update sys_third_client_accredit set del_flag = #{delFlag} where ID = #{id}
    </delete>
    <delete id="deleteSysThirdClientAccreditByIds">
        update sys_third_client_accredit set del_flag = #{delFlag}
        where id in
        <foreach item="id" collection="ids" open="(" separator="," close=")">#{id}</foreach>
    </delete>

</mapper>

2.3.3 Service 接口与实现

接口(ISysThirdClientAccreditService.java):

// 路径:sys-modules/sys-system/src/main/java/cn/semdo/system/service/ISysThirdClientAccreditService.java
public interface ISysThirdClientAccreditService {

    SysThirdClientAccredit selectSysThirdClientAccreditById(Long id);
    List<SysThirdClientAccredit> selectSysThirdClientAccreditList(SysThirdClientAccredit query);
    int insertSysThirdClientAccredit(SysThirdClientAccredit entity);
    int updateSysThirdClientAccredit(SysThirdClientAccredit entity);
    int deleteSysThirdClientAccreditByIds(Long[] ids);
    int deleteSysThirdClientAccreditById(Long id);

    /**
     * 获取详情(id 为空时返回空对象,供前端表单回显使用)
     */
    SysThirdClientAccredit getDataInfo(SysThirdClientAccredit entity);

    /**
     * 新增或修改(id 为空则新增并自动生成 appId,否则按 id 修改)
     */
    void saveData(SysThirdClientAccredit entity);

    /**
     * 根据 appId 查询该应用是否可用
     * @return true=可用,false=不可用(未录入或已逻辑删除)
     */
    boolean getAppStatusByAppId(String appId);
}

实现(SysThirdClientAccreditServiceImpl.java):

// 路径:sys-modules/sys-system/src/main/java/cn/semdo/system/service/impl/SysThirdClientAccreditServiceImpl.java
@Service
@Transactional(readOnly = true)
public class SysThirdClientAccreditServiceImpl implements ISysThirdClientAccreditService {

    @Resource
    private SysThirdClientAccreditMapper sysThirdClientAccreditMapper;
    @Resource
    private UserUtils userUtils;

    @Override
    public SysThirdClientAccredit selectSysThirdClientAccreditById(Long id) {
        return sysThirdClientAccreditMapper.selectSysThirdClientAccreditById(id);
    }

    @Override
    public List<SysThirdClientAccredit> selectSysThirdClientAccreditList(SysThirdClientAccredit query) {
        // 自动补充 delFlag=0,只查未删除的记录
        query.setDelFlag(BaseEntity.DEL_FLAG_NORMAL);
        return sysThirdClientAccreditMapper.selectSysThirdClientAccreditList(query);
    }

    @Override
    @Transactional(readOnly = false)
    public int insertSysThirdClientAccredit(SysThirdClientAccredit entity) {
        SysUser currUser = userUtils.getSysUser();
        entity.setCreateBy(currUser.getUserId().toString());
        entity.setUpdateBy(currUser.getUserId().toString());
        entity.setUpdateTime(DateUtils.getNowDate());
        return sysThirdClientAccreditMapper.insertSysThirdClientAccredit(entity);
    }

    @Override
    @Transactional(readOnly = false)
    public int updateSysThirdClientAccredit(SysThirdClientAccredit entity) {
        entity.setUpdateTime(DateUtils.getNowDate());
        entity.setUpdateBy(userUtils.getSysUser().getUserId().toString());
        return sysThirdClientAccreditMapper.updateSysThirdClientAccredit(entity);
    }

    @Override
    @Transactional(readOnly = false)
    public int deleteSysThirdClientAccreditByIds(Long[] ids) {
        return sysThirdClientAccreditMapper.deleteSysThirdClientAccreditByIds(ids, BaseEntity.DEL_FLAG_DELETE);
    }

    @Override
    @Transactional(readOnly = false)
    public int deleteSysThirdClientAccreditById(Long id) {
        return sysThirdClientAccreditMapper.deleteSysThirdClientAccreditById(id, BaseEntity.DEL_FLAG_DELETE);
    }

    @Override
    public SysThirdClientAccredit getDataInfo(SysThirdClientAccredit entity) {
        if (entity.getId() == null) return new SysThirdClientAccredit();
        SysThirdClientAccredit result = selectSysThirdClientAccreditById(entity.getId());
        return result != null ? result : new SysThirdClientAccredit();
    }

    /**
     * 保存:
     * - id 为空 → 新增,自动生成 appId(UUID)
     * - id 不为空 → 修改
     */
    @Override
    @Transactional(readOnly = false)
    public void saveData(SysThirdClientAccredit entity) {
        entity.setUpdateDate(DateUtils.getNowDate());
        if (entity.getId() == null) {
            entity.setAppId(UuidUtils.generateUuid()); // 自动生成 UUID 作为 appId
            entity.setCreateDate(DateUtils.getNowDate());
            insertSysThirdClientAccredit(entity);
        } else {
            updateSysThirdClientAccredit(entity);
        }
    }

    /**
     * 根据 appId 查询应用是否可用。
     * 逻辑:查到记录(delFlag=0)即为可用。
     * 扩展:如需 status 字段生效,可增加 "1".equals(list.get(0).getStatus()) 判断。
     */
    @Override
    public boolean getAppStatusByAppId(String appId) {
        SysThirdClientAccredit query = new SysThirdClientAccredit();
        query.setAppId(appId);
        List<SysThirdClientAccredit> list = selectSysThirdClientAccreditList(query);
        if (CollectionUtils.isEmpty(list)) {
            return false;
        }
        // selectSysThirdClientAccreditList 已过滤 delFlag=0,查到即表示未删除可用
        return true;
    }
}

2.3.4 应用管理 Controller

供后台管理页面维护第三方应用信息,需要本系统登录权限,不对外暴露。

// 路径:sys-modules/sys-system/src/main/java/cn/semdo/system/controller/SysThirdClientAccreditController.java
@RestController
@RequestMapping("/sysThirdClientAccredit")
public class SysThirdClientAccreditController extends BaseController {

    @Resource
    private ISysThirdClientAccreditService sysThirdClientAccreditService;

    /** 分页列表查询 */
    @GetMapping("/list")
    public TableDataInfo list(SysThirdClientAccredit sysThirdClientAccredit) {
        startPage();
        return getDataTable(sysThirdClientAccreditService.selectSysThirdClientAccreditList(sysThirdClientAccredit));
    }

    /** 详情 */
    @GetMapping("/{id}")
    public AjaxResult getInfo(@PathVariable Long id) {
        return AjaxResult.success(sysThirdClientAccreditService.selectSysThirdClientAccreditById(id));
    }

    /** 新增(appId 由后端自动生成,不需前端传) */
    @Log(title = "第三方系统授权", businessType = BusinessType.INSERT)
    @PostMapping
    public AjaxResult add(@Validated @RequestBody SysThirdClientAccredit entity) {
        return toAjax(sysThirdClientAccreditService.insertSysThirdClientAccredit(entity));
    }

    /** 修改 */
    @Log(title = "第三方系统授权", businessType = BusinessType.UPDATE)
    @PutMapping
    public AjaxResult edit(@Validated @RequestBody SysThirdClientAccredit entity) {
        return toAjax(sysThirdClientAccreditService.updateSysThirdClientAccredit(entity));
    }

    /** 逻辑删除 */
    @Log(title = "第三方系统授权", businessType = BusinessType.DELETE)
    @DeleteMapping("/{ids}")
    public AjaxResult remove(@PathVariable Long[] ids) {
        return toAjax(sysThirdClientAccreditService.deleteSysThirdClientAccreditByIds(ids));
    }

    /**
     * 保存(新增/修改合一接口,推荐使用)
     * id 为空时自动新增并生成 appId,否则按 id 修改
     */
    @PostMapping("/saveData")
    public AjaxResult saveData(@RequestBody SysThirdClientAccredit entity) {
        try {
            sysThirdClientAccreditService.saveData(entity);
            return AjaxResult.success();
        } catch (Exception e) {
            return AjaxResult.error("保存失败:" + e.getMessage());
        }
    }
}

2.3.5 Ticket 核心 Controller

这是整个方案的核心,包含两个接口:

  • getUserTicket:本系统前端(已登录用户)调用,获取一次性跳转凭证
  • validate:第三方系统后端调用,用凭证换取用户信息
// 路径:sys-modules/sys-system/src/main/java/cn/semdo/system/controller/TicketController.java
package cn.semdo.system.controller;

import cn.semdo.common.core.constants.CommonConstants;
import cn.semdo.common.core.sm.SM4Utils;
import cn.semdo.common.core.utils.StringUtils;
import cn.semdo.common.core.web.domain.AjaxResult;
import cn.semdo.common.redis.util.UserUtils;
import cn.semdo.system.api.domain.SysDept;
import cn.semdo.system.api.domain.SysUser;
import cn.semdo.system.service.ISysThirdClientAccreditService;
import com.alibaba.nacos.common.utils.UuidUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.authentication.OAuth2AuthenticationDetails;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.TimeUnit;

@Slf4j
@RestController
@RequestMapping("/ticket")
public class TicketController {

    @Resource private UserUtils userUtils;
    @Resource public RedisTemplate<String, String> redisTemplate;
    @Resource private TokenStore tokenStore;
    @Resource private ISysThirdClientAccreditService thirdClientAccreditService;

    // ----------------------------------------------------------------
    // 接口一:获取 Ticket 登录凭证
    // 调用方:本系统已登录的前端(需携带 Authorization: Bearer token)
    // 调用时机:用户点击「跳转到第三方系统」菜单项时
    // ----------------------------------------------------------------
    @GetMapping("getUserTicket")
    public String getUserTicket() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication instanceof OAuth2Authentication) {
            Object details = authentication.getDetails();
            if (details instanceof OAuth2AuthenticationDetails) {
                OAuth2AuthenticationDetails detail = (OAuth2AuthenticationDetails) details;

                String tokenValue = detail.getTokenValue();  // 当前用户的 OAuth2 Token
                String dateStr    = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
                Long   userId     = userUtils.getLoginUser(authentication).getUserId();

                // 明文格式固定为:token,时间,userId(逗号分隔,validate 解析时按下标取值)
                String plainText      = tokenValue + "," + dateStr + "," + userId;
                String encryptedValue = SM4Utils.encryptData_ECB(plainText, CommonConstants.TICKET_SECRET_KEY);

                if (encryptedValue != null) {
                    String uuid = UuidUtils.generateUuid(); // UUID 作为 Redis key,也是返回给前端的 ticket
                    // 存入 Redis,TTL = 60 秒
                    redisTemplate.opsForValue().set(uuid, encryptedValue, 60, TimeUnit.SECONDS);
                    return uuid;
                }
            }
        }
        throw new RuntimeException("获取 Ticket 失败:用户认证信息异常");
    }

    // ----------------------------------------------------------------
    // 接口二:校验 Ticket,返回用户信息
    // 调用方:第三方系统后端(无需 Authorization 头,网关已白名单放行)
    // 接口地址:GET /system/ticket/validate?ticket=xxx&appId=xxx
    // ----------------------------------------------------------------
    @GetMapping("validate")
    public AjaxResult validate(String ticket, String appId) {

        // step1:appId 非空校验
        if (appId == null || appId.isEmpty()) {
            log.error("[Ticket校验] APPID为空, ticket={}", ticket);
            return new AjaxResult(10000, "APPID为空!");
        }
        // step2:ticket 非空校验
        if (ticket == null || ticket.isEmpty()) {
            log.error("[Ticket校验] 令牌为空, appId={}", appId);
            return new AjaxResult(10001, "令牌为空!");
        }
        // step3:查库校验 appId 是否启用
        if (!thirdClientAccreditService.getAppStatusByAppId(appId)) {
            log.error("[Ticket校验] 应用不可用, appId={}, ticket={}", appId, ticket);
            return new AjaxResult(10002, "应用不可用!");
        }
        // step4:从 Redis 取加密内容(Redis key TTL 到期后 get 返回 null)
        String encryptedValue = redisTemplate.opsForValue().get(ticket);
        if (StringUtils.isBlank(encryptedValue)) {
            log.error("[Ticket校验] 令牌已失效, appId={}, ticket={}", appId, ticket);
            return new AjaxResult(10003, "令牌已失效!");
        }
        // step5:SM4 解密,还原明文
        String plainText = SM4Utils.decryptData_ECB(encryptedValue, CommonConstants.TICKET_SECRET_KEY);
        if (StringUtils.isBlank(plainText)) {
            log.error("[Ticket校验] 令牌解析错误, appId={}, ticket={}", appId, ticket);
            return new AjaxResult(10004, "令牌解析错误!");
        }
        // step6:校验 OAuth2 Token 仍有效(用户未退出登录、token 未过期)
        String[] parts      = plainText.split(","); // [0]=token, [1]=时间, [2]=userId
        String   tokenValue    = parts[0];
        OAuth2AccessToken accessToken = tokenStore.readAccessToken(tokenValue);
        if (accessToken == null || StringUtils.isEmpty(accessToken.getValue())) {
            log.error("[Ticket校验] 用户已离线, appId={}, ticket={}", appId, ticket);
            return new AjaxResult(10005, "当前用户已离线,请重新登录!");
        }
        // step7:查询用户信息,组装返回
        String  userId  = parts[2];
        SysUser sysUser = userUtils.getSysUser(Long.parseLong(userId));

        Map<String, Object> data = new HashMap<>();
        data.put("userName", sysUser.getUserName());
        data.put("name",     sysUser.getNickName());
        data.put("deptId",   sysUser.getDeptId());
        data.put("deptName", sysUser.getDept() == null
                ? null : sysUser.getDept().getDeptName().replaceAll("YJ", ""));

        // 特殊业务:deptType=4 且部门名含「站」时返回业务站名称,否则返回空字符串
        SysDept dept = sysUser.getDept();
        if (dept != null && "4".equals(dept.getDeptType()) && dept.getDeptName().contains("站")) {
            data.put("businessDeptName", dept.getDeptName().replaceAll("YJ", ""));
        } else {
            data.put("businessDeptName", "");
        }

        log.info("[Ticket校验] 成功, appId={}, ticket={}, 生成时间={}", appId, ticket, parts[1]);
        return AjaxResult.success(data);
    }
}

2.4 关键常量与加密工具说明

1. SM4 密钥常量定义

// 位置:sys-common-core 模块 CommonConstants.java
// SM4-ECB 模式密钥必须为 16 字节(128 bit)
public static final String TICKET_SECRET_KEY = "xxxxxxxxxxxxxxxx"; // 替换为实际16位密钥

生产环境不要硬编码密钥,建议通过 Nacos 配置中心读取并加密存储(如使用 Jasypt)。

2. SM4Utils 调用方式

// 加密(生成 Ticket 时使用)
String encrypted = SM4Utils.encryptData_ECB(plainText, CommonConstants.TICKET_SECRET_KEY);

// 解密(校验 Ticket 时使用)
String plain = SM4Utils.decryptData_ECB(encrypted, CommonConstants.TICKET_SECRET_KEY);

3. SM4 简介

SM4 是国密局发布的分组对称加密算法,密钥长度 128 bit,相比 AES 更符合国内等保合规要求。ECB 模式(电码本模式)加解密无需 IV,实现简单,适用于短文本加密场景(如本方案中的 Ticket 内容)。

4. Redis 存储结构

键(Key)值(Value)TTL
{UUID}SM4加密后的 token,时间,userId60 秒

Ticket 过期后 Redis 自动清除,validate 接口 getnull 即返回「令牌已失效」。

2.5 网关白名单放行配置

/ticket/validate 接口由第三方后端直接调用,不携带本系统 Authorization 头,必须在网关白名单中放行,否则网关会拦截并返回 401。

Nacos 网关配置(sys-gateway-[env].yaml):

security:
  oauth2:
    ignore:
      urls:
        - /system/ticket/validate    # 放行:供第三方后端校验 Ticket,无需登录态
        # 其他需放行的接口...

放行原理(AuthIgnoreConfig.java):

// 路径:sys-gateway/src/main/java/cn/semdo/gateway/config/AuthIgnoreConfig.java
// 通过 @ConfigurationProperties 绑定 Nacos 配置,@RefreshScope 支持不重启动态生效
@Component
@RefreshScope
@ConfigurationProperties(prefix = "security.oauth2.ignore")
public class AuthIgnoreConfig {

    /** 忽略认证的 URL 列表(配置后网关不校验 Authorization 头) */
    private List<String> urls = new ArrayList<>();

    /**
     * 不进行 IP 限制的 URL
     * 需同时配置在 urls 列表中才生效
     * 如果想让 /ticket/validate 完全开放(不限 IP),将其同时加入此列表
     */
    private List<String> urlsNoIpLimit = new ArrayList<>();

    /** 是否开启 IP 白名单校验,默认 false */
    private Boolean ipLimitEnabled = false;

    /** IP 白名单,ipLimitEnabled=true 时生效 */
    private List<String> whiteIps = new ArrayList<>();

    // getter/setter...
}

生产建议:若 ipLimitEnabled=true,将 /ticket/validate 加入 urlsNoIpLimit(不限 IP)或将第三方服务器 IP 加入 whiteIps(限制 IP),二选一即可。


3. 复用 Checklist 与注意事项

3.1 下个项目复用步骤

    • SysThirdClientAccredit.java(实体)
    • SysThirdClientAccreditMapper.java + SysThirdClientAccreditMapper.xml
    • ISysThirdClientAccreditService.java + SysThirdClientAccreditServiceImpl.java
    • SysThirdClientAccreditController.java(管理接口)
    • TicketController.java(核心接口)

3.2 安全注意事项

事项说明
Ticket 有效期默认 60 秒,可在 getUserTicket 中调整 TimeUnit.SECONDS 的第三个参数
SM4 密钥保护不要硬编码在代码中,建议放入 Nacos 配置并用 Jasypt 加密
appId 管理第三方停用后,及时将对应记录 del_flag 改为 1status 改为 0
Ticket 一次性当前实现 60s 内可重复校验。如需一次性,在 validate 方法 step4 之后加一行:redisTemplate.delete(ticket)
接口 IP 限制生产环境建议限制只有第三方服务器 IP 可调用 /ticket/validate,在网关 whiteIps 中配置
HTTPS生产环境跳转 URL 和接口调用全部使用 HTTPS,防止 ticket 在传输中被截获

3.3 常见错误排查

错误码 / 现象原因解决方案
10000 APPID为空调用方未传 appId 参数检查请求 URL 中是否携带 appId
10001 令牌为空调用方未传 ticket 参数检查请求 URL 中是否携带 ticket
10002 应用不可用appId 未在 sys_third_client_accredit 中维护或已被逻辑删除后台录入或恢复对应应用记录
10003 令牌已失效Ticket 超过 60s 未使用,Redis key 自动过期重新调用 getUserTicket 获取新凭证;或适当延长 TTL
10004 令牌解析错误SM4 解密失败,通常是两端密钥不一致检查 CommonConstants.TICKET_SECRET_KEY 与生成时一致
10005 用户已离线本系统 OAuth2 Token 已失效(用户退出或 token 过期)提示用户重新登录本系统后再点击跳转
网关返回 401/ticket/validate 未加入网关白名单Nacos 配置 security.oauth2.ignore.urls 中添加该路径
getUserTicket 报 401前端调用时未携带 Authorization: Bearer token确保前端请求头携带本系统有效 token


对接方接入说明文档

文档用途:本文档提供给需要接入本系统的第三方应用,指导其完成免登录跳转对接。

接入方式:基于一次性 Ticket 凭证的跨系统免登录,无需第三方系统与本系统共享账号体系。


一、接入申请

请向本系统管理员提供以下信息,申请接入授权:

信息项说明
应用名称你方系统名称,如「xxx运维管理系统」
系统简介一句话说明系统用途
调用服务器 IP调用 Ticket 校验接口的服务器出口 IP(用于 IP 白名单,若管理员开启了此限制)
对接负责人姓名 + 联系方式

申请通过后,管理员将发放唯一的 appId(UUID 格式),请妥善保管,勿泄露。


二、对接流程(共 4 步)

第一步:本系统用户点击跳转,获取 Ticket

本系统前端在用户点击「跳转到你方系统」时,会自动调用以下接口获取一次性 Ticket:

GET /system/ticket/getUserTicket
Authorization: Bearer {本系统用户token}

响应(直接返回字符串,即 Ticket):

"a1b2c3d4-e5f6-7890-abcd-ef1234567890"

本系统前端随后跳转到你方系统,跳转 URL 携带 ticket 参数

http://你方系统地址/你方登录落地页?ticket=a1b2c3d4-e5f6-7890-abcd-ef1234567890

Ticket 有效期为 60 秒,请收到后立即在后端完成校验,不要缓存或延迟处理。

第二步:你方前端获取 ticket 参数

你方前端(落地页)从 URL 中解析出 ticket 参数,传给你方后端。

// 示例:从 URL 获取 ticket
const urlParams = new URLSearchParams(window.location.search);
const ticket = urlParams.get('ticket');
// 调用你方后端接口,将 ticket 传过去
yourApi.login(ticket);

第三步:你方后端调用本系统校验接口

重要:必须由你方后端服务器发起请求,不能由前端浏览器直接调用(防止 appId 泄露)。

GET http://【本系统网关地址】/system/ticket/validate?ticket={ticket值}&appId={你方appId}

请求参数:

参数名位置类型必填说明
ticketquerystring从跳转 URL 中获取的凭证
appIdquerystring本系统管理员颁发的 APPID

此接口无需 Authorization 请求头,本系统网关已将其加入白名单放行。

成功响应示例(HTTP 200):

{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "userName": "zhangsan",
    "name": "张三",
    "deptId": 103,
    "deptName": "xxxx公司",
    "businessDeptName": "xxx部门"
  }
}

返回字段说明:

字段类型说明
userNamestring用户登录名,系统内唯一标识
namestring用户姓名(昵称)
deptIdinteger部门 ID(冗余存储,可能为空)
deptNamestring所属部门名称
businessDeptNamestring业务部门名;若为业务站人员则返回站名(如「某某站」),否则为空字符串

第四步:你方系统完成登录

你方后端获取到 userName 等信息后,按自身业务逻辑处理:

  1. 查询你方系统中是否存在 userName 对应的用户
  2. 若存在 → 生成你方系统的登录凭证(session/token),返回给前端完成登录
  3. 若不存在 → 提示「账户未关联」,或按需自动注册

三、错误码说明

code含义处理建议
200成功正常处理返回的 data
10000APPID 为空检查请求中是否传入了 appId 参数
10001令牌(ticket)为空检查请求中是否传入了 ticket 参数
10002应用不可用你方 appId 未在本系统维护或已停用,联系管理员
10003令牌已失效ticket 超过 60 秒未使用,需重新触发跳转获取新 ticket
10004令牌解析错误服务端解密异常,联系本系统管理员排查
10005用户已离线本系统侧 token 已失效(用户退出或过期),提示用户重新登录本系统后再跳转

四、对接注意事项

  1. Ticket 尽快使用:60 秒有效,收到后立即调用校验接口,不要缓存或转发给其他服务再处理。
  2. 后端调用/system/ticket/validate 必须由你方后端发起,不能从浏览器直接调用(appId 属于敏感凭证)。
  3. HTTPS 传输:生产环境所有跳转链接及接口调用请使用 HTTPS。
  4. appId 安全:不要将 appId 提交到公开代码仓库,建议通过环境变量或配置中心管理。
  5. IP 白名单:若管理员开启了 IP 限制,请提前确认你方调用服务器的出口 IP 已加入白名单。

五、Java 对接 Demo

// 你方系统 Service 层:调用本系统 Ticket 校验接口
@Service
public class XjbLoginService {

    @Value("${xjb.ticket-validate-url}")  // 如:http://本系统地址/system/ticket/validate
    private String ticketValidateUrl;

    @Value("${xjb.app-id}")               // 管理员颁发的 appId
    private String appId;

    private final RestTemplate restTemplate = new RestTemplate();

    /**
     * 用 ticket 换取本系统用户信息
     *
     * @param ticket 从跳转 URL 获取的凭证
     * @return 用户信息(userName / name / deptId / deptName / businessDeptName)
     */
    public Map<String, Object> validateTicket(String ticket) {
        String url = ticketValidateUrl + "?ticket={ticket}&appId={appId}";
        Map<String, String> uriVars = new HashMap<>();
        uriVars.put("ticket", ticket);
        uriVars.put("appId", appId);

        ResponseEntity<Map> resp = restTemplate.getForEntity(url, Map.class, uriVars);
        Map body = resp.getBody();
        if (body == null) {
            throw new RuntimeException("Ticket 校验接口无响应");
        }
        int code = (Integer) body.get("code");
        if (code != 200) {
            throw new RuntimeException("Ticket 校验失败,code=" + code + ",msg=" + body.get("msg"));
        }
        return (Map<String, Object>) body.get("data");
    }
}
// 你方系统 Controller 层:处理本系统跳转过来的落地页请求
@GetMapping("/landing")
public String landing(@RequestParam String ticket, HttpServletRequest request) {
    try {
        // 1. 校验 ticket,换取用户信息
        Map<String, Object> userInfo = xjbLoginService.validateTicket(ticket);
        String userName = (String) userInfo.get("userName");

        // 2. 查询你方系统用户
        YourUser user = yourUserService.findByUserName(userName);
        if (user == null) {
            return "redirect:/error?msg=" + URLEncoder.encode("账户未关联,请联系管理员", "UTF-8");
        }

        // 3. 生成你方系统登录态
        request.getSession().setAttribute("currentUser", user);
        return "redirect:/index";

    } catch (Exception e) {
        return "redirect:/error?msg=" + URLEncoder.encode(e.getMessage(), "UTF-8");
    }
}

配置文件示例(application.yml):

xjb:
  ticket-validate-url: http://本系统网关地址/system/ticket/validate
  app-id: 管理员颁发的appId值