前言
之前進行鑒權、授權都要寫一大堆代碼。如果使用像Spring Security這樣的架構,又要花好多時間學習,拿過來一用,好多配置項也不知道是幹嘛用的,又不想了解。要是不用Spring Security,token的生成、校驗、重新整理,權限的驗證配置設定,又全要自己寫,想想都頭大。
Spring Security太重而且配置繁瑣。自己實作所有的點必須又要顧及到,更是麻煩。
最近看到一個權限認證架構,真是夠簡單高效。這裡分享一個使用Sa-Token的gateway鑒權demo。
Sa-Token
需求分析
結構
認證
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()));
}
}
複制代碼
同樣的,作為一個異步元件,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());
}
});
}
複制代碼
實作擷取角色、權限接口
在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
@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);
}
}
複制代碼
修改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");
}
}