天天看點

Spring Gateway、Sa-Token、nacos完成認證/鑒權

作者:Java熱點

前言

之前進行鑒權、授權都要寫一大堆代碼。如果使用像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");
    }
}