一、Sa-Token
Sa-Token 是一个轻量级 Java 权限认证框架,主要解决:登录认证、权限认证、单点登录、OAuth2.0、分布式Session会话、微服务网关鉴权 等一系列权限相关问题。
当你受够 Shiro、SpringSecurity 等框架的三拜九叩之后,你就会明白,相对于这些传统老牌框架,Sa-Token 的 API 设计是多么的简单、优雅!
二、什么是Gateway?
大家可以这么理解,GateWay网关是我们项目的大门,是所有请求的统一入口处,以本篇文章的项目为例,可能更便于大家理解。
三、项目结构
这套项目采用的技术栈为
springboot 2.4.2 + spring could alibaba 2021.1 + spring could 2020.0.6
oss:对象存储服务
auth:认证服务
gateway:网关服务
subject:业务模块服务
并且所有模块都已交由nacos管理,bootstrap.yml配置文件内容如下:
spring:
application:
name: jc-club-gateway-dev
profiles:
active: dev
cloud:
nacos:
config:
server-addr: IP地址 + 端口
prefix: ${spring.application.name}
group: DEFAULT_GROUP
namespace:
file-extension: yaml
discovery:
enabled: true
server-addr: IP地址 + 端口
四、具体步骤
1、网关层实现拦截
首先在新建的gateway网关的pom文件中新增依赖
<dependencies>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.2.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.16</version>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-loadbalancer</artifactId>
</dependency>
<!-- Sa-Token 权限认证(Reactor响应式集成), 在线文档:https://sa-token.cc -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-reactor-spring-boot-starter</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>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.6</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring-cloud-alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
在网关新建application.yml文件,并添加sa-token、以及其他三个模块的nacos,大家根据自己的实际情况对应配置
server: port: 5000 spring: cloud: gateway: routes: - id: oss uri: lb://jc-club-oss-dev predicates: - Path=/oss/** filters: - StripPrefix=1 - id: auth uri: lb://jc-club-auth-dev predicates: - Path=/auth/** filters: - StripPrefix=1 - id: subject uri: lb://jc-club-subject-dev predicates: - Path=/subject/** filters: - StripPrefix=1 sa-token: # token 名称(同时也是 cookie 名称) token-name: satoken # token 有效期(单位:秒) 默认30天,-1 代表永久有效 timeout: 2592000 # token 如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结 active-timeout: -1 # 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录) is-concurrent: true # 在多人登录同一账号时,是否共用一个 token is-share: true # token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128) token-style: random-32 # 是否输出操作日志 is-log: true
新建SaTokenConfigure,,实现网关拦截
/**
* 权限验证配置器
*/
@Configuration
public class SaTokenConfigure {
// 注册 Sa-Token全局过滤器
@Bean
public SaReactorFilter getSaReactorFilter() {
return new SaReactorFilter()
// 拦截地址
.addInclude("/**") /* 拦截全部path */
// 鉴权方法:每次访问进入
.setAuth(obj -> {
System.out.println("-------- 前端访问path:" + SaHolder.getRequest().getRequestPath());
// 登录校验 -- 拦截所有路由,并排除/user/doLogin 用于开放登录
SaRouter.match("/auth/**", "/auth/user/**", r -> StpUtil.checkRole("admin"));
// 权限认证 -- 不同模块, 校验不同权限
SaRouter.match("/oss/**", r -> StpUtil.checkLogin());
SaRouter.match("/subject/subject/add", r -> StpUtil.checkPermission("subject:add"));
SaRouter.match("/subject/**", r -> StpUtil.checkLogin());
});
}
}
2、认证服务层实现Sa-token登录认证
首先导入依赖
<!-- Sa-Token 权限认证 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.38.0</version>
</dependency>
<!-- sa-token 依赖-->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.37.0</version>
</dependency>
然后在application.yml中添加配置
############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
sa-token:
# token 名称(同时也是 cookie 名称)
token-name: satoken
# 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: random-32
# 是否输出操作日志
is-log: true
然后我们写一个测试类进行登录的简单测试
@RestController
@RequestMapping("/user/")
public class TestController {
// 测试登录,浏览器访问: http://localhost:8081/user/doLogin?username=zhang&password=123456
@RequestMapping("doLogin")
public SaResult doLogin(String username, String password) {
// 此处仅作模拟示例,真实项目需要从数据库中查询数据进行比对
if("zhang".equals(username) && "123456".equals(password)) {
StpUtil.login(10001);
SaTokenInfo tokenInfo = StpUtil.getTokenInfo();
// 第3步,返回给前端
return SaResult.data(tokenInfo);
}
return SaResult.error("登录失败");
}
// 查询登录状态,浏览器访问: http://localhost:8081/user/isLogin
@RequestMapping("isLogin")
public String isLogin() {
return "当前会话是否登录:" + StpUtil.isLogin();
}
}
这里登录账号为:zhang,密码为123456,点击登录将会生成一串token
我们再访问另一个接口,验证是否登录成功
这里可以看到我们是登录成功了的,那么一个简单的登录案例就成功啦,接下来我们i可以对网关的拦截做一个升级改造,我们这里加一个全局异常拦截器以及结合redis进行权限的验证处理
首先我们需要创建一个Result类
private Boolean success;
private Integer code;
private String message;
public static Result fail( Integer code, String message){
Result result = new Result<>();
result.setSuccess(false);
result.setCode(code);
result.setMessage(message);
return result;
}
以及一个resoult枚举类
@Getter
public enum ResultCodeEnum {
SUCCESS(200,"拿下拿下"),
FAIL(500,"请求失败咯");
private int code;
private String desc;
ResultCodeEnum(int code , String desc){
this.code = code;
this.desc = desc;
}
public static ResultCodeEnum getByCode(int codeVal){
for (ResultCodeEnum resultCodeEnum : ResultCodeEnum.values()) {
if (resultCodeEnum.code == codeVal){
return resultCodeEnum;
}
}
return null;
}
}
接下来我们添加一个全局异常处理器,只要是satoken的异常我们全部捕捉并返回401错误
/**
* 全局异常处理
*/
@Component
public class GatewayExceptionHandler implements ErrorWebExceptionHandler {
private ObjectMapper objectMapper = new ObjectMapper();
@Override
public Mono<Void> handle(ServerWebExchange serverWebExchange, Throwable throwable) {
ServerHttpRequest request = serverWebExchange.getRequest();
ServerHttpResponse response = serverWebExchange.getResponse();
Integer code = 200;
String message = "";
if (throwable instanceof SaTokenException) {
code = 401;
message = "用户无权限";
} else {
code = 500;
message = "系统繁忙";
}
Result result = Result.fail(code, message);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
return response.writeWith(Mono.fromSupplier(() -> {
DataBufferFactory dataBufferFactory = response.bufferFactory();
byte[] bytes = null;
try {
bytes = objectMapper.writeValueAsBytes(result);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
return dataBufferFactory.wrap(bytes);
}));
}
}
然后我们利用redis对satoken进行一个权限的校验
@Component
public class StpInterfaceImpl implements StpInterface {
@Resource
private RedisUtil redisUtil;
private final String authPermissionPrefix = "auto.permission";
private final String authRolePrefix = "auth.role";
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
return gerAuth(loginId.toString(), authPermissionPrefix);
}
@Override
public List<String> getRoleList(Object loginId, String loginType) {
return gerAuth(loginId.toString(), authRolePrefix);
}
public List<String> gerAuth(Object loginId, String prefix) {
String authKey = redisUtil.buildKey(prefix, loginId.toString());
String authValue = redisUtil.get(authKey);
if (StringUtils.isBlank(authValue)) {
return Collections.emptyList();
}
List<String> authList = new Gson().fromJson(authValue, List.class);
return authList;
}
}
这里时重写了两个类,分别是RedisConfig、RedisUtil的方法 ,详见配置如下
/**
* Redis的config处理
*
* @author: ChickenWing
* @date: 2023/10/28
*/
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
RedisSerializer<String> redisSerializer = new StringRedisSerializer();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(redisSerializer);
redisTemplate.setHashKeySerializer(redisSerializer);
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer());
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer());
return redisTemplate;
}
private Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer() {
Jackson2JsonRedisSerializer<Object> jsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
jsonRedisSerializer.setObjectMapper(objectMapper);
return jsonRedisSerializer;
}
}
//redis工具类
@Component
@Slf4j
public class RedisUtil {
@Resource
private RedisTemplate redisTemplate;
private static final String CACHE_KEY_SEPARATOR = ".";
/**
* 构建缓存key
*/
public String buildKey(String... strObjs) {
return Stream.of(strObjs).collect(Collectors.joining(CACHE_KEY_SEPARATOR));
}
/**
* 是否存在key
*/
public boolean exist(String key) {
return redisTemplate.hasKey(key);
}
/**
* 删除key
*/
public boolean del(String key) {
return redisTemplate.delete(key);
}
/**
* set(不带过期)
*/
public void set(String key, String value) {
redisTemplate.opsForValue().set(key, value);
}
/**
* set(带过期)
*/
public boolean setNx(String key, String value, Long time, TimeUnit timeUnit) {
return redisTemplate.opsForValue().setIfAbsent(key, value, time, timeUnit);
}
/**
* 获取string类型缓存
*/
public String get(String key) {
return (String) redisTemplate.opsForValue().get(key);
}
public Boolean zAdd(String key, String value, Long score) {
return redisTemplate.opsForZSet().add(key, value, Double.valueOf(String.valueOf(score)));
}
public Long countZset(String key) {
return redisTemplate.opsForZSet().size(key);
}
public Set<String> rangeZset(String key, long start, long end) {
return redisTemplate.opsForZSet().range(key, start, end);
}
public Long removeZset(String key, Object value) {
return redisTemplate.opsForZSet().remove(key, value);
}
public void removeZsetList(String key, Set<String> value) {
value.stream().forEach((val) -> redisTemplate.opsForZSet().remove(key, val));
}
public Double score(String key, Object value) {
return redisTemplate.opsForZSet().score(key, value);
}
public Set<String> rangeByScore(String key, long start, long end) {
return redisTemplate.opsForZSet().rangeByScore(key, Double.valueOf(String.valueOf(start)), Double.valueOf(String.valueOf(end)));
}
public Object addScore(String key, Object obj, double score) {
return redisTemplate.opsForZSet().incrementScore(key, obj, score);
}
public Object rank(String key, Object obj) {
return redisTemplate.opsForZSet().rank(key, obj);
}
}
这个时候我们访问需要权限的端口时就会抛出无权限异常,到此我们微服务整合网关+Sa-Token简单应用就完成啦!