Spring Boot 跨项目 SSO 指纹登录实现方案

最近在做一个项目,遇到一个需求:两个独立的 Spring Boot 项目之间要做单点登录,但它们既不共享数据库,也不共享 Redis。标准的 OAuth2、CAS 那套太重了,杀鸡用牛刀。琢磨了一圈,最后用了一个比较轻量的方案——我把它叫做"指纹登录",本质上就是借鉴了 OAuth2 授权码模式的简化版,用 code + secretKey 对作为凭证来完成跨系统身份传递。

这篇文章把整个实现思路和代码都整理出来,希望对遇到类似需求的朋友有帮助。


这个方案的核心思路

说白了就三步:

  1. 客户端去调服务端的接口,生成一对 code + secretKey,然后把 secretKey 存到自己 Redis 里
  2. 用户在服务端完成授权后,服务端通过 HTTP 回调把用户信息推给客户端
  3. 客户端前端拿 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.javaSSO 配置属性类
SsoService.javaSSO 核心业务逻辑
SsoController.javaSSO 接口控制器

配置类 - 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 的数据结构,方便排查问题:

KeyValueTTL写入方说明
sso:fingerprint:code:{code}secretKey1min客户端指纹码对应的密钥
sso:fingerprint:user:{code}tenantId:username1min客户端(服务端回调)指纹码对应的用户标识
code_key:{code}secretKey1min服务端服务端自有的 code-key 映射

接口总览

把所有接口汇总一下,方便对照:

步骤方向接口方法参数说明
前端 → 客户端/sso/generate-fingerprint-codeGET-生成指纹码
客户端 → 服务端/api/auth/generate-code-keyGET-内部调用获取 code+secretKey
前端 → 服务端/auth/sso-fingerprint-authorizePOSTcode用户授权
服务端 → 客户端/sso/save-fingerprint-userPOSTcode,secretKey,tenantId,usernameHTTP 回调
前端 → 客户端/sso/fingerprint-loginPOSTcode完成登录

接口测试验证

接口写完了,肯定要测一下。用 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.JSONUtilcn.hutool.json.JSONObject
请求参数缺失:code服务端回调用 URL Query 参数,客户端用 @RequestBody 接收改用 @RequestParam 接收回调参数
SsoProperties 未注入只加了 @ConfigurationProperties 没有 @Component同时加 @Component 注解
跨 JDK 版本不兼容客户端 JDK8(javax)服务端 JDK17(jakarta)两端代码各自维护在各自项目中,HTTP 通信无兼容问题

最后那个跨 JDK 的问题其实不算坑,因为两个项目通过 HTTP 通信,各用各的 JDK 版本,不存在兼容性问题。但如果你要把代码写在同一个模块里,那就要注意 javax 和 jakarta 的区别了。


安全方面要注意的事

这个方案本质上是在两个系统之间传递身份信息,安全问题不能马虎:

  1. HTTPS:生产环境务必使用 HTTPS,防止 secretKey 和 Token 被窃取
  2. 过期时间:指纹码默认 1 分钟过期,生产环境可适当调整但不宜过长
  3. 一次性使用:code 使用后立即从 Redis 删除,防止重放攻击
  4. secretKey 校验:服务端回调时必须校验 secretKey 匹配,防止伪造回调
  5. IP 白名单save-fingerprint-user 接口建议加 IP 白名单限制,仅允许服务端调用
  6. 用户映射:两项目需有相同的用户名(或通过映射表关联),否则登录失败
  7. 签名机制:生产环境建议对回调请求增加签名验证(如 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 方案,用户体验最好。轮询是最简单的实现,但不够优雅。二维码模式适合移动端场景。


快速接入清单

如果你也想用这个方案,照着这个清单一步步来就行:

客户端接入步骤

  • 创建 SsoCodeKeyRespVOSsoFingerprintLoginReqVO
  • 创建 SsoProperties(配置类)
  • 创建 SsoService(核心逻辑)
  • 创建 SsoController(3 个接口)
  • 在 AuthService 中新增 createTokenForUser 方法
  • 添加错误码定义
  • 添加 YAML 配置

服务端接入步骤

  • 确认已有 generate-code-key 接口
  • 在 AuthController 中新增 sso-fingerprint-authorize 接口
  • 添加 YAML 配置(客户端地址)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

开源Z

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值