最近在做一个项目,遇到一个需求:两个独立的 Spring Boot 项目之间要做单点登录,但它们既不共享数据库,也不共享 Redis。标准的 OAuth2、CAS 那套太重了,杀鸡用牛刀。琢磨了一圈,最后用了一个比较轻量的方案——我把它叫做"指纹登录",本质上就是借鉴了 OAuth2 授权码模式的简化版,用 code + secretKey 对作为凭证来完成跨系统身份传递。
这篇文章把整个实现思路和代码都整理出来,希望对遇到类似需求的朋友有帮助。
这个方案的核心思路
说白了就三步:
- 客户端去调服务端的接口,生成一对
code + secretKey,然后把 secretKey 存到自己 Redis 里 - 用户在服务端完成授权后,服务端通过 HTTP 回调把用户信息推给客户端
- 客户端前端拿
code去登录,拿到本地 Token
这里面涉及到两个角色,先明确一下:
| 角色 | 说明 | 示例 |
|---|---|---|
| 客户端(Client) | 需要被登录的项目,即发起 SSO 请求的一方 | 项目 A(端口 58081) |
| 服务端(Server) | 已有登录态的项目,即提供身份认证的一方 | 项目 B(端口 48080) |
开始之前需要确认几个前提:服务端得有一个类似 generate-code-key 的接口(生成 code + secretKey 并存 Redis);两个项目得有相同的用户标识体系(比如 username 一致,或者有映射表);还有就是两边的网络要互通。
整个流程是怎么跑的
先把时序图画出来,对着图看代码会清晰很多:
┌──────────────┐ ┌──────────────┐
│ 客户端 │ │ 服务端 │
│ (Client) │ │ (Server) │
└──────┬───────┘ └──────┬───────┘
│ │
│ ① 请求生成指纹码 │
│ GET /sso/generate-fingerprint-code │
│ ──────────────────────────────────> │
│ 内部调用服务端的 │
│ generate-code-key 接口 │
│ <────────────────────────────────── │
│ 返回 {code, secretKey} │
│ │
│ ② 将 secretKey 存入客户端 Redis │
│ key: sso:fingerprint:code:{code} │
│ val: secretKey │
│ │
│ 返回 code 给前端 │
│ │
│ (用户在服务端已登录, │
│ 前端携带 code 访问服务端) │
│ │
│ │ ③ 用户授权
│ │ POST /auth/
│ │ sso-fingerprint-authorize
│ │ {code}
│ │
│ │ ④ 服务端验证 code,
│ │ 获取当前登录用户
│ │
│ ⑤ HTTP 回调客户端 │
│ POST /sso/save-fingerprint-user │
│ ?code=xx&secretKey=xx │
│ &tenantId=1&username=admin │
│ <────────────────────────────────── │
│ 返回 {data: true} │
│ │
│ ⑥ 将用户信息存入客户端 Redis │
│ key: sso:fingerprint:user:{code} │
│ val: tenantId:username │
│ │
│ ⑦ 前端轮询/回调,完成登录 │
│ POST /sso/fingerprint-login │
│ {code} │
│ ──────────────────────────────────> │
│ (从 Redis 取出用户信息, │
│ 创建本地 Token) │
│ <────────────────────────────────── │
│ 返回 {accessToken} ✅ │
│ │
客户端(Client)怎么实现
客户端就是那个"需要被登录"的项目。下面是要新增的文件:
| 文件 | 说明 |
|---|---|
SsoCodeKeyRespVO.java | 指纹码响应 VO |
SsoFingerprintLoginReqVO.java | 指纹登录请求 VO |
SsoProperties.java | SSO 配置属性类 |
SsoService.java | SSO 核心业务逻辑 |
SsoController.java | SSO 接口控制器 |
配置类 - SsoProperties
先搭配置,比较直观:
@Component
@ConfigurationProperties(prefix = "sso.fingerprint") // ← 通用前缀
@Validated
@Data
public class SsoProperties {
/** 是否启用 SSO 指纹登录 */
private boolean enable = false;
/** 服务端的基础 URL(如 http://localhost:48080) */
@NotEmpty(message = "SSO 服务端地址不能为空")
private String serverUrl;
/** 服务端生成 code+secretKey 的接口路径 */
private String generateCodeKeyUrl = "/generate-code-key";
/** 指纹编码过期时间(分钟) */
private int codeExpireMinutes = 1;
}
这里有个容易踩的坑:generateCodeKeyUrl 的值要和你的服务端接口路径完全一致。如果你的服务端有统一的 API 前缀(比如 /app-api、/admin-api),一定记得加上,不然请求会 404。
VO 类
两个简单的 VO:
SsoCodeKeyRespVO - 指纹码响应:
@Data
public class SsoCodeKeyRespVO {
/** 随机密钥 */
private String secretKey;
/** 随机编码 */
private String code;
}
SsoFingerprintLoginReqVO - 指纹登录请求:
@Data
public class SsoFingerprintLoginReqVO {
/** 指纹编码 */
@NotEmpty(message = "指纹编码不能为空")
private String code;
}
核心业务 - SsoService
这个类是整个方案的重心,三个方法分别对应流程里的三个关键步骤:
@Service
@Slf4j
public class SsoService {
private static final String SSO_CODE_KEY_PREFIX = "sso:fingerprint:code:";
private static final String SSO_USER_KEY_PREFIX = "sso:fingerprint:user:";
private static final Duration CODE_EXPIRE_TIME = Duration.ofMinutes(1);
@Resource
private SsoProperties ssoProperties;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private UserService userService; // ← 你的用户查询服务
@Resource
private AuthService authService; // ← 你的认证服务
/**
* ① 生成 SSO 指纹编码
* 调用服务端 generate-code-key → 存 secretKey 到 Redis → 返回 code
*/
public SsoCodeKeyRespVO generateFingerprintCode() {
if (!ssoProperties.isEnable()) {
throw new RuntimeException("SSO 指纹登录功能未启用");
}
// 1. 调用服务端接口
String url = ssoProperties.getServerUrl() + ssoProperties.getGenerateCodeKeyUrl();
SsoCodeKeyRespVO result;
try {
HttpResponse response = HttpRequest.get(url).timeout(5000).execute();
JSONObject jsonResult = JSONUtil.parseObj(response.body());
if (jsonResult.getInt("code") != 0) {
throw new RuntimeException("生成 SSO 指纹编码失败");
}
JSONObject data = jsonResult.getJSONObject("data");
result = new SsoCodeKeyRespVO();
result.setCode(data.getStr("code"));
result.setSecretKey(data.getStr("secretKey"));
} catch (Exception e) {
log.error("[generateFingerprintCode] 调用服务端异常", e);
throw new RuntimeException("生成 SSO 指纹编码失败");
}
// 2. 将 secretKey 存储到客户端 Redis,用于后续验证
stringRedisTemplate.opsForValue().set(
SSO_CODE_KEY_PREFIX + result.getCode(),
result.getSecretKey(),
CODE_EXPIRE_TIME
);
return result;
}
/**
* ⑤ 接收服务端回调,保存用户信息到 Redis
*/
public void saveSsoFingerprintUser(String code, String secretKey,
Long tenantId, String username) {
// 验证 secretKey 是否匹配
String storedSecretKey = stringRedisTemplate.opsForValue()
.get(SSO_CODE_KEY_PREFIX + code);
if (!secretKey.equals(storedSecretKey)) {
throw new RuntimeException("SSO 指纹密钥不匹配");
}
// 保存用户标识(格式:tenantId:username)
stringRedisTemplate.opsForValue().set(
SSO_USER_KEY_PREFIX + code,
tenantId + ":" + username,
CODE_EXPIRE_TIME
);
}
/**
* ⑦ 指纹登录:从 Redis 取用户信息 → 创建本地 Token
*/
public LoginRespVO fingerprintLogin(String code) {
if (!ssoProperties.isEnable()) {
throw new RuntimeException("SSO 指纹登录功能未启用");
}
// 1. 从 Redis 取用户标识
String userIdentifier = stringRedisTemplate.opsForValue()
.get(SSO_USER_KEY_PREFIX + code);
if (StrUtil.isEmpty(userIdentifier)) {
throw new RuntimeException("SSO 指纹编码无效");
}
// 2. 验证 secretKey 是否存在
String secretKey = stringRedisTemplate.opsForValue()
.get(SSO_CODE_KEY_PREFIX + code);
if (StrUtil.isEmpty(secretKey)) {
throw new RuntimeException("SSO 指纹编码已过期");
}
// 3. 删除已使用的 code,防止重复使用
stringRedisTemplate.delete(SSO_CODE_KEY_PREFIX + code);
stringRedisTemplate.delete(SSO_USER_KEY_PREFIX + code);
// 4. 解析用户标识,查找本地用户
String[] parts = userIdentifier.split(":", 2);
String username = parts[1];
UserDO user = userService.getUserByUsername(username); // ← 你的用户查询方法
if (user == null) {
throw new RuntimeException("SSO 指纹登录用户不存在");
}
// 5. 创建本地 Token(跳过密码验证)
return authService.createTokenForUser(user); // ← 你的 Token 创建方法
}
}
说实话这里最关键的就是 fingerprintLogin 方法——它从 Redis 里把用户信息取出来,然后跳过密码校验直接签发 Token。这也是整个方案最核心的一步。
控制器 - SsoController
三个接口,对应三个步骤:
@RestController
@RequestMapping("/sso")
@Validated
@Slf4j
public class SsoController {
@Resource
private SsoService ssoService;
/**
* 生成 SSO 指纹编码
*/
@GetMapping("/generate-fingerprint-code")
@PermitAll
public CommonResult<SsoCodeKeyRespVO> generateFingerprintCode() {
return success(ssoService.generateFingerprintCode());
}
/**
* 保存 SSO 指纹用户信息(服务端回调此接口)
* 使用 @RequestParam 接收参数(服务端通过 URL 拼接参数回调)
*/
@PostMapping("/save-fingerprint-user")
@PermitAll
public CommonResult<Boolean> saveFingerprintUser(
@RequestParam("code") String code,
@RequestParam("secretKey") String secretKey,
@RequestParam("tenantId") Long tenantId,
@RequestParam("username") String username) {
ssoService.saveSsoFingerprintUser(code, secretKey, tenantId, username);
return success(true);
}
/**
* SSO 指纹登录
*/
@PostMapping("/fingerprint-login")
@PermitAll
public CommonResult<LoginRespVO> fingerprintLogin(
@RequestBody @Valid SsoFingerprintLoginReqVO reqVO) {
return success(ssoService.fingerprintLogin(reqVO.getCode()));
}
}
这里强调一点:save-fingerprint-user 用的是 @RequestParam 接收参数,因为服务端是通过 URL Query 参数回调的。我当时在这卡了一会儿,用了 @RequestBody 一直报参数缺失,后来才反应过来。
AuthService 扩展
在认证服务中加一个无密码登录的方法:
/**
* SSO 指纹登录专用:跳过密码验证,直接为已知用户创建 Token
*/
public LoginRespVO createTokenForUser(UserDO user) {
if (user == null) {
throw new RuntimeException("用户不存在");
}
// 复用已有的 Token 创建逻辑
return createTokenAfterLoginSuccess(user.getId(), user.getUsername());
}
这个方法没什么花头,就是跳过密码和验证码校验,直接给已验证身份的用户签发 Token。
YAML 配置
# application.yml
sso:
fingerprint:
enable: true # 是否启用
server-url: http://localhost:48080 # 服务端地址
generate-code-key-url: /api/auth/generate-code-key # 服务端生成指纹码路径
code-expire-minutes: 1 # 指纹码过期时间(分钟)
服务端(Server)怎么实现
服务端就是那个"已经有登录态"的项目,改动比较少。
前提:服务端得有 generate-code-key 接口
服务端需要提供一个公开接口来生成 code + secretKey 对,并存到 Redis:
@GetMapping("/generate-code-key")
@PermitAll
public CommonResult<CodeKeyRespVO> generateCodeKey() {
String secretKey = generateRandomString(32);
String code = generateRandomString(32);
// 存入 Redis,key = "code_key:" + code, value = secretKey, TTL = 1min
stringRedisTemplate.opsForValue().set(
"code_key:" + code, secretKey, Duration.ofMinutes(1));
return success(new CodeKeyRespVO(code, secretKey));
}
如果你的项目是基于芋道源码(ruoyi-vue-pro)的,这个接口在 AppAuthController 里已经有了,不用自己写。
新增 SSO 授权端点
在认证控制器里加一个 sso-fingerprint-authorize 接口:
@Value("${sso.fingerprint.client-url:}")
private String clientUrl;
@PostMapping("/sso-fingerprint-authorize")
@Operation(summary = "SSO 指纹授权")
@PermitAll
public CommonResult<Boolean> ssoFingerprintAuthorize(
@RequestParam("code") String code) {
// 1. 校验 code
if (StrUtil.isEmpty(code)) {
return CommonResult.error(400, "指纹编码不能为空");
}
// 2. 从服务端 Redis 中获取 secretKey
String secretKey = stringRedisTemplate.opsForValue()
.get("code_key:" + code);
if (StrUtil.isEmpty(secretKey)) {
return CommonResult.error(400, "指纹编码无效或已过期");
}
// 3. 获取当前登录用户(必须在服务端已登录)
UserDO currentUser = userService.getUser(getLoginUserId());
if (currentUser == null) {
return CommonResult.error(401, "请先登录系统");
}
// 4. HTTP 回调客户端,推送用户信息
if (StrUtil.isEmpty(clientUrl)) {
return CommonResult.error(500, "未配置客户端项目地址");
}
String url = clientUrl + "/sso/save-fingerprint-user"
+ "?code=" + code
+ "&secretKey=" + secretKey
+ "&tenantId=" + currentUser.getTenantId()
+ "&username=" + currentUser.getUsername();
try {
HttpResponse response = HttpRequest.post(url)
.timeout(5000)
.execute();
JSONObject jsonResult = JSONUtil.parseObj(response.body());
if (jsonResult.getInt("code") != 0) {
return CommonResult.error(500, "推送 SSO 用户信息失败");
}
} catch (Exception e) {
log.error("[ssoFingerprintAuthorize] 推送 SSO 用户信息异常", e);
return CommonResult.error(500, "推送 SSO 用户信息异常");
}
// 5. 删除服务端 Redis 中的 code,防止重复使用
stringRedisTemplate.delete("code_key:" + code);
return success(true);
}
这个接口做了几件事:校验 code、拿到当前登录用户、然后通过 HTTP 回调把用户信息推给客户端。回调完之后记得删掉 Redis 里的 code,防止被重复使用。
YAML 配置
# application.yml
sso:
fingerprint:
client-url: http://localhost:58081 # 客户端地址(SSO 回调目标)
Redis 里都存了什么
整理一下 Redis 的数据结构,方便排查问题:
| Key | Value | TTL | 写入方 | 说明 |
|---|---|---|---|---|
sso:fingerprint:code:{code} | secretKey | 1min | 客户端 | 指纹码对应的密钥 |
sso:fingerprint:user:{code} | tenantId:username | 1min | 客户端(服务端回调) | 指纹码对应的用户标识 |
code_key:{code} | secretKey | 1min | 服务端 | 服务端自有的 code-key 映射 |
接口总览
把所有接口汇总一下,方便对照:
| 步骤 | 方向 | 接口 | 方法 | 参数 | 说明 |
|---|---|---|---|---|---|
| ① | 前端 → 客户端 | /sso/generate-fingerprint-code | GET | - | 生成指纹码 |
| ② | 客户端 → 服务端 | /api/auth/generate-code-key | GET | - | 内部调用获取 code+secretKey |
| ③ | 前端 → 服务端 | /auth/sso-fingerprint-authorize | POST | code | 用户授权 |
| ④ | 服务端 → 客户端 | /sso/save-fingerprint-user | POST | code,secretKey,tenantId,username | HTTP 回调 |
| ⑤ | 前端 → 客户端 | /sso/fingerprint-login | POST | code | 完成登录 |
接口测试验证
接口写完了,肯定要测一下。用 curl 跑一遍完整流程:
生成指纹码
curl -X GET 'http://localhost:58081/sso/generate-fingerprint-code' \
-H 'tenant-id: 1'
响应:
{
"code": 0,
"data": {
"secretKey": "Qdu7l1UKP9mA6gntlmnDHxEcNkBHhEel",
"code": "YfyHFUMqHEWkzZY0uiUGCXAqmCOs5CVI"
}
}
服务端授权(需在服务端已登录)
curl -X POST 'http://localhost:48080/auth/sso-fingerprint-authorize?code=YfyHFUMqHEWkzZY0uiUGCXAqmCOs5CVI' \
-H 'Authorization: Bearer {服务端accessToken}' \
-H 'tenant-id: 1'
响应:
{
"code": 0,
"data": true
}
指纹登录
curl -X POST 'http://localhost:58081/sso/fingerprint-login' \
-H 'tenant-id: 1' \
-H 'Content-Type: application/json' \
-d '{"code": "YfyHFUMqHEWkzZY0uiUGCXAqmCOs5CVI"}'
响应:
{
"code": 0,
"data": {
"userId": 1,
"accessToken": "03c1820539ca4020aca5801d5ad2d647",
"refreshToken": "db2cface222f4c03ba7bcb4063dc3397",
"expiresTime": 1775723128962
}
}
验证 Token
curl -X GET 'http://localhost:58081/auth/get-permission-info' \
-H 'Authorization: Bearer 03c1820539ca4020aca5801d5ad2d647' \
-H 'tenant-id: 1'
响应:返回完整的用户信息、角色和权限列表,SSO 登录验证通过 ✅
踩过的坑
做这个方案的过程中踩了不少坑,整理一下:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 生成指纹码失败 | generateCodeKeyUrl 路径缺少 API 前缀 | 配置中显式指定完整路径(含 /api、/app-api 等前缀) |
程序包com.alibaba.fastjson2不存在 | 模块未引入 fastjson2 依赖 | 改用 cn.hutool.json.JSONUtil 和 cn.hutool.json.JSONObject |
请求参数缺失:code | 服务端回调用 URL Query 参数,客户端用 @RequestBody 接收 | 改用 @RequestParam 接收回调参数 |
SsoProperties 未注入 | 只加了 @ConfigurationProperties 没有 @Component | 同时加 @Component 注解 |
| 跨 JDK 版本不兼容 | 客户端 JDK8(javax)服务端 JDK17(jakarta) | 两端代码各自维护在各自项目中,HTTP 通信无兼容问题 |
最后那个跨 JDK 的问题其实不算坑,因为两个项目通过 HTTP 通信,各用各的 JDK 版本,不存在兼容性问题。但如果你要把代码写在同一个模块里,那就要注意 javax 和 jakarta 的区别了。
安全方面要注意的事
这个方案本质上是在两个系统之间传递身份信息,安全问题不能马虎:
- HTTPS:生产环境务必使用 HTTPS,防止 secretKey 和 Token 被窃取
- 过期时间:指纹码默认 1 分钟过期,生产环境可适当调整但不宜过长
- 一次性使用:code 使用后立即从 Redis 删除,防止重放攻击
- secretKey 校验:服务端回调时必须校验 secretKey 匹配,防止伪造回调
- IP 白名单:
save-fingerprint-user接口建议加 IP 白名单限制,仅允许服务端调用 - 用户映射:两项目需有相同的用户名(或通过映射表关联),否则登录失败
- 签名机制:生产环境建议对回调请求增加签名验证(如 HMAC),防止参数被篡改
我个人觉得这里面最重要的是第 4 点(secretKey 校验)和第 7 点(签名机制),这两个做好了基本就能防住大部分攻击。IP 白名单是锦上添花,有条件就加上。
多系统扩展
上面的方案是两个系统之间的,实际场景可能更复杂。
一对多 SSO
如果多个客户端项目都要 SSO 登录到同一个服务端:
┌──────────┐
│ 客户端 A │ ──┐
└──────────┘ │
┌──────────┐ │ ┌──────────┐
│ 客户端 B │ ──┼──> │ 服务端 │
└──────────┘ │ └──────────┘
┌──────────┐ │
│ 客户端 C │ ──┘
└──────────┘
每个客户端独立配置 sso.fingerprint.server-url,服务端为每个客户端配置 client-url(可改为 List)。
多对多 SSO
这种情况建议加一个 SSO 网关来统一管理:
┌──────────┐ ┌──────────┐
│ 客户端 A │ ──┐ ┌──> │ 服务端 X │
└──────────┘ │ │ └──────────┘
│ ┌─────┴─┐
┌──────────┐ ├──> │SSO网关 │
│ 客户端 B │ ──┘ └─────┬─┘
│ ┌──────────┐
└──> │ 服务端 Y │
└──────────┘
SSO 网关负责:
- 统一管理 code + secretKey
- 统一回调客户端
- 用户身份映射
前端集成建议
- 二维码模式:客户端前端将 code 生成二维码,服务端前端扫码授权
- WebSocket 通知:客户端前端订阅 code 授权状态,授权成功后自动完成登录
- 轮询模式:客户端前端定时轮询
fingerprint-login接口,直到登录成功
我个人比较推荐 WebSocket 方案,用户体验最好。轮询是最简单的实现,但不够优雅。二维码模式适合移动端场景。
快速接入清单
如果你也想用这个方案,照着这个清单一步步来就行:
客户端接入步骤
- 创建
SsoCodeKeyRespVO、SsoFingerprintLoginReqVO - 创建
SsoProperties(配置类) - 创建
SsoService(核心逻辑) - 创建
SsoController(3 个接口) - 在 AuthService 中新增
createTokenForUser方法 - 添加错误码定义
- 添加 YAML 配置
服务端接入步骤
- 确认已有
generate-code-key接口 - 在 AuthController 中新增
sso-fingerprint-authorize接口 - 添加 YAML 配置(客户端地址)

691

被折叠的 条评论
为什么被折叠?



