深入解析:SSO 单点登录 解决方案
SSO 单点登录 解决方案
1. 概述
1.1. 共享会话 Session
- 前提条件: 统一域名, 或统一父域名, 再手动设置 Cookie 域名为父级域名
- Cookie 自动绑定当前域名, 而不绑定端口, 如需绑定父级域名, 显示指定
- Cookie 共享以后, 还想做到后端共享 Session , 也就自动实现了 SSO 单点登录
实现技术: Spring Session + Redis
缺点: 无法跨顶级域名, 需要共享会话(Redis 或 JDBC)
1.3. CAS 中心化认证服务
CAS 简化版 sso 登录流程:
- 客户端 service1 登录时, 重定向到 cas-server 进行登录
- cas-server 认证成功后, 保存 Session, 创建 Cookie=TGC, 创建 ST, 再携带 ST 重定向回到 service1
- service1 拿到 ST, 向 cas-server 的 /serviceValidate 端点验证 ST 是否有效
- service1 验证成功后, 保存 Session
- 当 service2 登录时, 重定向到 cas-server, 由于 TGC 已经存在, 直接创建 ST, service2 实现自动登录
要点: 各个 service, 以及 cas-server 都有自己的独立的会话 Session 和 Cookie
实现技术: Apereo CAS (同时支持 CAS, OAuth2, ODIC 等多种协议)
1.3. JWT 无会话令牌
- JWT 即 Json Web Token, 其内容包括: 基本的用户信息 , 证明用户的签名
- JWT 已经可以证明用户身份, 每次请求只需验证 JWT, 所以无需保存会话 Session,
- JWT 特别适合微服务架构 + 前后端分离, 不用考虑 Session 共享问题
- JWT 采用 RSA 签名, 认证服务用私钥签发 JWT, 其他业务服务用公钥验证 JWT
JWT 实现登录登出:
- 登录后, 创建 2 个 JWT: Access Token 用于访问, Refresh Token 用于刷新 Access Token
- Access Token 15 分钟有效, 存储: 内存变量(Vue Pinia, 不用 localStorage)
- Refresh Token 7 天有效, 存储: Cookie(HttpOnly; Secure; SameSite=Strict)
- 登出时: 后端删除Redis 中的 Refresh Token, 前端删除 Cookie
JWT 解决了登录, 要想实现单点登录, 还需考虑前端如何共享 Refresh Token?
- 通过父域名共享 Cookie, 共享 Refresh Token, 也就可以实现自动登录
- 通过中心化认证服务, 重定向的方式获得 Refresh Token, 也可以实现自动登录
可能会问: 既然共享 Cookie 了, 或使用心化认证服务, 还需要用 JWT 吗, 用前面的2种方式不好吗?
答: 使用 JWT 与前面的 共享会话 和 CAS 本质区别在于: JWT 无会话, 适合微服务架构
实现技术: jjwt, nimbus-jose
2 OAuth2 OIDC
OAuth2 OIDC 实际上就是 JWT 的中心认证服务的具体实现
OIDC 不是独立的协议, 而是对 OAuth2 的补充, 最初 OAuth2 只支持授权, 增加 OIDC 则支持认证
2.1 OAuth2 中的角色
- Authorization Server 授权服务器 : 负责用户认证、颁发 Token
- Resource Server 资源服务 : 微服务,需要 Access Token 才能访问的 API
- Client 客户端 : 访问资源服务器的客户端, 可以是后端服务, 也可以是单页面应用 SPA
- User 用户 : 资源的所有者, 授权服务器所管理的用户
2.2 OAuth2 授权码授权流程
- 授权请求 : 客户端重定向到授权服务器, 用户登录, 并同意授权, 并返回授权码
- 换取令牌 : 客户端使用授权码换取令牌: Access Token, ID Token, Refresh Token
- 客户端 访问资源服务的 API, 需要携带 Access Token
- 资源服务 收到 Access Token 向授权服务器校验是否有效
Access Token 可以是 JWT 或者是 无意义的字符串, ID Token 规定必须为 JWT
JWT 的验证, 向授权服务器获取公钥, 然后使用公钥验证 Access Token 或 ID Token
2.3 换取令牌验证密匙 (PKCE):
PKCE 是 OAuth 2.0 的一项安全扩展, 从 OAuth 2.1 开始,强制实施 PKCE
PKCE 的工作流程:
- 客户端生成一个验证码: code_verifier
- 客户端创建一个挑战码: code_challenge = BASE64URL-ENCODE(SHA256(code_verifier))
- 授权请求: 包含 code_challenge
GET /oauth2/authorize?
response_type=code&
client_id=CLIENT_ID&
redirect_uri=REDIRECT_URI&
code_challenge=XXXXXX&
code_challenge_method=S256
- 返回授权码:
GET /login/oauth2/code/gateway?code=XXX
授权码 通过 URL 参数传递, 泄露风险高
- 换取令牌请求: 包含 code_verifier
POST /token HTTP/1.1
Host: your-authorization-server.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=YOUR_AUTHORIZATION_CODE
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback
&client_id=YOUR_CLIENT_ID
&code_verifier=YOUR_CODE_VERIFIER
- 公共客户端(单页应用 SPA) 只需包含: code_verifier
- 机密客户端(Web 后端应用) 需包含: code_verifier + client_secret
- 授权服务收到 code_verifier 与 code_challenge 进行校验, 即使授权码泄露, 也无法换取令牌
- POST + HTTPS 参数传递, 泄露风险极低
2.4 使用 OAuth2 架构设计与适用场景
Authorization Code + PKCE(SPA 作为 OAuth 客户端,前端直接走 OIDC)
- 优点:符合现代 SPA 安全实践,不需要客户端密钥,跨域可行(每个前端注册自己的 redirect URI)。
- 缺点:要为每个前端注册 redirect URI(可自动化或批量管理)。
- 适用:大量独立前端、希望前端直接完成登录(推荐 SPA 场景)。
BFF(为每个前端提供独立的后端)/ 后端代理模式
- 做法:前端只与自己同源的后端通信,后端作为 OAuth 客户端握有密钥并管理会话(cookie 只在同源后端域名下)。
- 优点:后端负责令牌管理,前端无需处理敏感令牌,单点控制会话与安全策略。
- 适用:需要更高安全性、控制会话(尤其要对接资源服务器/后端服务)。
2.5 主流身份提供商(Identity Provider, IdP):
| IdP名称 | 类型 | 协议支持 | 部署方式 | 开源/商业 | 适用场景 | 特点 |
|---|---|---|---|---|---|---|
| Keycloak | 全功能 | OAuth 2.0 OIDC, SAML | 自托管 | 开源(Red Hat 支持) | 中大型企业、微服务架构 | 功能丰富、UI 友好、支持多租户、可扩展性强 |
| Auth0 | 云原生 | OAuth 2.0 OIDC, SAML | SaaS | 商业(有免费层) | 快速上线、初创公司、SaaS 应用 | 易集成、开发者友好、支持社交登录、规则引擎 |
| Spring Authorization Server | 轻量级 | OAuth 2.1 OIDC(有限) | 自托管 | 开源(Spring 官方) | Spring 生态内建、定制化需求高 | 代码可控、轻量、适合微服务内部认证 |
如果你在 Spring 生态中,且需要轻量、可控的 OAuth2 授权服务器 → 选择 Spring Authorization Server
如果你需要完整的身份管理(用户注册、MFA、社交登录、审计) → 选择 Keycloak(开源) 或 Auth0/Okta(商业)
实践建议:很多团队采用 混合模式 —— 用 Keycloak/Auth0 做用户身份管理(IdP),用 Spring Authorization Server 做内部服务间 OAuth2 授权(如 Machine-to-Machine),两者通过 OIDC 联邦集成。
3 使用 Spring Authorization Server + oauth2-client 实现 SSO
- auth-server 为授权服务器, 地址: http://auth-server.net:9000
- app1-vue 为前端, 地址: http://app1.taj.com:3000
- gateway 为统一登录 OAuth2 客户端, 地址: http://gateway:8080
- service-a 为一个微服务, 地址: http://service-a:8081
- app1 添加代理 /api 转到 gateway 再转给 service-a
- app1 添加代理 /oauth2/authorization, /login/oauth2/code 转到 gateway 处理 OIDC
- app1 检测 未登录 导航到 /api/to-app-login, 认证拦截 触发 OIDC, 最后回到 app1 首页
本地域名映射 host
# ------ sso ------
127.0.0.1 auth-server.net
127.0.0.1 service-a
127.0.0.1 gateway
127.0.0.1 taj.com
127.0.0.1 app.taj.com
127.0.0.1 app1.taj.com
127.0.0.1 app2.taj.com
3.1 搭建授权服务器: auth-server
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.0</version>
<!--<version>3.5.8</version>--> <!--OAuth2授权服务器 1.5.3 版本-->
<!--<version>3.3.13</version>--> <!--OAuth2授权服务器 1.3.7 版本-->
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.taj</groupId>
<artifactId>auth-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>auth-server</name>
<description>auth-server</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<!--web-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--oauth2-authorization-server-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
<!--security-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!--跳过测试-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<skipTests>true</skipTests>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.yaml
server:
port: 9000
spring:
application:
name: auth-server
---
# 日志
logging:
level:
org.springframework.security: DEBUG
---
SecurityConfig.java
package com.taj.resservera.security;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
imp

浙公网安备 33010602011771号