SSO 单点登录
一、什么是单点登录?
单点登录(SSO)在微服务架构中是一个核心概念,它允许用户在一个应用中登录后,无需再次登录即可访问其他相互信任的应用。
在微服务架构中,由于系统被拆分成多个独立的服务,如果用户每访问一个服务都要登录一次,那对用户的体验太不友好,而单点登录就是为了解决这个问题而生。
简而言之,单点登录其本质就是多个系统之间的会话共享。
二、Sa-Token-SSO
Sa-Token-SSO 是基于 Sa-Token 框架的单点登录解决方案。它允许在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
2.1 Sa-Token-SSO 原理
Sa-Token-SSO 单点登录模块主要基于令牌(Token)的验证和转发来实现。其核心原理如下:
- 统一认证中心:所有的登录请求都经过统一的认证中心进行验证。
- 令牌生成与验证:用户登录成功后,认证中心生成一个令牌(Token),并将该令牌与用户信息关联存储。然后,认证中心将令牌返回给客户端。
- 子系统验证:当用户访问其他子系统时,子系统将携带令牌向认证中心验证令牌的有效性。验证通过后,子系统知道当前用户已经登录,并允许访问。
2.2 搭建统一认证中心 SSO-Server
要实现单点登录,必须先搭建统一认证中心 ,所有登录请求必须通过认证中心进行登录处理。
2.2.1 添加依赖
创建 SpringBoot 项目 sa-token-demo-sso-server,在引入 SpringBoot 依赖的基础上,继续引入:
<!-- Sa-Token 权限认证,在线文档:https://sa-token.cc -->
<dependency><groupId>cn.dev33</groupId><artifactId>sa-token-spring-boot-starter</artifactId><version>1.44.0</version>
</dependency><!-- Sa-Token 插件:整合SSO -->
<dependency><groupId>cn.dev33</groupId><artifactId>sa-token-sso</artifactId><version>1.44.0</version>
</dependency><!-- Sa-Token 插件:整合 RedisTemplate -->
<dependency><groupId>cn.dev33</groupId><artifactId>sa-token-redis-template</artifactId><version>1.44.0</version>
</dependency>
<dependency><groupId>org.apache.commons</groupId><artifactId>commons-pool2</artifactId>
</dependency><!-- 视图引擎(在前后端不分离模式下提供视图支持) -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency><!-- Sa-Token 插件:整合 Forest 请求工具 (模式三需要通过 http 请求推送消息) -->
<dependency><groupId>cn.dev33</groupId><artifactId>sa-token-forest</artifactId><version>1.44.0</version>
</dependency>
2.2.2 开放认证接口
新建 SsoServerController,用于对外开放接口:
/*** Sa-Token-SSO Server端 Controller */
@RestController
public class SsoServerController {/*** SSO-Server端:处理所有SSO相关请求 * http://{host}:{port}/sso/auth -- 单点登录授权地址* http://{host}:{port}/sso/doLogin -- 账号密码登录接口,接受参数:name、pwd* http://{host}:{port}/sso/signout -- 单点注销地址(isSlo=true时打开)*/@RequestMapping("/sso/*")public Object ssoRequest() {return SaSsoServerProcessor.instance.dister();}/*** 配置SSO相关参数 */@Autowiredprivate void configSso(SaSsoServerTemplate ssoServerTemplate) {// 配置:未登录时返回的View ssoServerTemplate.strategy.notLoginView = () -> {// 简化模拟表单String doLoginCode ="fetch(`/sso/doLogin?name=${document.querySelector('#name').value}&pwd=${document.querySelector('#pwd').value}`) " +" .then(res => res.json()) " +" .then(res => { if(res.code === 200) { location.reload() } else { alert(res.msg) } } )";String res ="<h2>当前客户端在 SSO-Server 认证中心尚未登录,请先登录</h2>" +"用户:<input id='name' /> <br> " +"密码:<input id='pwd' /> <br>" +"<button onclick=\"" + doLoginCode + "\">登录</button>";return res;};// 配置:登录处理函数 ssoServerTemplate.strategy.doLoginHandle = (name, pwd) -> {// 此处仅做模拟登录,真实环境应该查询数据库进行登录 if("sa".equals(name) && "123456".equals(pwd)) {StpUtil.login(10001);return SaResult.ok("登录成功!").setData(StpUtil.getTokenValue());}return SaResult.error("登录失败!");};}}
2.2.3 application.yml 配置
# 端口
server:port: 9000# Sa-Token 配置
sa-token: # 打印操作日志is-log: true# SSO-模式一相关配置 (非模式一不需要配置) # cookie: # 配置 Cookie 作用域 # domain: stp.com # SSO-Server 配置sso-server:# Ticket有效期 (单位: 秒),默认五分钟 ticket-timeout: 300# 应用列表:配置接入的应用信息clients:# 应用 sso-client1:采用模式一对接 (同域、同Redis)sso-client1:client: sso-client1allow-url: "*"# 应用 sso-client2:采用模式二对接 (跨域、同Redis)sso-client2:client: sso-client2allow-url: "*"secret-key: SSO-C2-kQwIOrYvnXmSDkwEiFngrKidMcdrgKor# 应用 sso-client3:采用模式三对接 (跨域、跨Redis)sso-client3:# 应用名称client: sso-client3# 允许授权地址allow-url: "*"# 是否接收消息推送is-push: true# 消息推送地址push-url: http://sa-sso-client1.com:9003/sso/pushC# 接口调用秘钥 (如果不配置则使用全局默认秘钥)secret-key: SSO-C3-kQwIOrYvnXmSDkwEiFngrKidMcdrgKorspring: # Redis配置 (SSO模式一和模式二使用Redis来同步会话)redis:# Redis数据库索引(默认为0)database: 1# Redis服务器地址host: 127.0.0.1# Redis服务器连接端口port: 6379# Redis服务器连接密码(默认为空)password:
2.2.4 创建启动类
@SpringBootApplication
public class SaSsoServerApplication {public static void main(String[] args) {SpringApplication.run(SaSsoServerApplication.class, args);System.out.println();System.out.println("---------------------- Sa-Token SSO 统一认证中心启动成功 ----------------------");System.out.println("配置信息:" + SaSsoManager.getServerConfig());System.out.println("统一认证登录地址:http://sa-sso-server.com:9000/sso/auth");System.out.println("测试前需要根据官网文档修改 hosts 文件,测试账号密码:sa / 123456");System.out.println();}
}
2.3 Sa-Token-SSO 三种模式
Sa-Token-SSO 由简入难划分为三种模式,解决不同架构下的 SSO 接入问题:
- 模式一:采用共享 Cookie 来做到前端 Token 的共享,从而达到后端的 Session 会话共享。
- 模式二:采用 URL 重定向,以 ticket 码为授权中介,做到多个系统间的会话传播。
- 模式三:采用 Http 请求主动查询会话,做到 Client 端与 Server 端的会话同步。
2.3.1 模式一 共享Cookie同步会话
-
同域:就是指多个系统可以部署在同一个主域名之下,比如:c1.domain.com、c2.domain.com、c3.domain.com 三个不同的子域,其对应一个共同的主域名 domain.com。
-
共享Cookie:就是在同域的情况下,主域名下的Cookie在二级域名下是共享的。例如:写在父域名domain.com下的Cookie,在c1.domain.com、c2.domain.com、c3.domain.com 等子域名都是可以共享访问的。
-
核心原理:利用浏览器的Cookie共享机制,在相同父域名下的系统间自动同步会话。
实现原理
- 用户访问其中一个应用(如 c1.domain.com)时,如果未登录,则跳转到认证中心(sso.domain.com)进行登录。
- 登录成功后,认证中心将令牌(Token)写入主域(domain.com)的 Cookie 中。
- 当用户访问其他应用(如 c2.domain.com)时,应用会检查当前域的主域下的 Cookie 是否存在令牌,如果存在则自动登录。
实现步骤
-
步骤1:统一认证中心服务配置
在统一认证中心服务中的application.yml 配置Cookie的作用域,将Cookie写入到其父级域名domain.com下。sa-token: cookie: # 配置 Cookie 作用域 domain: domain.com -
步骤2:客户端服务配置
- 引入依赖
<!-- Sa-Token 权限认证, 在线文档:https://sa-token.cc --> <dependency><groupId>cn.dev33</groupId><artifactId>sa-token-spring-boot-starter</artifactId><version>1.44.0</version> </dependency> <!-- Sa-Token 插件:整合SSO --> <dependency><groupId>cn.dev33</groupId><artifactId>sa-token-sso</artifactId><version>1.44.0</version> </dependency><!-- Sa-Token 整合 RedisTemplate --> <dependency><groupId>cn.dev33</groupId><artifactId>sa-token-redis-template</artifactId><version>1.44.0</version> </dependency> <dependency><groupId>org.apache.commons</groupId><artifactId>commons-pool2</artifactId> </dependency><!-- Sa-Token插件:权限缓存与业务缓存分离 --> <dependency><groupId>cn.dev33</groupId><artifactId>sa-token-alone-redis</artifactId><version>1.44.0</version> </dependency> - application.yml 配置
# 端口 server:port: 9001# Sa-Token 配置 sa-token: # SSO-相关配置sso-client:# client 标识client: sso-client1# SSO-Server端主机地址server-url: http://sso.domain.com:9000
- 引入依赖
-
优点:
- 实现简单,无需额外的重定向或HTTP请求。
- 用户体验好,无跳转。
-
缺点:
- 仅限于同一顶级域名下的系统。
- 安全性较低,容易受到CSRF攻击。
2.3.2 模式二 URL重定向传播会话
- 核心原理:通过URL参数传递ticket,在系统间重定向实现会话传播。
完整流程
- 用户在 子系统 点击 [登录] 按钮。
- 用户跳转到子系统登录接口 /sso/login,并携带 back参数 记录初始页面URL。形如:
http://{sso-client}/sso/login?back=xxx - 子系统检测到此用户尚未登录,再次将其重定向至SSO认证中心,并携带redirect参数记录子系统的登录页URL。形如:
http://{sso-server}/sso/auth?redirect=xxx?back=xxx - 用户进入了 SSO认证中心 的登录页面,开始登录。
- 用户 输入账号密码 并 登录成功,SSO认证中心再次将用户重定向至子系统的登录接口/sso/login,并携带ticket码参数。形如:
http://{sso-client}/sso/login?back=xxx&ticket=xxxxxxxxx - 子系统根据 ticket码 从 SSO-Redis 中获取账号id,并在子系统登录此账号会话。
- 子系统将用户再次重定向至最初始的 back 页面。
实现方式
-
客户端服务配置:
- 引入依赖
<!-- Sa-Token 权限认证, 在线文档:https://sa-token.cc --> <dependency><groupId>cn.dev33</groupId><artifactId>sa-token-spring-boot-starter</artifactId><version>1.44.0</version> </dependency> <!-- Sa-Token 插件:整合SSO --> <dependency><groupId>cn.dev33</groupId><artifactId>sa-token-sso</artifactId><version>1.44.0</version> </dependency><!-- Sa-Token 整合 RedisTemplate --> <dependency><groupId>cn.dev33</groupId><artifactId>sa-token-redis-template</artifactId><version>1.44.0</version> </dependency> <dependency><groupId>org.apache.commons</groupId><artifactId>commons-pool2</artifactId> </dependency><!-- Sa-Token插件:权限缓存与业务缓存分离 --> <dependency><groupId>cn.dev33</groupId><artifactId>sa-token-alone-redis</artifactId><version>1.44.0</version> </dependency><!-- Sa-Token 插件:整合 Forest 请求工具 --> <dependency><groupId>cn.dev33</groupId><artifactId>sa-token-forest</artifactId><version>${sa-token.version}</version> </dependency> - 创建 SSO-Client 端认证接口
- 同 SSO-Server 一样,Sa-Token 为 SSO-Client 端所需代码也提供了完整的封装,你只需提供一个访问入口,接入 Sa-Token 的方法即可。
/*** Sa-Token-SSO Client端 Controller */ @RestController public class SsoClientController {// 首页 @RequestMapping("/")public String index() {String str = "<h2>Sa-Token SSO-Client 应用端 (模式二)</h2>" +"<p>当前会话是否登录:" + StpUtil.isLogin() + " (" + StpUtil.getLoginId("") + ")</p>" +"<p> " +"<a href='/sso/login?back=/'>登录</a> - " +"<a href='/sso/logoutByAlone?back=/'>单应用注销</a> - " +"<a href='/sso/logout?back=self'>全端注销</a> - " +"<a href='/sso/myInfo' target='_blank'>账号资料</a>" +"</p>";return str;}/** SSO-Client端:处理所有SSO相关请求 * http://{host}:{port}/sso/login -- Client 端登录地址* http://{host}:{port}/sso/logout -- Client 端注销地址(isSlo=true时打开)* http://{host}:{port}/sso/pushC -- Client 端接收消息推送地址*/@RequestMapping("/sso/*")public Object ssoRequest() {return SaSsoClientProcessor.instance.dister();}// 配置SSO相关参数@Autowiredprivate void configSso(SaSsoClientTemplate ssoClientTemplate) {}// 当前应用独自注销 (不退出其它应用)@RequestMapping("/sso/logoutByAlone")public Object logoutByAlone() {StpUtil.logout();return SaSsoClientProcessor.instance._ssoLogoutBack(SaHolder.getRequest(), SaHolder.getResponse());}} - 配置SSO认证中心地址
- 需要在 application.yml 配置如下信息:
# 端口 server:port: 9002# sa-token配置 sa-token: # 打印操作日志is-log: true# SSO-相关配置sso-client: # 应用标识client: sso-client2# SSO-Server 端主机地址server-url: http://sa-sso-server.com:9000# API 接口调用秘钥 (单点注销时会用到)secret-key: SSO-C2-kQwIOrYvnXmSDkwEiFngrKidMcdrgKor# 配置 Sa-Token 单独使用的Redis连接(此处需要和 SSO-Server 端连接同一个 Redis)# 注:使用 alone-redis 需要在 pom.xml 引入 sa-token-alone-redis 依赖alone-redis: # Redis数据库索引 (默认为0)database: 1# Redis服务器地址host: 127.0.0.1# Redis服务器连接端口port: 6379# Redis服务器连接密码(默认为空)password: # 连接超时时间timeout: 10s
- 引入依赖
-
优点:
- 支持不同域名。
- 安全性较高,Token通过URL传递,但需注意防范Token泄露。
-
缺点:
- 需要多次重定向,用户体验稍差。
- URL中传递Token,可能被记录在浏览器历史或日志中。
2.3.3 模式三 Http请求获取会话
- 核心原理:客户端不直接登录,而是通过后端HTTP API调用认证中心,获取会话信息。
完整流程
- 客户端拦截未登录请求,跳转到SSO认证中心:
- 在客户端的拦截器中,如果检测到未登录,则重定向到SSO认证中心的登录页面,并带上回调地址。
- SSO认证中心处理登录请求:
- 用户登录成功后,生成一个临时ticket,然后重定向回客户端提供的回调地址,并带上ticket。
- 客户端在回调地址的Controller中处理:
- 通过ticket向SSO认证中心发送请求,获取对应的Token,然后使用StpUtil.login(Object loginId)方法进行登录。
- 客户端登录成功后,重定向到最初访问的页面。
代码示例:
客户端回调地址的Controller方法:
@RequestMapping("/sso/callback")
public String callback(String ticket, String returnUrl) {// 通过ticket向SSO认证中心发送HTTP请求,获取TokenString ssoServerUrl = "http://sso-server.com/sso/checkTicket";String response = HttpUtil.get(ssoServerUrl + "?ticket=" + ticket);// 解析响应,获取TokenString token = JSON.parseObject(response).getString("data");// 使用Token在客户端登录,Sa-Token会通过Token获取对应的登录id,然后创建客户端会话StpUtil.login(token);// 重定向到最初访问的页面return "redirect:" + returnUrl;
}
实现方式
Sa-Token的SSO模式三已经封装了大部分逻辑,我们只需要进行一些配置即可。
-
在SSO认证中心需要配置:
@Configuration public class SaTokenSsoConfig {@Autowiredprivate void configSso(SaTokenConfig cfg) {cfg.setTokenName("satoken").setTimeout(30 * 24 * 60 * 60) // 30天.setActivityTimeout(-1).setIsConcurrent(true).setIsShare(true);// SSO 配置SaSsoManager.getConfig().setAuthUrl("/sso/auth").setCheckTicketUrl("/sso/checkTicket").setSendHttp("http://sso-server.com:9000/sso");} } -
在客户端需要配置:
@Configuration public class SaTokenClientConfig {@Autowiredprivate void configSso(SaTokenConfig cfg) {cfg.setTokenName("satoken").setTimeout(30 * 24 * 60 * 60);// SSO-Client端配置SaSsoManager.getConfig().setAuthUrl("/sso/auth").setCheckTicketUrl("/sso/checkTicket").setSsoServerUrl("http://sso-server.com:9000").setSendHttp("http://sso-server.com:9000/sso");} } -
关键接口实现
- 认证中心接口
@RestController public class SsoServerController {// SSO认证入口@RequestMapping("/sso/auth")public Object ssoAuth() {// 如果当前会话在认证中心已经登录,返回重定向URL和ticketif(StpUtil.isLogin()) {String redirectUrl = SaSsoManager.getConfig().getSsoClientUrl() + "?mode=simple&ticket=" + StpUtil.getLoginId();return SaResult.ok().setData(redirectUrl);}// 否则返回未登录状态return SaResult.error("未登录");}// 校验ticket接口@RequestMapping("/sso/checkTicket")public Object checkTicket(String ticket, String ssoLogoutCall) {// 校验ticket有效性if(StpUtil.getLoginIdByToken(ticket) != null) {// 返回用户信息return SaResult.ok("有效ticket").setData(StpUtil.getLoginIdByToken(ticket));}return SaResult.error("无效ticket");} } - 客户端接口
@RestController public class SsoClientController {// 客户端登录验证@RequestMapping("/sso/login")public Object ssoLogin(String ticket) {// 通过HTTP请求向认证中心验证ticketString checkUrl = SaSsoManager.getConfig().getSsoServerUrl() + "/sso/checkTicket?ticket=" + ticket;// 发送HTTP请求String result = SaSsoUtil.request(checkUrl);if("ok".equals(SaResult.ok(result).getMsg())) {// 验证成功,在客户端创建会话Object loginId = SaResult.ok(result).getData();StpUtil.login(loginId);return SaResult.ok("登录成功");}return SaResult.error("登录失败");}// 获取当前登录状态@RequestMapping("/sso/checkLogin")public Object checkLogin() {return SaResult.ok().setData(StpUtil.isLogin()).set("loginId", StpUtil.getLoginIdDefaultNull());} }
- 认证中心接口
- 优点:
- 支持不同域名,适用于多种客户端(如Web、移动端)。
- 不依赖Cookie和重定向,更灵活。
- 缺点:
- 需要自己处理HTTP请求,相对复杂。
- 安全性依赖于HTTPS和API接口的安全设计。
2.3.4 三种模式详细对比
| 模式 | 适用场景 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|---|
| 模式一 | 同一顶级域名 | 共享Cookie | 简单,无跳转 | 域名限制,安全性低 |
| 模式二 | 不同域名 | URL重定向 | 支持不同域名,较安全 | 多次重定向,体验稍差 |
| 模式三 | 不同域名,移动端等 | HTTP接口 | 灵活,支持多种客户端 | 实现复杂,需处理HTTP请求 |
2.3.5 Ticket劫持攻击
在URL重定向传播会话模式中,用户在认证中心登录成功后会将代表着用户身份的 Ticket 码拼接到redirect参数中,如果攻击者根据模仿我们的授权地址,巧妙的构造一个redirect的URL,就能够在这个重定向到这个url时获取到 Ticket ,再用这个Ticket 去访问服务器。
-
漏洞原因:造成此漏洞的直接原因就是SSO-Server认证中心没有对 redirect地址 进行任何的限制。
-
防范防范:对redirect参数进行校验,如果其不在指定的URL列表中时,拒绝下放ticket。
sa-token: sso-server: clients:sso-client3:# 配置允许单点登录的 url allow-url: http://sa-sso-client1.com:9003/sso/login -
为什么不直接回传 Token,而是先回传 Ticket,再用 Ticket 去查询对应的账号id?
- Token 作为长时间有效的会话凭证,在任何时候都不应该直接暴露在 URL 之中。
- 为了不让系统安全处于亚健康状态,Sa-Token-SSO 选择先回传 Ticket,再由 Ticket 获取账号id,且 Ticket 一次性用完即废,提高安全性。
三、SSO、OAuth 2.0 和 Spring Security 区别
- SSO(单点登录):是一种业务场景/用户体验的概括。
- OAuth 2.0:是一个授权框架,是实现SSO或其他授权场景的标准协议之一。
- Spring Security:是一个具体的Java安全框架,可以用来实现OAuth 2.0协议,从而构建SSO系统。
| 特性 | SSO(单点登录) | OAuth 2.0 | Spring Security |
|---|---|---|---|
| 本质 | 一种业务场景/用户体验 | 一个授权协议/标准 | 一个具体的安全框架 |
| 核心目标 | 一次登录,处处访问 | 安全的第三方授权 | 为应用提供全面的安全能力 |
| 关系 | 可以使用OAuth 2.0来实现 | 可以被Spring Security来实现 | 可以用来实现OAuth 2.0和SSO |
