第三方系统登录 — 完整实现方案笔记
第三方系统登录 — 完整实现方案笔记
本文档基于
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-gateway | sys-gateway/ | 网关鉴权、将 /ticket/validate 加入白名单放行 |
sys-system | sys-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,时间,userId | 60 秒 |
Ticket 过期后 Redis 自动清除,validate 接口 get 到 null 即返回「令牌已失效」。
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.xmlISysThirdClientAccreditService.java+SysThirdClientAccreditServiceImpl.javaSysThirdClientAccreditController.java(管理接口)TicketController.java(核心接口)
3.2 安全注意事项
| 事项 | 说明 |
|---|---|
| Ticket 有效期 | 默认 60 秒,可在 getUserTicket 中调整 TimeUnit.SECONDS 的第三个参数 |
| SM4 密钥保护 | 不要硬编码在代码中,建议放入 Nacos 配置并用 Jasypt 加密 |
| appId 管理 | 第三方停用后,及时将对应记录 del_flag 改为 1 或 status 改为 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}
请求参数:
| 参数名 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
ticket | query | string | 是 | 从跳转 URL 中获取的凭证 |
appId | query | string | 是 | 本系统管理员颁发的 APPID |
此接口无需 Authorization 请求头,本系统网关已将其加入白名单放行。
成功响应示例(HTTP 200):
{
"code": 200,
"msg": "操作成功",
"data": {
"userName": "zhangsan",
"name": "张三",
"deptId": 103,
"deptName": "xxxx公司",
"businessDeptName": "xxx部门"
}
}
返回字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
userName | string | 用户登录名,系统内唯一标识 |
name | string | 用户姓名(昵称) |
deptId | integer | 部门 ID(冗余存储,可能为空) |
deptName | string | 所属部门名称 |
businessDeptName | string | 业务部门名;若为业务站人员则返回站名(如「某某站」),否则为空字符串 |
第四步:你方系统完成登录
你方后端获取到 userName 等信息后,按自身业务逻辑处理:
- 查询你方系统中是否存在
userName对应的用户 - 若存在 → 生成你方系统的登录凭证(session/token),返回给前端完成登录
- 若不存在 → 提示「账户未关联」,或按需自动注册
三、错误码说明
| code | 含义 | 处理建议 |
|---|---|---|
200 | 成功 | 正常处理返回的 data |
10000 | APPID 为空 | 检查请求中是否传入了 appId 参数 |
10001 | 令牌(ticket)为空 | 检查请求中是否传入了 ticket 参数 |
10002 | 应用不可用 | 你方 appId 未在本系统维护或已停用,联系管理员 |
10003 | 令牌已失效 | ticket 超过 60 秒未使用,需重新触发跳转获取新 ticket |
10004 | 令牌解析错误 | 服务端解密异常,联系本系统管理员排查 |
10005 | 用户已离线 | 本系统侧 token 已失效(用户退出或过期),提示用户重新登录本系统后再跳转 |
四、对接注意事项
- Ticket 尽快使用:60 秒有效,收到后立即调用校验接口,不要缓存或转发给其他服务再处理。
- 后端调用:
/system/ticket/validate必须由你方后端发起,不能从浏览器直接调用(appId属于敏感凭证)。 - HTTPS 传输:生产环境所有跳转链接及接口调用请使用 HTTPS。
- appId 安全:不要将
appId提交到公开代码仓库,建议通过环境变量或配置中心管理。 - 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值