Spring Gateway、Sa-Token、nacos完结认证/鉴权

前语

之前进行鉴权、授权都要写一大堆代码。假如运用像Spring Security这样的结构,又要花很多时刻学习,拿过来一用,很多装备项也不知道是干嘛用的,又不想了解。要是不用Spring Security,token的生成、校验、改写,权限的验证分配,又全要自己写,想想都头大。

Spring Security太重而且装备繁琐。自己完成一切的点有必要又要顾及到,更是麻烦。

最近看到一个权限认证结构,真是够简单高效。这儿共享一个运用Sa-Token的gateway鉴权demo。

Spring Gateway、Sa-Token、nacos完成认证/鉴权
Sa-Token

需求剖析

Spring Gateway、Sa-Token、nacos完成认证/鉴权

结构

Spring Gateway、Sa-Token、nacos完成认证/鉴权

认证

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模型里,用户会具有多个人物,不同的人物又会有不同的权限。

这儿咱们运用五个表来表明用户、人物、权限之间的关系。

Spring Gateway、Sa-Token、nacos完成认证/鉴权
很显然,咱们想判别用户有没有权限拜访一个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");
    }
}

项目源码地址

gitee.com/qiyan23/wei…