前言
之前进行鉴权、授权都要写一大堆代码。如果使用像Spring Security这样的框架,又要花好多时间学习,拿过来一用,好多配置项也不知道是干嘛用的,又不想了解。要是不用Spring Security,token的生成、校验、刷新,权限的验证分配,又全要自己写,想想都头大。
Spring Security太重而且配置繁琐。自己实现所有的点必须又要顾及到,更是麻烦。
最近看到一个权限认证框架,真是够简单高效。这里分享一个使用Sa-Token的gateway鉴权demo。

需求分析

结构

认证
sa-token模块
我们首先编写sa-token模块进行token生成和权限分配。
在sa-token的session模式下生成token非常方便,只需要调用
StpUtil.login(Object id);
就可以为账号生成 Token 凭证与 Session 会话了。
配置信息
server:
# 端口
port: 8081
spring:
application:
name: weishuang-account
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/weishuang_account?useUnicode=true&characterEncoding=utf8&autoReconnect=true&allowMultiQueries=true&useSSL=false&serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=CONVERT_TO_NULL
username: root
password: root
# redis配置
redis:
# Redis数据库索引(默认为0)
database: 0
# Redis服务器地址
host: 127.0.0.1
# Redis服务器连接端口
port: 6379
# Redis服务器连接密码(默认为空)
# password:
# 连接超时时间
timeout: 10s
lettuce:
pool:
# 连接池最大连接数
max-active: 200
# 连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1ms
# 连接池中的最大空闲连接
max-idle: 10
# 连接池中的最小空闲连接
min-idle: 0
############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
sa-token:
# token名称 (同时也是cookie名称)
token-name: weishuang-token
# token有效期,单位s 默认30天, -1代表永不过期
timeout: 2592000
# token临时有效期 (指定时间内无操作就视为token过期) 单位: 秒
activity-timeout: -1
# 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录)
is-concurrent: true
# 在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token)
is-share: true
# token风格
token-style: uuid
# 是否输出操作日志
is-log: false
# token前缀
token-prefix: Bearer
在sa-token的配置中,我使用了token-name来指定token的名称,如果不指定那么就是默认的satoken。
使用token-prefix来指定token的前缀,这样前端在header里传入token的时候就要加上Bearer了(注意有个空格),建议和前端商量一下需不需要这个前缀,如果不使用,直接传token就好了。
现在调用接口时传入的格式就是
weishuang-token = Bearer token123456
sa-token的session模式需要redis来存储session,在微服务中,各个服务的session也需要redis来同步。
当然sa-token也支持jwt来生成无状态的token,这样就不需要在服务中引入redis了。本文使用session模式(jwt的刷新token等机制还要自己实现,session的刷新sa-token都帮我们做好了,使用默认的模式更加方便,而且功能更多)
我们来编写一个登录接口
-
User
@Data
public class User {
/**
* id
*/
private String id;
/**
* 账号
*/
private String userName;
/**
* 密码
*/
private String password;
}
-
UserController
@RestController
@RequestMapping("/account/user/")
public class UserController {
@Autowired
private UserManager userManager;
@PostMapping("doLogin")
public SaResult doLogin(@RequestBody AccountUserLoginDTO req) {
userManager.login(req);
return SaResult.ok("登录成功");
}
}
-
UserManager
@Component
public class UserManagerImpl implements UserManager {
@Autowired
private UserService userService;
@Override
public void login(AccountUserLoginDTO req) {
//生成密码
String password = PasswordUtil.generatePassword(req.getPassword());
//调用数据库校验是否存在用户
User user = userService.getOne(req.getUserName(), password);
if (user == null) {
throw new RuntimeException("账号或密码错误");
}
//为账号生成Token凭证与Session会话
StpUtil.login(user.getId());
//为该用户的session存储更多信息
//这里为了方便直接把user实体存进去了,也包括了密码,自己实现时不建议这样做。
StpUtil.getSession().set("USER_DATA", user);
}
}
-
UserService
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Autowired
private UserMapper userMapper;
public User getOne(String username, String password){
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUserName,username)
.eq(User::getPassword,password);
return userMapper.selectOne(queryWrapper);
}
}
gateway模块
依赖
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<scope>provided</scope>
</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-loadbalancer</artifactId>
</dependency>
<!-- 引入gateway网关 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!-- Sa-Token 权限认证(Reactor响应式集成), 在线文档:https://sa-token.cc -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-reactor-spring-boot-starter</artifactId>
<version>1.34.0</version>
</dependency>
<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-dao-redis-jackson</artifactId>
<version>1.34.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
</dependencies>
配置
server:
port: 9000
spring:
application:
name: weishuang-gateway
cloud:
loadbalancer:
ribbon:
enabled: false
nacos:
discovery:
username: nacos
password: nacos
server-addr: localhost:8848
gateway:
routes:
- id: account
uri: lb://weishuang-account
order: 1
predicates:
- Path=/account/**
# redis配置
redis:
# Redis数据库索引(默认为0)
database: 0
# Redis服务器地址
host: 127.0.0.1
# Redis服务器连接端口
port: 6379
# Redis服务器连接密码(默认为空)
# password:
# 连接超时时间
timeout: 10s
lettuce:
pool:
# 连接池最大连接数
max-active: 200
# 连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1ms
# 连接池中的最大空闲连接
max-idle: 10
# 连接池中的最小空闲连接
min-idle: 0
############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
sa-token:
# token名称 (同时也是cookie名称)
token-name: weishuang-token
# token有效期,单位s 默认30天, -1代表永不过期
timeout: 2592000
# token临时有效期 (指定时间内无操作就视为token过期) 单位: 秒
activity-timeout: -1
# 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录)
is-concurrent: true
# 在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token)
is-share: true
# token风格
token-style: uuid
# 是否输出操作日志
is-log: false
# token前缀
token-prefix: Bearer
同样的,在gateway中也需要配置sa-token和redis,注意和在account服务中配置的要一致,否则在redis中获取信息的时候找不到。
gateway我们也注册到nacos中。
拦截认证
package com.weishuang.gateway.gateway.config;
import cn.dev33.satoken.reactor.filter.SaReactorFilter;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Configuration
public class SaTokenConfigure {
// 注册 Sa-Token全局过滤器
@Bean
public SaReactorFilter getSaReactorFilter() {
return new SaReactorFilter()
// 拦截地址
.addInclude("/**") /* 拦截全部path */
// 开放地址
.addExclude("/favicon.ico")
// 鉴权方法:每次访问进入
.setAuth(obj -> {
// 登录校验 -- 拦截所有路由,并排除/account/user/doLogin用于开放登录
SaRouter.match("/**", "/account/user/doLogin", r -> StpUtil.checkLogin());
// // 权限认证 -- 不同模块, 校验不同权限
// SaRouter.match("/account/**", r -> StpUtil.checkRole("user"));
// SaRouter.match("/admin/**", r -> StpUtil.checkRole("admin"));
// SaRouter.match("/goods/**", r -> StpUtil.checkPermission("goods"));
// SaRouter.match("/orders/**", r -> StpUtil.checkPermission("orders"));
// 更多匹配 ... */
})
// 异常处理方法:每次setAuth函数出现异常时进入
.setError(e -> {
return SaResult.error(e.getMessage());
})
;
}
}
只需要在gateway中添加一个全局过滤器进行鉴权操作就可以实现认证/鉴权操作了。
这里我们对**
全部路径进行拦截,但不要忘记把我们的登录接口释放出来,允许访问。
到这里简单的认证操作就实现了。我们仅仅使用了sa-token的一个StpUtil.login(Object id)
方法,其他事情sa-token都帮我们完成了,更无需复杂的配置和多到爆炸的Bean。
鉴权
有时候一个token认证并不能让我们区分用户能不能访问这个资源,使用那个菜单,我们需要更细粒度的鉴权。
在经典的RBAC模型里,用户会拥有多个角色,不同的角色又会有不同的权限。
这里我们使用五个表来表示用户、角色、权限之间的关系。

很显然,我们想判断用户有没有权限访问一个path,需要判断用户是否还有该权限。
在sa-token中想要实现这个功能,只需要实现StpInterface接口即可。
/**
* 自定义权限验证接口扩展
*/
@Component
public class StpInterfaceImpl implements StpInterface {
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
// 返回此 loginId 拥有的权限列表
return ...;
}
@Override
public List<String> getRoleList(Object loginId, String loginType) {
// 返回此 loginId 拥有的角色列表
return ...;
}
}
我们在gateway实现这个接口,为用户赋予权限,再进行权限校验,就可以精确到path了。
我们使用先从Redis中获取缓存数据,获取不到时走RPC调用account服务获取。
为了更方便的使用gateway调用account服务,我们使用nacos进行服务发现,用feign调用。
在account和gateway服务中配置nacos
配置nacos
在两个服务中加入nacos的配置
spring:
cloud:
nacos:
discovery:
username: nacos
password: nacos
server-addr: localhost:8848
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/weishuang_account?useUnicode=true&characterEncoding=utf8&autoReconnect=true&allowMultiQueries=true&useSSL=false&serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=CONVERT_TO_NULL
username: root
password: root
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
配置gateway
需要注意的是,gateway是基于WebFlux的一个响应式组件,HttpMessageConverters
不会像Spring Mvc一样自动注入,需要我们手动配置。
package com.weishuang.gateway.gateway.config;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import java.util.stream.Collectors;
@Configuration
public class HttpMessageConvertersConfigure {
@Bean
@ConditionalOnMissingBean
public HttpMessageConverters messageConverters(ObjectProvider<HttpMessageConverter<?>> converters) {
return new HttpMessageConverters(converters.orderedStream().collect(Collectors.toList()));
}
}
实现获取角色、权限接口
在account中实现通过用户获取角色、获取权限的接口
RoleController、PermissionController
@RestController
@RequestMapping("/account/role/")
public class RoleController {
@Autowired
private RoleManager roleManager;
@PostMapping("/getRoles")
public List<RoleDTO> getRoles(@RequestParam String userId) {
return roleManager.getRoles(userId);
}
}
@RestController
@RequestMapping("/account/permission/")
public class PermissionController {
@Autowired
private PermissionManager permissionManager;
@PostMapping("/getPermissions")
public List<PermissionDTO> getPermissions(@RequestParam String userId) {
return permissionManager.getPermissions(userId);
}
}
RoleManager
@Component
public class RoleManagerImpl implements RoleManager {
@Autowired
private RoleService roleService;
@Autowired
private UserRoleService userRoleService;
@Autowired
private Role2RoleDTOCovert role2RoleDTOCovert;
@Override
public List<RoleDTO> getRoles(String userId) {
List<UserRole> userRoles = userRoleService.getByUserId(userId);
Set<String> roleIds = userRoles.stream().map(UserRole::getRoleId).collect(Collectors.toSet());
List<RoleDTO> roleDTOS = role2RoleDTOCovert.covertTargetList2SourceList(roleService.getByIds(roleIds));
//服务不对外暴露,网关不传token到子服务,这里通过userId获取session,并设置角色。
String tokenValue = StpUtil.getTokenValueByLoginId(userId);
//为这个token在redis中设置角色,使网关获取更方便
if(StringUtils.isNotEmpty(tokenValue)){
if(CollectionUtils.isEmpty(roleDTOS)){
StpUtil.getTokenSessionByToken(tokenValue).set("ROLES", "");
}else{
List<String> roleNames = roleDTOS.stream().map(RoleDTO::getRoleName).collect(Collectors.toList());
StpUtil.getTokenSessionByToken(tokenValue).set("ROLES", ListUtil.list2String(roleNames));
}
}
return roleDTOS;
}
}
PermissionManager
@Component
public class PermissionManagerImpl implements PermissionManager {
@Autowired
private PermissionService permissionService;
@Autowired
private RolePermService rolePermService;
@Autowired
private UserRoleService userRoleService;
@Autowired
private Permission2PermissionDTOCovert permissionDTOCovert;
@Override
public List<PermissionDTO> getPermissions(String userId) {
//获取用户的角色
List<UserRole> roles = userRoleService.getByUserId(userId);
if (CollectionUtils.isEmpty(roles)) {
handleUserPermSession(userId, null);
}
Set<String> roleIds = roles.stream().map(UserRole::getRoleId).collect(Collectors.toSet());
List<RolePerm> rolePerms = rolePermService.getByRoleIds(roleIds);
if (CollectionUtils.isEmpty(rolePerms)) {
handleUserPermSession(userId, null);
}
Set<String> permIds = rolePerms.stream().map(RolePerm::getPermId).collect(Collectors.toSet());
List<PermissionDTO> perms = permissionDTOCovert.covertTargetList2SourceList(permissionService.getByIds(permIds));
handleUserPermSession(userId, perms);
return perms;
}
private void handleUserPermSession(String userId, List<PermissionDTO> perms) {
//通过userId获取session,并设置权限
String tokenValue = StpUtil.getTokenValueByLoginId(userId);
if (StringUtils.isNotEmpty(tokenValue)) {
//为了防止没有权限的用户多次进入到该接口,没权限的用户在redis中存入空字符串
if (CollectionUtils.isEmpty(perms)) {
StpUtil.getTokenSessionByToken(tokenValue).set("PERMS", "");
} else {
List<String> paths = perms.stream().map(PermissionDTO::getPath).collect(Collectors.toList());
StpUtil.getTokenSessionByToken(tokenValue).set("PERMS", ListUtil.list2String(paths));
}
}
}
}
gateway获取角色、权限
方式一:
官方写的实现StpInterfaceImpl
中的方法
作为一个异步组件,gateway中不允许使用引起阻塞的同步调用,若使用feign进行调用就会发生错误,我们使用CompletableFuture
来将同步调用转换成异步操作,但使用CompletableFuture
我们需要指定线程池,否则将会使用默认的ForkJoinPool
这里我们创建一个线程池,用于权限获取使用
package com.weishuang.gateway.gateway.config;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
@Configuration
public class ThreadPollConfig {
private final BlockingQueue<Runnable> asyncSenderThreadPoolQueue = new LinkedBlockingQueue<Runnable>(50000);
public final ExecutorService USER_ROLE_PERM_THREAD_POOL = new ThreadPoolExecutor(
Runtime.getRuntime().availableProcessors(),
Runtime.getRuntime().availableProcessors(),
1000 * 60,
TimeUnit.MILLISECONDS,
this.asyncSenderThreadPoolQueue,
new ThreadFactory() {
private final AtomicInteger threadIndex = new AtomicInteger(0);
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "RolePermExecutor_" + this.threadIndex.incrementAndGet());
}
});
}
StpInterfaceImpl
@Component
public class StpInterfaceImpl implements StpInterface {
@Autowired
private RoleFacade roleFacade;
@Autowired
private PermissionFacade permissionFacade;
@Autowired
private ThreadPollConfig threadPollConfig;
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
Object res = StpUtil.getTokenSession().get("PERMS");
if (res == null) {
CompletableFuture<List<String>> permFuture = CompletableFuture.supplyAsync(() -> {
// 返回此 loginId 拥有的权限列表
List<PermissionDTO> permissions = permissionFacade.getPermissions((String) loginId);
return permissions.stream().map(PermissionDTO::getPath).collect(Collectors.toList());
}, threadPollConfig.USER_ROLE_PERM_THREAD_POOL);
try {
return permFuture.get();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
String paths = (String) res;
System.out.println(paths);
return ListUtil.string2List(paths);
}
@Override
public List<String> getRoleList(Object loginId, String loginType) {
Object res = StpUtil.getTokenSession().get("ROLES");
if (res == null) {
CompletableFuture<List<String>> roleFuture = CompletableFuture.supplyAsync(() -> {
// 返回此 loginId 拥有的权限列表
List<RoleDTO> roles = roleFacade.getRoles((String) loginId);
return roles.stream().map(RoleDTO::getRoleName).collect(Collectors.toList());
}, threadPollConfig.USER_ROLE_PERM_THREAD_POOL);
try {
return roleFuture.get();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
String roleNames = (String) res;
System.out.println(roleNames);
return ListUtil.string2List(roleNames);
}
}
gateway配置过滤器,实现鉴权
@Component
public class ForwardAuthFilter implements WebFilter {
static Set<String> whitePaths = new HashSet<>();
static {
whitePaths.add("/account/user/doLogin");
whitePaths.add("/account/user/logout");
whitePaths.add("/account/user/register");
}
@Override
public Mono<Void> filter(ServerWebExchange serverWebExchange, WebFilterChain webFilterChain) {
ServerHttpRequest serverHttpRequest = serverWebExchange.getRequest();
String path = serverHttpRequest.getPath().toString();
//需要校验权限
if(!whitePaths.contains(path)){
//判断用户是否有该权限
if(!StpUtil.hasPermission(path)){
throw new NotPermissionException(path);
}
}
return webFilterChain.filter(serverWebExchange);
}
}
方式二:
如果您觉得一定要使用响应式才行,那么无需实现StpInterfaceImpl
/**
* 全局过滤器
*/
@Component
public class ForwardAuthFilter implements WebFilter {
@Autowired
private WebClient.Builder webClientBuilder;
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
ServerHttpRequest serverHttpRequest = exchange.getRequest();
String path = serverHttpRequest.getPath().toString();
// /api开头的都要鉴权
if (StringUtils.isNotEmpty(path) && path.startsWith("/api")) {
Mono<List<String>> permissionList = getPermissionList();
return permissionList.flatMap(list -> {
if (!StpUtil.stpLogic.hasElement(list, path)) {
return Mono.error(new NotPermissionException(path));
}
return chain.filter(exchange);
});
}
return chain.filter(exchange);
}
@Bean
@LoadBalanced
public WebClient.Builder loadBalancedWebClientBuilder() {
return WebClient.builder();
}
private Mono<List<String>> getPermissionList() {
String userId = (String) StpUtil.getLoginId();
Mono<List<PermissionDTO>> listMono = webClientBuilder.build()
.post()
.uri("http://weishuang-account/account/permission/getPermissions")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData("userId", userId))
.retrieve()
.bodyToFlux(PermissionDTO.class)
.collectList();
return listMono.map(permissions -> permissions.stream().map(PermissionDTO::getPath).collect(Collectors.toList()));
}
}
修改sa-token的配置
@Configuration
public class SaTokenConfigure {
// 注册 Sa-Token全局过滤器
@Bean
public SaReactorFilter getSaReactorFilter() {
return new SaReactorFilter()
// 拦截地址
.addInclude("/**") /* 拦截全部path */
// 开放地址
.addExclude("/favicon.ico")
// 鉴权方法:每次访问进入
.setAuth(obj -> {
// 登录校验 -- 拦截所有路由,排除白名单
SaRouter.match("/**")
.notMatch(new ArrayList<>(WhitePath.whitePaths))
.check(r -> StpUtil.checkLogin());
})
// 异常处理方法:每次setAuth函数出现异常时进入
.setError(e -> {
return SaResult.error(e.getMessage());
});
}
}
白名单
public class WhitePath {
static Set<String> whitePaths = new HashSet<>();
static {
whitePaths.add("/account/user/doLogin");
whitePaths.add("/account/user/logout");
whitePaths.add("/account/user/register");
}
}
来源:juejin.cn/post/7217360688263200825
后端专属技术群 构建高质量的技术交流社群,欢迎从事编程开发、技术招聘HR进群,也欢迎大家分享自己公司的内推信息,相互帮助,一起进步!
文明发言,以
交流技术
、职位内推
、行业探讨
为主广告人士勿入,切勿轻信私聊,防止被骗
加我好友,拉你进群
原文始发于微信公众号(Java面试题精选):Spring Gateway、Sa-Token、Nacos 认证/鉴权方案,yyds!
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/202849.html