你好,这里是专栏“SpringCloud2023实战”。
前言
单点登录(Single Sign-On,简称SSO)是一种认证机制,它允许用户只需一次登录就可以访问多个应用程序或系统。在使用SSO时,用户只需要提供一次凭据(用户名和密码等),就可以访问多个系统,而无需在每个系统中都进行登录认证。
SSO的实现通常涉及以下三个角色:
- 服务提供商(Service Provider,SP):提供需要认证用户身份的应用程序或系统。
- 身份提供商(Identity Provider,IdP):负责处理认证请求,验证用户身份,并返回授权票据。
- 用户(User):需要访问多个应用程序或系统,并使用相同的凭据进行登录。
单点登录(SSO)解决用户在访问多个互相信任的系统时需要反复登录的问题。通过单点登录,用户只需在一个系统中登录一次,就可以访问所有系统,从而提高用户体验。
架构选型
不同架构下的 SSO 接入问题如下(摘自sa-token):
系统架构 | 采用模式 | 简介 |
---|---|---|
前端同域 + 后端同 Redis | 模式一 | 共享 Cookie 同步会话 |
前端不同域 + 后端同 Redis | 模式二 | URL重定向传播会话 |
前端不同域 + 后端不同 Redis | 模式三 | Http请求获取会话 |
根据同域与不同域和session存储中间件redis的不同分为三种模式,下文将基于最特殊的“模式三”说明springcloudGateway结合sa-token完成SSO服务的开发任务。
sa-token是一款开源好用的sso实现框架,提供开箱即用的sso服务集成。
SpringCloudGateway作为微服务的入口,用来提供sso服务是比较合适的。
SSO服务搭建
引入pom.xml
- 引入sa-token和springcloudgateway主要是引入
spring-cloud-starter-gateway
和sa-token-reactor-spring-boot3-starter
。
<dependencies>
<!--gateway 网关依赖,内置webflux 依赖-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
<!--注册中心客户端-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zookeeper-discovery</artifactId>
</dependency>
<!-- LB 扩展 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<!--caffeine 替换LB 默认缓存实现-->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- 工具包依赖 -->
<dependency>
<groupId>io.rainforest</groupId>
<artifactId>banana-common-core</artifactId>
</dependency>
<!--Lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<!--测试依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Sa-Token 权限认证,在线文档:https://sa-token.cc -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-reactor-spring-boot3-starter</artifactId>
<version>1.37.0</version>
</dependency>
<!-- Sa-Token 插件:整合SSO -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-sso</artifactId>
<version>1.37.0</version>
</dependency>
<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>1.37.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>
<!-- Http请求工具(在模式三的单点注销功能下用到,如不需要可以注释掉) -->
<dependency>
<groupId>com.dtflys.forest</groupId>
<artifactId>forest-spring-boot-starter</artifactId>
<version>1.5.26</version>
</dependency>
</dependencies>
修改配置
- 主要是修改Sa-Token配置和sso相关的测试账户,以及使用到的
spring.redis
。
## 应用名称设置
spring.application.name: gateway-sso
## 微服务设置
spring:
# Redis配置 (SSO模式一和模式二使用Redis来同步会话)
redis:
# Redis数据库索引(默认为0)
database: 1
# Redis服务器地址
host: 127.0.0.1
# Redis服务器连接端口
port: 6379
# Redis服务器连接密码(默认为空)
password:
cloud:
zookeeper:
connect-string: localhost:2181
gateway:
discovery:
locator:
enabled: false
lowerCaseServiceId: true
routes: ## 服务端路由设置
- id: client1
uri: lb://client1
predicates:
- Path=/client1/**
# filters:
# - StripPrefix=0
- id: client2
uri: lb://client2
predicates:
- Path=/client2/**
filters:
- StripPrefix=0
- id: client3
uri: lb://client3
predicates:
- Path=/client3/**
filters:
- StripPrefix=0
## springboot服务端设置
server:
port: 10105
servlet:
context-path: /
## 日志级别设置
logging:
level:
root: info
## sso 相关配置
sso:
account: ## 测试账号密码
- username: yulin # 账号密码
password: 123yl.
userid: 10001
permissions:
- user.add
- user.delete
- user.update
- user.query
roles:
- admin
- user
- username: sa
password: 123456
userid: 10002
permissions:
- user.add
- user.update
- user.query
roles:
- user
- username: admin
password: 123456
userid: 10003
permissions:
- user.update
- user.query
roles:
- user
- username: test
password: 123456
userid: 10004
permissions:
- user.update
- user.test
roles:
- test
# Sa-Token 配置
############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
sa-token:
# token 名称(同时也是 cookie 名称)
token-name: banana-token
# token 有效期(单位:秒) 默认30天,-1 代表永久有效
timeout: 2592000
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
active-timeout: -1
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
is-concurrent: true
# 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)
is-share: true
# token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)
token-style: uuid
# 是否输出操作日志
is-log: true
# ------- SSO-模式一相关配置 (非模式一不需要配置)
# cookie:
# 配置 Cookie 作用域
# domain: stp.com
# ------- SSO-模式二相关配置
sso:
# Ticket有效期 (单位: 秒),默认五分钟
# ticket-timeout: 300
# 所有允许的授权回调地址
allow-url: "*"
# ------- SSO-模式三相关配置 (下面的配置在使用SSO模式三时打开)
# 是否打开模式三
is-http: true
# SSO-Server端 ticket校验地址
check-ticket-url: http://localhost:10105/sso/checkTicket
sign:
# API 接口调用秘钥
secret-key: helloworld
# ---- 除了以上配置项,你还需要为 Sa-Token 配置http请求处理器(文档有步骤说明)
forest:
# 关闭 forest 请求日志打印
log-enabled: false
修改启动类
- 启动类不需要特殊修改,作为网关需要启用注册中心来使用负载均衡。
package io.rainforest.banana.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@SpringBootApplication
@EnableDiscoveryClient
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
开发SSO基础接口
项目地址: http://localhost:10105
接口 | 说明 |
---|---|
/user/login | 用户登录 |
/user/token | 获取token信息 |
/user/isLogin | 判断用户是否登录 |
/user/logout | 用户登出 |
/user/userInfo | 用户信息 |
/user/role | 用户角色信息 |
/user/permission | 用户权限信息 |
package io.rainforest.banana.gateway.sso.web.user;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import io.rainforest.banana.gateway.sso.conifg.SSOConfig;
import io.rainforest.banana.gateway.sso.dto.base.Account;
import io.rainforest.banana.gateway.sso.service.user.UserSSOServiceI;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.ObjectUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/user")
public class UserSSOController {
@Autowired
private UserSSOServiceI userSSOServiceI;
// 测试登录 ---- http://localhost:10105/user/doLogin?name=test&pwd=123456
@GetMapping("login")
public SaResult login(String name, String pwd) {
if(StpUtil.isLogin()){
StpUtil.logout();
}
// 此处仅作模拟示例,真实项目需要从数据库中查询数据进行比对
Account account = userSSOServiceI.getAccount(name, pwd);
// 此处仅做模拟登录,真实环境应该查询数据进行登录
if(!ObjectUtils.isEmpty(account)){
StpUtil.login(account.getUserid());
return SaResult.ok("登录成功!").setData(StpUtil.getTokenValue());
}
return SaResult.error("登录失败!");
}
// 查询登录状态 ---- http://localhost:10105/user/isLogin
@GetMapping("isLogin")
public SaResult isLogin() {
return SaResult.data(StpUtil.isLogin());
}
// 查询 Token 信息 ---- http://localhost:10105/user/tokenInfo
@GetMapping("token")
public SaResult token() {
return SaResult.data(StpUtil.getTokenInfo());
}
// 测试注销 ---- http://localhost:10105/user/logout
@GetMapping("logout")
public SaResult logout() {
StpUtil.logout();
return SaResult.ok();
}
/**
* 获取用户信息
* @return
*/
@GetMapping("userInfo")
public SaResult userInfo() {
String loginId = StpUtil.getLoginIdAsString();
if (loginId == null) {
return SaResult.error("未登录");
}
return SaResult.data(userSSOServiceI.getUserInfo((loginId)));
}
/**
* 获取权限信息
* @return
*/
@GetMapping("role")
public SaResult role() {
return SaResult.data(StpUtil.getRoleList());
}
/**
* 获取权限信息
* @return
*/
@GetMapping("permission")
public SaResult permission() {
return SaResult.data(StpUtil.getPermissionList());
}
}
也可以通过sa-token提供的开箱即用接口作为登录服务,线上环境不推荐使用。
/**
* Sa-Token-SSO Server端 Controller
*/
@RestController
public class SsoServerController {
/*
* SSO-Server端:处理所有SSO相关请求
* 开放接口api说明:https://sa-token.cc/doc.html#/sso/sso-apidoc
* 或者查看类: cn.dev33.satoken.sso.name.ApiName
*/
@RequestMapping("/sso/*")
public Object ssoRequest() {
return SaSsoProcessor.instance.serverDister();
}
}
实现权限获取方法
通过实现权限获取方法可以使得用户登录的权限匹配。
package io.rainforest.banana.gateway.sso.conifg;
import cn.dev33.satoken.stp.StpInterface;
import io.rainforest.banana.gateway.sso.service.user.UserSSOServiceI;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 自定义权限加载接口实现类
* 保证此类被 SpringBoot 扫描,完成 Sa-Token 的自定义权限验证扩展
*/
@Component
public class StpInterfaceImpl implements StpInterface {
@Autowired
private UserSSOServiceI userSSOServiceI;
/**
* 返回一个账号所拥有的权限码集合
*/
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
// 本 list 仅做模拟,实际项目中要根据具体业务逻辑来查询权限
return userSSOServiceI.getPermissionsByLoginId((String) loginId);
}
/**
* 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验)
*/
@Override
public List<String> getRoleList(Object loginId, String loginType) {
// 本 list 仅做模拟,实际项目中要根据具体业务逻辑来查询角色
return userSSOServiceI.getRolesByLoginId((String) loginId);
}
}
权限验证说明
@Bean
public SaReactorFilter getSaReactorFilter() {
return new SaReactorFilter()
// 指定 [拦截路由]
.addInclude("/**") /* 拦截所有path */
// 指定 [放行路由]
.addExclude("/favicon.ico")
.addExclude("/user/**")
// 指定[认证函数]: 每次请求执行
.setAuth(obj -> {
// System.out.println("---------- sa全局认证");
SaRouter.match("/**", () -> StpUtil.checkLogin());
// 根据路由划分模块,不同模块不同鉴权
// todo 修改为动态权限鉴权,角色权限和路径基于数据库配置
SaRouter.match("/admin/**", r -> StpUtil.checkPermission("admin"));
SaRouter.match("/goods/**", r -> StpUtil.checkPermission("goods"));
})
// 指定[异常处理函数]:每次[认证函数]发生异常时执行此函数
.setError(e -> {
// System.out.println("---------- sa全局异常 ");
return SaResult.error(e.getMessage());
});
}
/admin/**
接口校验是否拥有admin
角色/goods/**
接口校验是否拥有goods
角色- 实际场景并不多使用这种硬编码方式,后续修改为动态权限鉴权,角色权限和路径基于数据库配置。
例子说明
用户登录流程
## 进行用户登录
http://localhost:10105/user/doLogin?name=sa&pwd=123456
http://localhost:10105/user/doLogin?name=test&pwd=123456
## 测试接口信息
http://localhost:10105/user/tokenInfo
测试角色流程
有权限用户登录:
## 进行用户登录
http://localhost:10105/user/doLogin?name=sa&pwd=123456
## 测试接口信息
http://localhost:10105/demoUser/tokenInfo
无权限用户登录测试:
## 进行用户登录
http://localhost:10105/user/doLogin?name=test&pwd=123456
## 测试接口信息
http://localhost:10105/demoUser/tokenInfo
注: 实际测试中基于注解的权限并未生效。基于filter的权限拦截生效了。
单元测试
下面代码基于登录成功和不成功写的单元测试用例。
package io.rainforest.banana.gateway.sso.web.base;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
@SpringBootTest
@AutoConfigureMockMvc
public class LoginTest {
@Test
void testLoginSuccess(@Autowired WebTestClient webClient) {
// 使用@Autowired注解获取WebTestClient对象,用于发送HTTP请求
webClient
.get().uri(url -> url.path("/user/login").queryParam("name", "test").queryParam("pwd", "123456").build())
.accept(MediaType.APPLICATION_JSON)
.exchange() // 发送GET请求并获取响应
.expectStatus().isOk() // 断言响应状态码为200
.expectBody().jsonPath("$.code").isEqualTo(200); // 断言响应体中的jsonPath("$.code")是否等于200
}
@Test
void testLoginFailure(@Autowired WebTestClient webClient) {
// 使用@Autowired注解获取WebTestClient对象,用于发送HTTP请求
webClient
.get().uri(url -> url.path("/user/login").queryParam("name", "test233").queryParam("pwd", "123456").build())
.accept(MediaType.APPLICATION_JSON)
.exchange() // 发送GET请求并获取响应
.expectStatus().isOk() // 断言响应状态码为200
.expectBody().jsonPath("$.code").isEqualTo(500); // 断言响应体中的jsonPath("$.code")是否等于200
}
}
关于作者
来自全栈程序员nine的探索与实践,持续迭代中。