天天看點

通用權限系統

權限服務

基礎環境搭建

在開發權限服務的業務功能之前,我們需要進行基礎環境的搭建,這是權限服務的基礎。這些基礎環境包括:配置檔案、配置類、啟動類等。

配置檔案

bootstrap.yml

由于我們目前使用的是Nacos作為整個項目的配置中心,是以Spring Boot的大部配置設定置檔案都在Nacos中進行統一配置,我們的項目中隻需要按照Spring Boot的要求在resources目錄下提供bootstrap.yml配置檔案即可,檔案内容如下:

bootstrap.yml的加載順序早于application.yml,作用是讀取nacos中配置檔案。

# @xxx@ 從pom.xml中取值, 是以 @xx@ 标注的值,都不能從nacos中擷取
pinda:
  nacos:
    # 配置檔案從pd-parent.pom中加載
    ip: ${NACOS_IP:@pom.nacos.ip@}
    port: ${NACOS_PORT:@pom.nacos.port@}
    namespace: ${NACOS_ID:@pom.nacos.namespace@}

spring:
  main:
    allow-bean-definition-overriding: true
  application:
    name: @project.artifactId@
  profiles: # 目前環境為dev
    active: @pom.profile.name@
  cloud:
    nacos:
      config: # 配置中心相關
        server-addr: ${pinda.nacos.ip}:${pinda.nacos.port}
        file-extension: yml
        namespace: ${pinda.nacos.namespace}
        shared-dataids: common.yml,redis.yml,mysql.yml
        refreshable-dataids: common.yml
        enabled: true
      discovery: # 服務注冊中心相關
        server-addr: ${pinda.nacos.ip}:${pinda.nacos.port}
        namespace: ${pinda.nacos.namespace}
        metadata: # 中繼資料,用于權限服務實時擷取各個服務的所有接口
          management.context-path: ${server.servlet.context-path:}${spring.mvc.servlet.path:}${management.endpoints.web.base-path:}
  aop:
    proxy-target-class: true
    auto: true

# 隻能配置在bootstrap.yml ,否則會生成 log.path_IS_UNDEFINED 檔案夾
# window會自動在 代碼所在盤 根目錄下自動建立檔案夾,  如: D:/data/projects/logs
logging:
  file:
    path: /data/projects/logs
    name: ${logging.file.path}/${spring.application.name}/root.log

# 用于/actuator/info
info:
  name: '@project.name@'
  description: '@project.description@'
  version: '@project.version@'
  spring-boot-version: '@spring.boot.version@'
  spring-cloud-version: '@spring.cloud.version@'
           

logback-spring.xml

logback-spring.xml中引用的變量來自于pd-tools-log子產品

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!--引入pd-tools-log的配置檔案-->
    <include resource="com/itheima/pinda/log/logback/pinda-defaults.xml"/>

    <!--level指日志輸出級别,appender指日志輸出類型比如控制台或者檔案-->
    <springProfile name="test,docker,prod">
        <logger name="com.itheima.pinda.authority.controller" additivity="true"
                level="${log.level.controller}">
            <appender-ref ref="ASYNC_CONTROLLER_APPENDER"/>
        </logger>
        <logger name="com.itheima.pinda.authority.biz.service" additivity="true"
                level="${log.level.service}">
            <appender-ref ref="ASYNC_SERVICE_APPENDER"/>
        </logger>
        <logger name="com.itheima.pinda.authority.biz.dao" additivity="false"
                level="${log.level.dao}">
            <appender-ref ref="ASYNC_DAO_APPENDER"/>
        </logger>
    </springProfile>

    <springProfile name="dev">
        <logger name="com.itheima.pinda.authority.controller" additivity="true"
                level="${log.level.controller}">
            <appender-ref ref="CONTROLLER_APPENDER"/>
        </logger>
        <logger name="com.itheima.pinda.authority.biz.service" additivity="true"
                level="${log.level.service}">
            <appender-ref ref="SERVICE_APPENDER"/>
        </logger>
    </springProfile>
</configuration>
           

j2cache配置檔案

在目前pd-auth-server項目中會使用到j2cache來操作緩存,在Nacos配置中心的redis.yml中已經配置了j2cache的相關配置:

redis.yml配置檔案存在于nacos中,配置了一二級緩存資訊

j2cache:
  #  config-location: /j2cache.properties
  open-spring-cache: true
  cache-clean-mode: passive
  allow-null-values: true
  redis-client: lettuce
  l2-cache-open: true
  # l2-cache-open: false     # 關閉二級緩存
  broadcast: net.oschina.j2cache.cache.support.redis.SpringRedisPubSubPolicy
  #  broadcast: jgroups       # 關閉二級緩存
  L1:
    provider_class: caffeine
  L2:
    provider_class: net.oschina.j2cache.cache.support.redis.SpringRedisProvider
    config_section: lettuce
  sync_ttl_to_redis: true
  default_cache_null_object: false
  serialization: fst
caffeine:
  properties: /j2cache/caffeine.properties   # 這個配置檔案需要放在項目中
lettuce:
  mode: single
  namespace:
  storage: generic
  channel: j2cache
  scheme: redis
  hosts: ${pinda.redis.ip}:${pinda.redis.port}
  password: ${pinda.redis.password}
  database: ${pinda.redis.database}
  sentinelMasterId:
  maxTotal: 100
  maxIdle: 10
  minIdle: 10
  timeout: 10000
           

通過上面的配置可以看到,還需要在項目中提供/j2cache/caffeine.properties,檔案内容如下:

#########################################
# Caffeine configuration
# \u6682\u65F6\u6CA1\u7528
# [name] = size, xxxx[s|m|h|d]
#########################################
default=2000, 2h
captcha=1000, 5m
resource=2000, 2h
user_resource=3000, 2h
           

密鑰檔案

JWT簽名算法中,一般有兩個選擇:HS256和RS256。

HS256 (帶有 SHA-256 的 HMAC )是一種對稱加密算法, 雙方之間僅共享一個密鑰。由于使用相同的密鑰生成簽名和驗證簽名, 是以必須注意確定密鑰不被洩密。

RS256 (采用SHA-256 的 RSA 簽名) 是一種非對稱加密算法, 它使用公共/私鑰對: JWT的提供方采用私鑰生成簽名, JWT 的使用方擷取公鑰以驗證簽名。

在Nacos配置中心的pd-auth-server.yml中通過配置的形式已經指定了這兩個配置檔案的位置和名稱:

authentication:
  user:
    header-name: token
    expire: 43200               # 外部token有效期為12小時
    pri-key: client/pri.key    # 加密
    pub-key: client/pub.key    # 解密
           

spy.properties

spy.properties是p6spy所需的屬性檔案。p6spy是一個開源項目,通常使用它來跟蹤資料庫操作,檢視程式運作過程中執行的sql語句,還可以輸出執行sql語句消耗的時間。

在Nacos配置中心的pd-auth-server-dev.yml中進行了如下配置:

pd-auth-server-dev.yml此配置檔案隻針對dev環境p6spy進行sql語句輸出,對于prod環境不需要使用p6spy

# p6spy是一個開源項目,通常使用它來跟蹤資料庫操作,檢視程式運作過程中執行的sql語句
# 開發環境需要使用p6spy進行sql語句輸出
# 但p6spy會有性能損耗,不适合在生産線使用,故其他環境無需配置
spring:
  datasource:
    driver-class-name: com.p6spy.engine.spy.P6SpyDriver
    url: jdbc:p6spy:mysql://${pinda.mysql.ip}:${pinda.mysql.port}/${pinda.mysql.database}?serverTimezone=CTT&characterEncoding=utf8&useUnicode=true&useSSL=false&autoReconnect=true&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true
    db-type: mysql
           

我們在開發階段使用的資料源其實就是P6Spy提供的資料源,這樣就可以在控制台列印sql已經sql執行的時間了。

spy.properties是p6spy所需的屬性檔案。

spy.properties配置檔案内容如下:

module.log=com.p6spy.engine.logging.P6LogFactory,com.p6spy.engine.outage.P6OutageFactory
logMessageFormat=com.baomidou.mybatisplus.extension.p6spy.P6SpyLogger
appender=com.baomidou.mybatisplus.extension.p6spy.StdoutLogger
deregisterdrivers=true
useprefix=true
excludecategories=info,debug,result,commit,resultset
dateformat=yyyy-MM-dd HH:mm:ss
driverlist=com.mysql.cj.jdbc.Driver
outagedetection=true
outagedetectioninterval=2
           

dozer

Dozer是Java Bean到Java Bean映射器

在resources下建立dozer目錄并提供biz.dozer.xml和global.dozer.xml檔案,内容如下:

biz.dozer.xml

<?xml version="1.0" encoding="UTF-8"?>
<mappings xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xmlns="http://dozermapper.github.io/schema/bean-mapping"
          xsi:schemaLocation="http://dozermapper.github.io/schema/bean-mapping
                             http://dozermapper.github.io/schema/bean-mapping.xsd">
    <mapping date-format="yyyy-MM-dd HH:mm:ss">
        <class-a>com.itheima.pinda.authority.entity.auth.Menu</class-a>
        <class-b>com.itheima.pinda.authority.dto.auth.VueRouter</class-b>
        <field>
            <a>name</a>
            <b>meta.title</b>
        </field>
        <field>
            <a>name</a>
            <b>name</b>
        </field>
        <field>
            <a>icon</a>
            <b>meta.icon</b>
        </field>
    </mapping>
</mappings>
           

global.dozer.xml

<?xml version="1.0" encoding="UTF-8"?>
<mappings xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xmlns="http://dozermapper.github.io/schema/bean-mapping"
          xsi:schemaLocation="http://dozermapper.github.io/schema/bean-mapping 
                              http://dozermapper.github.io/schema/bean-mapping.xsd">
    <!--
    @see: http://www.jianshu.com/p/bf8f0e8aee23
    @see: http://blog.csdn.net/whhahyy/article/details/48594657
    全局配置:
    <date-format>表示日期格式
    <stop-on-errors>錯誤處理開關
    <wildcard>通配符
    <trim-strings>裁剪字元串開關
     -->
    <configuration>
        <date-format>yyyy-MM-dd HH:mm:ss</date-format>
    </configuration>
</mappings>
           

配置類

全局異常處理的配置類:

繼承DefaultGlobalExceptionHandler類的一個全局異常處理的配置類

import com.itheima.pinda.common.handler.DefaultGlobalExceptionHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

/**
 * 全局異常處理
 */
@Configuration
@ControllerAdvice(annotations = {RestController.class, Controller.class})
@ResponseBody
public class ExceptionConfiguration extends DefaultGlobalExceptionHandler {
}
           

公共基礎的配置類:

import com.itheima.pinda.common.config.BaseConfig;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AuthorityWebConfiguration extends BaseConfig {
}
           

資料庫相關的配置類:

import cn.hutool.core.util.ArrayUtil;
import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder;
import com.baomidou.mybatisplus.autoconfigure.ConfigurationCustomizer;
import com.baomidou.mybatisplus.autoconfigure.MybatisPlusProperties;
import com.baomidou.mybatisplus.autoconfigure.MybatisPlusPropertiesCustomizer;
import com.itheima.pinda.database.datasource.BaseDatabaseConfiguration;
import com.itheima.pinda.database.properties.DatabaseProperties;
import com.p6spy.engine.spy.P6DataSource;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.mapping.DatabaseIdProvider;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.scripting.LanguageDriver;
import org.apache.ibatis.session.ExecutorType;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.type.TypeHandler;
import org.mybatis.spring.SqlSessionTemplate;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.aop.Advisor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ResourceLoader;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import javax.sql.DataSource;
import java.util.List;

@Configuration
@Slf4j
//mybatis相關mapper掃描,掃描com.itheima.pinda包,annotationClass注解辨別
@MapperScan(
        basePackages = {"com.itheima.pinda",},
        annotationClass = Repository.class,
        //sqlSessionFactory
        sqlSessionFactoryRef = AuthorityDatabaseAutoConfiguration.DATABASE_PREFIX + "SqlSessionFactory")
//開啟配置屬性
@EnableConfigurationProperties({MybatisPlusProperties.class, DatabaseProperties.class})
public class AuthorityDatabaseAutoConfiguration extends BaseDatabaseConfiguration {
    /**
     * 每個資料源配置不同即可
     */
    final static String DATABASE_PREFIX = "master";

    //構造方法
    public AuthorityDatabaseAutoConfiguration(MybatisPlusProperties properties,
                                              DatabaseProperties databaseProperties,
                                              ObjectProvider<Interceptor[]> interceptorsProvider,
                                              ObjectProvider<TypeHandler[]> typeHandlersProvider,
                                              ObjectProvider<LanguageDriver[]> languageDriversProvider,
                                              ResourceLoader resourceLoader,
                                              ObjectProvider<DatabaseIdProvider> databaseIdProvider,
                                              ObjectProvider<List<ConfigurationCustomizer>> configurationCustomizersProvider,
                                              ObjectProvider<List<MybatisPlusPropertiesCustomizer>> mybatisPlusPropertiesCustomizerProvider,
                                              ApplicationContext applicationContext) {
        super(properties, databaseProperties, interceptorsProvider, typeHandlersProvider,
                languageDriversProvider, resourceLoader, databaseIdProvider,
                configurationCustomizersProvider, mybatisPlusPropertiesCustomizerProvider, applicationContext);
    }

    //模闆對象,spring封裝的,需要注入SqlSessionFactory
    @Bean(DATABASE_PREFIX + "SqlSessionTemplate")
    public SqlSessionTemplate getSqlSessionTemplate(@Qualifier(DATABASE_PREFIX + "SqlSessionFactory") SqlSessionFactory sqlSessionFactory) {
        ExecutorType executorType = this.properties.getExecutorType();
        if (executorType != null) {
            return new SqlSessionTemplate(sqlSessionFactory, executorType);
        } else {
            return new SqlSessionTemplate(sqlSessionFactory);
        }
    }

    /**
     * 資料源資訊
     * 開發環境建立出P6DataSource資料源,别的環境建立出DruidDataSource
     *
     * @return
     */
    @Bean(name = DATABASE_PREFIX + "DruidDataSource")
    @ConfigurationProperties(prefix = "spring.datasource.druid")
    public DataSource druidDataSource() {
        return DruidDataSourceBuilder.create().build();
    }

    @Bean(name = DATABASE_PREFIX + "DataSource")
    public DataSource dataSource(@Qualifier(DATABASE_PREFIX + "DruidDataSource") DataSource dataSource) {
        //判斷是否是dev環境
        if (ArrayUtil.contains(DEV_PROFILES, this.profiles)) {
            return new P6DataSource(dataSource);
        } else {
            return dataSource;
        }
    }

    /**
     * mybatis Sql Session 工廠
     *
     * @return
     * @throws Exception
     */
    @Bean(DATABASE_PREFIX + "SqlSessionFactory")
    public SqlSessionFactory getSqlSessionFactory(@Qualifier(DATABASE_PREFIX + "DataSource") DataSource dataSource) throws Exception {
        return super.sqlSessionFactory(dataSource);
    }

    /**
     * 資料源事務管理器
     *
     * @return
     */
    @Bean(name = DATABASE_PREFIX + "TransactionManager")
    public DataSourceTransactionManager dsTransactionManager(@Qualifier(DATABASE_PREFIX + "DataSource") DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }

    /**
     * 事務攔截器
     *
     * @param transactionManager
     * @return
     */
    @Bean(DATABASE_PREFIX + "TransactionInterceptor")
    public TransactionInterceptor transactionInterceptor(@Qualifier(DATABASE_PREFIX + "TransactionManager") PlatformTransactionManager transactionManager) {
        return new TransactionInterceptor(transactionManager, this.transactionAttributeSource());
    }

    /**
     * 事務 Advisor
     *
     * @param transactionManager
     * @return
     */
    @Bean(DATABASE_PREFIX + "Advisor")
    public Advisor getAdvisor(@Qualifier(DATABASE_PREFIX + "TransactionManager") PlatformTransactionManager transactionManager, @Qualifier(DATABASE_PREFIX + "TransactionInterceptor") TransactionInterceptor ti) {
        return super.txAdviceAdvisor(ti);
    }

}
           

mybatis架構相關的配置類:

import com.itheima.pinda.database.datasource.BaseMybatisConfiguration;
import com.itheima.pinda.database.properties.DatabaseProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
/**
 * Mybatis相關配置
 */
@Configuration
@Slf4j
public class AuthorityMybatisAutoConfiguration extends BaseMybatisConfiguration {
    public AuthorityMybatisAutoConfiguration(DatabaseProperties databaseProperties) {
        super(databaseProperties);
    }
}
           

啟動類

import com.itheima.pinda.auth.server.EnableAuthServer;
import com.itheima.pinda.user.annotation.EnableLoginArgResolver;
import com.itheima.pinda.validator.config.EnableFormValidator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.core.env.Environment;
import java.net.InetAddress;
import java.net.UnknownHostException;

@SpringBootApplication
@EnableDiscoveryClient //開啟nacos服務發現功能
@EnableAuthServer //jwt utils工具類,生成解析jwt
@EnableFeignClients(value = {  //開啟Feign用戶端進行接口調用
        "com.itheima.pinda",
})
@Slf4j //日志
@EnableAspectJAutoProxy(proxyTargetClass = true, exposeProxy = true) //自動代理,AOP
@EnableLoginArgResolver  //參數解析器,目前登入user自動注入
@EnableFormValidator //hibernate-validator表單校驗
public class AuthorityApplication {
    public static void main(String[] args) throws UnknownHostException {
        //ApplicationContext對象
        ConfigurableApplicationContext application =
                SpringApplication.run(AuthorityApplication.class, args);
        Environment env = application.getEnvironment();
        //日志輸出
        log.info("應用 '{}' 運作成功!  Swagger文檔: http://{}:{}/doc.html",
                //擷取配置檔案中某個配置,在本地配置檔案中有配置
                env.getProperty("spring.application.name"),
                //擷取ip位址
                InetAddress.getLocalHost().getHostAddress(),
                //擷取端口号,在nacos上配置了
                env.getProperty("server.port"));
    }
}
           

開發驗證碼接口

1,建立LoginController并提供生成驗證碼的方法

package com.itheima.pinda.authority.controller.auth;
import com.itheima.pinda.authority.biz.service.auth.ValidateCodeService;
import com.itheima.pinda.base.BaseController;
import com.itheima.pinda.base.R;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
 * 登入
 */
@RestController
@RequestMapping("/anno")
@Api(value = "UserAuthController", tags = "登入")//swagger文檔接口
@Slf4j
public class LoginController extends BaseController {
    @Autowired
    private ValidateCodeService validateCodeService;

    @ApiOperation(value = "驗證碼", notes = "驗證碼")
    @GetMapping(value = "/captcha", produces = "image/png")
    public void captcha(@RequestParam(value = "key") String key, 
                        HttpServletResponse response) throws IOException {
        this.validateCodeService.create(key, response);
    }
}
           

2,建立ValidateCodeService接口

import java.io.IOException;
import javax.servlet.http.HttpServletResponse;
/**
 * 驗證碼
 */
public interface ValidateCodeService {
    /**
     * 生成驗證碼
     */
    void create(String key, HttpServletResponse response) throws IOException;
}
           

3,建立ValidateCodeServiceImpl

import java.io.IOException;
import javax.servlet.http.HttpServletResponse;
import com.itheima.pinda.authority.biz.service.auth.ValidateCodeService;
import com.itheima.pinda.common.constant.CacheKey;
import com.itheima.pinda.exception.BizException;
import com.wf.captcha.ArithmeticCaptcha;
import net.oschina.j2cache.CacheChannel;
import net.oschina.j2cache.CacheObject;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
/**
 * 驗證碼服務
 */
@Service
public class ValidateCodeServiceImpl implements ValidateCodeService {
    @Autowired
    private CacheChannel cache;

    @Override
    public void create(String key, 
                       HttpServletResponse response) throws IOException {
        if (StringUtils.isBlank(key)) {
            throw BizException.validFail("驗證碼key不能為空");
        }

        response.setContentType(MediaType.IMAGE_PNG_VALUE);
        response.setHeader(HttpHeaders.PRAGMA, "No-cache");
        response.setHeader(HttpHeaders.CACHE_CONTROL, "No-cache");
        response.setDateHeader(HttpHeaders.EXPIRES, 0L);

        Captcha captcha = new ArithmeticCaptcha(115, 42);
        captcha.setCharType(2);

        cache.set(CacheKey.CAPTCHA, key, StringUtils.lowerCase(captcha.text()));
        captcha.out(response.getOutputStream());
    }
}
           

驗證碼接口開發完成後可以啟動服務,通過接口文檔進行測試:

通用權限系統

可以看到已經将驗證碼緩存到redis:

通用權限系統

開發認證接口

1,在LoginController中建立login方法

@Autowired
private AuthManager authManager;//認證管理器對象

/**
 * 登入認證
*/
@ApiOperation(value = "登入", notes = "登入")
@PostMapping(value = "/login")
public R<LoginDTO> login(@Validated @RequestBody LoginParamDTO login) 
    throws BizException {
    log.info("account={}", login.getAccount());
    if (this.validateCodeService.check(login.getKey(), login.getCode())) {
        return this.authManager.login(login.getAccount(), login.getPassword());
    }
    return this.success(null);
}
           

2,在ValidateCodeService接口中擴充check方法完成校驗驗證碼

/**
* 校驗驗證碼
* @param key   前端上送 key
* @param value 前端上送待校驗值
*/
boolean check(String key, String value);
           

3,在ValidateCodeServiceImpl實作類中實作check方法

//校驗驗證碼
@Override
public boolean check(String key, String value) {
    if (StringUtils.isBlank(value)) {
        throw BizException.validFail("請輸入驗證碼");
    }
    //根據key從緩存中擷取驗證碼
    CacheObject cacheObject = cache.get(CacheKey.CAPTCHA, key);
    if (cacheObject.getValue() == null) {
        throw BizException.validFail("驗證碼已過期");
    }
    //比對驗證碼
    if (!StringUtils.equalsIgnoreCase(value, 
                                      String.valueOf(cacheObject.getValue()))) {
        throw BizException.validFail("驗證碼不正确");
    }
    //驗證通過,立即從緩存中删除驗證碼
    cache.evict(CacheKey.CAPTCHA, key);
    return true;
}
           

4,建立AuthManager認證管理器類,提供使用者名密碼認證功能

import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.itheima.pinda.auth.server.utils.JwtTokenServerUtils;
import com.itheima.pinda.auth.utils.JwtUserInfo;
import com.itheima.pinda.auth.utils.Token;
import com.itheima.pinda.authority.biz.service.auth.ResourceService;
import com.itheima.pinda.authority.dto.auth.LoginDTO;
import com.itheima.pinda.authority.dto.auth.ResourceQueryDTO;
import com.itheima.pinda.authority.dto.auth.UserDTO;
import com.itheima.pinda.authority.entity.auth.Resource;
import com.itheima.pinda.authority.entity.auth.User;
import com.itheima.pinda.base.R;
import com.itheima.pinda.dozer.DozerUtils;
import com.itheima.pinda.exception.BizException;
import com.itheima.pinda.exception.code.ExceptionCode;
import com.itheima.pinda.utils.BizAssert;
import com.itheima.pinda.utils.NumberHelper;
import com.itheima.pinda.authority.biz.service.auth.UserService;
import com.itheima.pinda.utils.TimeUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
 *認證管理器
 */
@Service
@Slf4j
public class AuthManager {
    @Autowired
    private JwtTokenServerUtils jwtTokenServerUtils;
    @Autowired
    private UserService userService;
    @Autowired
    private ResourceService resourceService;
    @Autowired
    private DozerUtils dozer; //類型轉換
    /**
     * 賬号登入
     * @param account
     * @param password
     */
    public R<LoginDTO> login(String account, String password) {
        // 登入驗證
        R<User> result = checkUser(account, password);
        if (result.getIsError()) {
            return R.fail(result.getCode(), result.getMsg());
        }
        User user = result.getData();

        // 生成jwt token
        Token token = this.generateUserToken(user);

        List<Resource> resourceList =this.resourceService.
                findVisibleResource(ResourceQueryDTO.builder().
                        userId(user.getId()).build());
        List<String> permissionsList = null;
        if(resourceList != null && resourceList.size() > 0){
            //将使用者對應的權限(給前端使用的)
            //Resource中code
            permissionsList = resourceList.stream().
                    map(Resource::getCode).
                    collect(Collectors.toList());

            //将使用者對應的權限(給後端網關使用的)進行緩存
            //Resource中method+url
            List<String> visibleResource = resourceList.stream().map(
                    (resource -> {
                        return resource.getMethod()+resource.getUrl();
                    })
            ).collect(Collectors.toList());
            cacheChannel.set(CacheKey.USER_RESOURCE,user.getId().toString(),visibleResource);
        }
        //封裝資料
        LoginDTO loginDTO = LoginDTO.builder()
                .user(this.dozer.map(user, UserDTO.class))
                .token(token)
                .permissionsList(permissionsList)
                .build();
        return R.success(loginDTO);
    }

    //生成jwt token
    private Token generateUserToken(User user) {
        JwtUserInfo userInfo = new JwtUserInfo(user.getId(),
                user.getAccount(),
                user.getName(),
                user.getOrgId(),
                user.getStationId());

        Token token = this.jwtTokenServerUtils.generateUserToken(userInfo, null);
        log.info("token={}", token.getToken());
        return token;
    }

    // 登入驗證
    private R<User> checkUser(String account, String password) {
        User user = this.userService.getOne(Wrappers.<User>lambdaQuery()
                .eq(User::getAccount, account));

        // 密碼加密
        String passwordMd5 = DigestUtils.md5Hex(password);

        if (user == null || !user.getPassword().equals(passwordMd5)) {
            return R.fail(ExceptionCode.JWT_USER_INVALID);
        }

        return R.success(user);
    }
}
           

5,建立UserService接口、UserServiceImpl實作類、UserMapper接口

import com.baomidou.mybatisplus.extension.service.IService;
/**
 * 業務接口,繼承IService指定實體類
 */
public interface UserService extends IService<User> {
}
           
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itheima.pinda.authority.biz.dao.auth.UserMapper;
import com.itheima.pinda.authority.biz.service.auth.UserService;
import com.itheima.pinda.authority.entity.auth.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
 * 業務實作類
 */
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> 
    						implements UserService {
}
           
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.itheima.pinda.authority.entity.auth.User;
import org.springframework.stereotype.Repository;
/**
 * Mapper 接口
 */
@Repository
public interface UserMapper extends BaseMapper<User> {
}
           

6,建立ResourceService接口、ResourceServiceImpl實作類、ResourceMapper接口、ResourceMapper.xml

import java.util.List;
import com.baomidou.mybatisplus.extension.service.IService;
import com.itheima.pinda.authority.dto.auth.ResourceQueryDTO;
import com.itheima.pinda.authority.entity.auth.Resource;
/**
 * 業務接口
 */
public interface ResourceService extends IService<Resource> {
    /**
     * 查詢 使用者擁有的資源權限
     */
    List<Resource> findVisibleResource(ResourceQueryDTO resource);
}
           
import java.util.List;
import java.util.stream.Collectors;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itheima.pinda.authority.biz.service.auth.ResourceService;
import com.itheima.pinda.authority.dto.auth.ResourceQueryDTO;
import com.itheima.pinda.authority.entity.auth.Resource;
import com.itheima.pinda.common.constant.CacheKey;
import com.itheima.pinda.exception.BizException;
import com.itheima.pinda.utils.StrHelper;
import com.itheima.pinda.authority.biz.dao.auth.ResourceMapper;
import lombok.extern.slf4j.Slf4j;
import net.oschina.j2cache.CacheChannel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
 * 業務實作類
 * 資源
 */
@Slf4j
@Service
public class ResourceServiceImpl extends ServiceImpl<ResourceMapper, Resource> implements ResourceService {
    @Autowired
    private CacheChannel cache;

    /**
     * 查詢使用者的可用資源權限
     */
    @Override
    public List<Resource> findVisibleResource(ResourceQueryDTO resourceQueryDTO) {
        //查詢目前使用者可通路的資源
        List<Resource> visibleResource = 
            baseMapper.findVisibleResource(resourceQueryDTO);
        if(visibleResource != null && visibleResource.size() > 0){
            List<String> userResource = visibleResource.
                stream().
                map((Resource r) -> {
                return r.getMethod() + r.getUrl();
            }).collect(Collectors.toList());
            //将目前使用者可通路的資源載入緩存,形式為:GET/user/page
            cache.set(CacheKey.USER_RESOURCE,
                      resourceQueryDTO.getUserId().toString(),
                      userResource);
        }
        return visibleResource;
    }
}
           
import java.util.List;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.itheima.pinda.authority.dto.auth.ResourceQueryDTO;
import com.itheima.pinda.authority.entity.auth.Resource;
import org.springframework.stereotype.Repository;
/**
 * Mapper 接口
 */
@Repository
public interface ResourceMapper extends BaseMapper<Resource> {
    /**
     * 查詢使用者擁有的資源權限
     */
    List<Resource> findVisibleResource(ResourceQueryDTO resource);
}
           

在resources目錄下建立mapper_authority目錄,在此目錄中建立ResourceMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.itheima.pinda.authority.biz.dao.auth.ResourceMapper">
    <!-- 通用查詢映射結果 -->
    <resultMap id="BaseResultMap" 
               type="com.itheima.pinda.authority.entity.auth.Resource">
        <id column="id" jdbcType="BIGINT" property="id"/>
        <result column="create_user" jdbcType="BIGINT" property="createUser"/>
        <result column="create_time" jdbcType="TIMESTAMP" property="createTime"/>
        <result column="update_user" jdbcType="BIGINT" property="updateUser"/>
        <result column="update_time" jdbcType="TIMESTAMP" property="updateTime"/>
        <result column="code" jdbcType="VARCHAR" property="code"/>
        <result column="name" jdbcType="VARCHAR" property="name"/>
        <result column="menu_id" jdbcType="BIGINT" property="menuId"/>
        <result column="describe_" jdbcType="VARCHAR" property="describe"/>
        <result column="method" jdbcType="VARCHAR" property="method"/>
        <result column="url" jdbcType="VARCHAR" property="url"/>
    </resultMap>

    <!-- 通用查詢結果列 -->
    <sql id="Base_Column_List">
        id, create_user, create_time, update_user, update_time, 
        code, name, menu_id, describe_,method,url
    </sql>
    
    <select id="findVisibleResource"  resultMap="BaseResultMap">
        SELECT
        <include refid="Base_Column_List"/>
        from pd_auth_resource where 1=1
        and id in (
        SELECT authority_id FROM pd_auth_role_authority ra INNER JOIN pd_auth_user_role ur on ra.role_id = ur.role_id
        INNER JOIN pd_auth_role r on r.id = ra.role_id
        where ur.user_id = #{userId, jdbcType=BIGINT} and r.`status` = true
        and ra.authority_type = 'RESOURCE'
        )
    </select>
</mapper>
           

7,認證接口開發完成後可以使用接口文檔進行測試:

通用權限系統
通用權限系統

開發記錄檔功能

目前的權限服務已經依賴了pd-tools-log日志子產品,此子產品中已經定義好了SysLogAspect切面類用于攔截Controller中添加@SysLog注解的方法,在切面類中通過前置通知和後置通知方法收集記錄檔相關資訊并釋出SysLogEvent日志事件,通過定義SysLogListener監聽器來監聽日志事件。

在權限服務中隻需要定義配置類來建立SysLogListener,同時将SysLogListener所需的Consumer參數傳遞進行即可。

具體開發步驟:

1,建立OptLogService接口

import com.baomidou.mybatisplus.extension.service.IService;
import com.itheima.pinda.authority.entity.common.OptLog;
import com.itheima.pinda.log.entity.OptLogDTO;
/**
 * 業務接口
 * 記錄檔
 */
public interface OptLogService extends IService<OptLog> {
    /**
     * 儲存日志
     */
    boolean save(OptLogDTO entity);
}
           

2,建立OptLogServiceImpl實作類

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itheima.pinda.authority.biz.dao.common.OptLogMapper;
import com.itheima.pinda.authority.entity.common.OptLog;
import com.itheima.pinda.dozer.DozerUtils;
import com.itheima.pinda.log.entity.OptLogDTO;
import com.itheima.pinda.authority.biz.service.common.OptLogService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
 * 業務實作類
 * 記錄檔
 */
@Slf4j
@Service
public class OptLogServiceImpl extends ServiceImpl<OptLogMapper, OptLog> 
    							implements OptLogService {
    @Autowired
    DozerUtils dozer;//類型轉換

    @Override
    public boolean save(OptLogDTO entity) {
        return super.save(dozer.map(entity, OptLog.class));
    }
}
           

3,建立OptLogMapper接口

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.itheima.pinda.authority.entity.common.OptLog;
import org.springframework.stereotype.Repository;
/**
 * Mapper 接口
 * 系統日志
 */
@Repository
public interface OptLogMapper extends BaseMapper<OptLog> {
}
           

4,建立SysLogConfiguration配置類

import com.itheima.pinda.authority.biz.service.common.OptLogService;
import com.itheima.pinda.log.entity.OptLogDTO;
import com.itheima.pinda.log.event.SysLogListener;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import java.util.function.Consumer;
/**
 * 日志自動配置
 */
@EnableAsync
@Configuration
public class SysLogConfiguration {
    //日志記錄監聽器
    @Bean
    public SysLogListener sysLogListener(OptLogService optLogService) {
        Consumer<OptLogDTO> consumer = (optLog) -> optLogService.save(optLog);
        return new SysLogListener(consumer);
    }
}
           

5,在已經開發的Controller的方法上加入@SysLog注解,然後通過接口文檔通路,可以看到記錄檔已經插入到pd_common_opt_log日志表中了。

網關服務開發

pd-gateway作為通用權限系統的網關服務,前端的http請求首先需要經過網關服務處理,再通過網關服務的路由功能轉發到權限服務或者其他微服務進行業務處理。我們可以在網關服務進行統一的jwt令牌解析、鑒權相關操作。

配置檔案

bootstrap.yml

由于我們目前使用的是Nacos作為整個項目的配置中心,是以Spring Boot的大部配置設定置檔案都在Nacos中進行統一配置,我們的項目中隻需要按照Spring Boot的要求在resources目錄下提供bootstrap.yml配置檔案即可,檔案内容如下:

pinda:
  # docker部署時,需要指定, 表示運作該服務的主控端IP
  local-ip: ${LOCAL_IP:${spring.cloud.client.ip-address}}   
  nacos:
    ip: ${NACOS_IP:@pom.nacos.ip@}
    port: ${NACOS_PORT:@pom.nacos.port@}
    namespace: ${NACOS_ID:@pom.nacos.namespace@}

spring:
  main:
    allow-bean-definition-overriding: true
  application:
    name: @project.artifactId@ #pd-gateway
  profiles:
    active: @pom.profile.name@ #dev
  cloud:
    nacos:
      config:
        server-addr: ${pinda.nacos.ip}:${pinda.nacos.port}
        file-extension: yml
        namespace: ${pinda.nacos.namespace}
        shared-dataids: common.yml,redis.yml,mysql.yml
        refreshable-dataids: common.yml
        enabled: true
      discovery:
        server-addr: ${pinda.nacos.ip}:${pinda.nacos.port}
        namespace: ${pinda.nacos.namespace}
        metadata:
          management.context-path: ${server.servlet.context-path:}${spring.mvc.servlet.path:}${management.endpoints.web.base-path:}
          #http://localhost:8760/api/gate/actuator
           

logback-spring.xml

由于pd-gateway已經添加了pd-tools-log子產品的依賴,是以可以在項目中使用logback記錄日志資訊。在resources目錄下提供logback-spring.xml配置檔案,Spring Boot預設就可以加載到,檔案内容如下:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <include resource="com/itheima/pinda/log/logback/pinda-defaults.xml"/>

    <springProfile name="test,docker,prod">
        <logger name="com.itheima.pinda.zuul" additivity="true" level="INFO">
            <appender-ref ref="ASYNC_CONTROLLER_APPENDER"/>
        </logger>
    </springProfile>

    <springProfile name="dev">
        <logger name="com.itheima.pinda.zuul" additivity="true" level="INFO">
            <appender-ref ref="CONTROLLER_APPENDER"/>
        </logger>
    </springProfile>
</configuration>
           

j2cache配置檔案

在目前pd-gateway項目中會使用到j2cache來操作緩存,在Nacos配置中心的redis.yml中已經配置了j2cache的相關配置:

j2cache:
  #  config-location: /j2cache.properties
  open-spring-cache: true
  cache-clean-mode: passive
  allow-null-values: true
  redis-client: lettuce
  l2-cache-open: true
  # l2-cache-open: false     # 關閉二級緩存
  broadcast: net.oschina.j2cache.cache.support.redis.SpringRedisPubSubPolicy
  #  broadcast: jgroups       # 關閉二級緩存
  L1:
    provider_class: caffeine
  L2:
    provider_class: net.oschina.j2cache.cache.support.redis.SpringRedisProvider
    config_section: lettuce
  sync_ttl_to_redis: true
  default_cache_null_object: false
  serialization: fst
caffeine:
  properties: /j2cache/caffeine.properties   # 這個配置檔案需要放在項目中
lettuce:
  mode: single
  namespace:
  storage: generic
  channel: j2cache
  scheme: redis
  hosts: ${pinda.redis.ip}:${pinda.redis.port}
  password: ${pinda.redis.password}
  database: ${pinda.redis.database}
  sentinelMasterId:
  maxTotal: 100
  maxIdle: 10
  minIdle: 10
  timeout: 10000
           

通過上面的配置可以看到,還需要在項目中提供/j2cache/caffeine.properties,檔案内容如下:

#########################################
# Caffeine configuration
# \u6682\u65F6\u6CA1\u7528
# [name] = size, xxxx[s|m|h|d]
#########################################
default=2000, 2h
resource=2000, 1h
           

密鑰檔案

JWT簽名算法中,一般有兩個選擇:HS256和RS256。

HS256 (帶有 SHA-256 的 HMAC )是一種對稱加密算法, 雙方之間僅共享一個密鑰。由于使用相同的密鑰生成簽名和驗證簽名, 是以必須注意確定密鑰不被洩密。

RS256 (采用SHA-256 的 RSA 簽名) 是一種非對稱加密算法, 它使用公共/私鑰對: JWT的提供方采用私鑰生成簽名, JWT 的使用方擷取公鑰以驗證簽名。
           

本項目中使用RS256非對稱加密算法進行簽名,這就需要使用RSA生成一對公鑰和私鑰。在授課資料中已經提供了一對公鑰和私鑰,其中pub.key為公鑰,pri.key為私鑰。

前面我們已經提到,在目前網關服務中我們需要對用戶端請求中攜帶的jwt token進行解析,隻需要公鑰就可以。将授課資料中的pub.key檔案複制到pd-gateway項目的resources/client下。

啟動類

import com.itheima.pinda.auth.client.EnableAuthClient;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;
import org.springframework.cloud.openfeign.EnableFeignClients;

@SpringBootApplication
@EnableDiscoveryClient //nacos服務發現
@EnableFeignClients({"com.itheima.pinda"})
@EnableZuulProxy//開啟網關代理
@EnableAuthClient//開啟授權用戶端,開啟後就可以使用pd-tools-jwt提供的工具類進行jwt token解析了
public class ZuulServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(ZuulServerApplication.class,args);
    }
}
           

配置類

import com.itheima.pinda.common.config.BaseConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
/**
 * 解決跨域問題
 */
@Configuration
public class ZuulConfiguration extends BaseConfig {
    @Bean
    public CorsFilter corsFilter() {
        final UrlBasedCorsConfigurationSource source = 
            new UrlBasedCorsConfigurationSource();
        final org.springframework.web.cors.CorsConfiguration config = 
            new org.springframework.web.cors.CorsConfiguration();
        // 允許cookies跨域
        config.setAllowCredentials(true);
        // #允許向該伺服器送出請求的URI,*表示全部允許
        config.addAllowedOrigin("*");
        // #允許通路的頭資訊,*表示全部
        config.addAllowedHeader("*");
        // 預檢請求的緩存時間(秒),即在這個時間段裡,對于相同的跨域請求不會再預檢了
        config.setMaxAge(18000L);
        // 允許送出請求的方法,*表示全部允許
        config.addAllowedMethod("OPTIONS");
        config.addAllowedMethod("HEAD");
        // 允許Get的請求類型
        config.addAllowedMethod("GET");
        config.addAllowedMethod("PUT");
        config.addAllowedMethod("POST");
        config.addAllowedMethod("DELETE");
        config.addAllowedMethod("PATCH");
        source.registerCorsConfiguration("/**", config);
        return new CorsFilter(source);
    }
}
           

API接口和熔斷器

在網關服務中會通過Feign來調用權限服務擷取相關資訊,是以需要定義API接口和對應的熔斷器類

import com.itheima.pinda.authority.dto.auth.ResourceQueryDTO;
import com.itheima.pinda.authority.entity.auth.Resource;
import com.itheima.pinda.base.R;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import java.util.List;

@FeignClient(name = "${pinda.feign.authority-server:pd-auth-server}",
        //熔斷器
        fallback = ResourceApiFallback.class)
public interface ResourceApi {
    //擷取所有需要鑒權的資源
    @GetMapping("/resource/list")
    public R<List> list();

    //查詢目前登入使用者擁有的資源權限
    @GetMapping("/resource")
    public R<List<Resource>> visible(ResourceQueryDTO resource);
}
           
import com.itheima.pinda.authority.dto.auth.ResourceQueryDTO;
import com.itheima.pinda.authority.entity.auth.Resource;
import com.itheima.pinda.base.R;
import org.springframework.stereotype.Component;
import java.util.List;
/**
 * 資源API熔斷
 */
@Component
public class ResourceApiFallback implements ResourceApi {
    public R<List> list() {
        return null;
    }

    public R<List<Resource>> visible(ResourceQueryDTO resource) {
        return null;
    }
}
           

過濾器

在網關服務中我們需要通過過濾器來實作

jwt token解析

鑒權

相關處理。

BaseFilter

BaseFilter作為基礎過濾器,統一抽取一些公共屬性和方法。

import javax.servlet.http.HttpServletRequest;
import com.itheima.pinda.base.R;
import com.itheima.pinda.common.adapter.IgnoreTokenConfig;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.netflix.zuul.filters.RouteLocator;
/**
 * 基礎 網關過濾器
 */
@Slf4j
public abstract class BaseFilter extends ZuulFilter {
    @Value("${server.servlet.context-path}")
    protected String zuulPrefix;

    /**
     * 判斷目前請求uri是否需要忽略
     */
    protected boolean isIgnoreToken() {
        HttpServletRequest request = 
            RequestContext.getCurrentContext().getRequest();
        String uri = request.getRequestURI();
        uri = StrUtil.subSuf(uri, zuulPrefix.length());
        uri = StrUtil.subSuf(uri, uri.indexOf("/", 1));
        boolean ignoreToken = IgnoreTokenConfig.isIgnoreToken(uri);
        return ignoreToken;
    }

    /**
     * 網關抛異常
     * @param errMsg
     * @param errCode
     * @param httpStatusCode
     */
    protected void errorResponse(String errMsg, int errCode, int httpStatusCode) {
        R tokenError = R.fail(errCode, errMsg);
        RequestContext ctx = RequestContext.getCurrentContext();
        // 傳回錯誤碼
        ctx.setResponseStatusCode(httpStatusCode);
        ctx.addZuulResponseHeader(
            "Content-Type", "application/json;charset=UTF-8");
        if (ctx.getResponseBody() == null) {
            // 傳回錯誤内容
            ctx.setResponseBody(tokenError.toString());
            // 過濾該請求,不對其進行路由
            ctx.setSendZuulResponse(false);
        }
    }
}
           

TokenContextFilter

TokenContextFilter過濾器主要作用就是解析請求頭中的jwt token并将解析出的使用者資訊放入zuul的header中供後面的程式使用。

import javax.servlet.http.HttpServletRequest;
import com.itheima.pinda.auth.client.properties.AuthClientProperties;
import com.itheima.pinda.auth.client.utils.JwtTokenClientUtils;
import com.itheima.pinda.auth.utils.JwtUserInfo;
import com.itheima.pinda.base.R;
import com.itheima.pinda.context.BaseContextConstants;
import com.itheima.pinda.exception.BizException;
import com.itheima.pinda.utils.StrHelper;
import com.netflix.zuul.context.RequestContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.PRE_TYPE;
/**
 * 解析token中的使用者資訊,并将解析出的使用者資訊放入zuul的header中
 */
@Component
public class TokenContextFilter extends BaseFilter {
    @Autowired
    private AuthClientProperties authClientProperties;
    @Autowired
    private JwtTokenClientUtils jwtTokenClientUtils;

    @Override
    public String filterType() {
        // 前置過濾器
        return PRE_TYPE;
    }

    /**
     * filterOrder:通過int值來定義過濾器的執行順序,數字越大,優先級越低
     */
    @Override
    public int filterOrder() {
        /*
         一定要在
         org.springframework.cloud.netflix.zuul.filters.pre.PreDecorationFilter
         過濾器之後執行,因為這個過濾器做了路由,而我們需要這個路由資訊來鑒權
         這個過濾器會将我們鑒權需要的資訊放置在請求上下文中
         */
        return FilterConstants.PRE_DECORATION_FILTER_ORDER + 1;
    }

    /**
     * 傳回一個boolean類型來判斷該過濾器是否要執行
     */
    @Override
    public boolean shouldFilter() {
        return true;
    }

    //過濾邏輯
    @Override
    public Object run() {
        // 不進行攔截的位址
        if (isIgnoreToken()) {
            return null;
        }

        //上下文對象
        RequestContext ctx = RequestContext.getCurrentContext();
        HttpServletRequest request = ctx.getRequest();

        //擷取token, 解析,然後将資訊放入 header
        //1, 擷取token
        //從配置屬性類中擷取配置檔案路徑
        //authClientProperties.getUser().getHeaderName() = token
        String userToken =
                request.getHeader(authClientProperties.getUser().getHeaderName());

        //2, 解析token
        JwtUserInfo userInfo = null;

        try {
            userInfo = jwtTokenClientUtils.getUserInfo(userToken);
        } catch (BizException e) {  //BizException業務異常
            //父類的errorResponse方法
            errorResponse(e.getMessage(), e.getCode(), 200);
            return null;
        } catch (Exception e) {
            errorResponse("解析token出錯", R.FAIL_CODE, 200);
            return null;
        }

        //3, 将資訊放入header
        if (userInfo != null) {
            addHeader(ctx, BaseContextConstants.JWT_KEY_ACCOUNT,
                    userInfo.getAccount());
            addHeader(ctx, BaseContextConstants.JWT_KEY_USER_ID,
                    userInfo.getUserId());
            addHeader(ctx, BaseContextConstants.JWT_KEY_NAME,
                    userInfo.getName());
            addHeader(ctx, BaseContextConstants.JWT_KEY_ORG_ID,
                    userInfo.getOrgId());
            addHeader(ctx, BaseContextConstants.JWT_KEY_STATION_ID,
                    userInfo.getStationId());
        }
        return null;
    }

    //将指定資訊放入zuul的header中
    private void addHeader(RequestContext ctx, String name, Object value) {
        if (StringUtils.isEmpty(value)) {
            return;
        }
        ctx.addZuulRequestHeader(name, StrHelper.encode(value.toString()));
    }
}
           

AccessFilter

AccessFilter過濾器主要進行的是鑒權相關處理。具體的處理邏輯如下:

第1步:判斷目前請求uri是否需要忽略
第2步:擷取目前請求的請求方式和uri,拼接成GET/user/page這種形式,稱為權限辨別符
第3步:從緩存中擷取所有需要進行鑒權的資源(同樣是由資源表的method字段值+url字段值拼接成),如果沒有擷取到則通過Feign調用權限服務擷取并放入緩存中
第4步:判斷這些資源是否包含目前請求的權限辨別符,如果不包含目前請求的權限辨別符,則傳回未經授權錯誤提示
第5步:如果包含目前的權限辨別符,則從zuul header中取出使用者id,根據使用者id取出緩存中的使用者擁有的權限,如果沒有取到則通過Feign調用權限服務擷取并放入緩存,判斷使用者擁有的權限是否包含目前請求的權限辨別符
第6步:如果使用者擁有的權限包含目前請求的權限辨別符則說明目前使用者擁有權限,直接放行
第7步:如果使用者擁有的權限不包含目前請求的權限辨別符則說明目前使用者沒有權限,傳回未經授權錯誤提示
           
import cn.hutool.core.util.StrUtil;
import com.itheima.pinda.authority.dto.auth.ResourceQueryDTO;
import com.itheima.pinda.authority.entity.auth.Resource;
import com.itheima.pinda.base.R;
import com.itheima.pinda.common.constant.CacheKey;
import com.itheima.pinda.context.BaseContextConstants;
import com.itheima.pinda.exception.code.ExceptionCode;
import com.itheima.pinda.zuul.api.ResourceApi;
import com.netflix.zuul.context.RequestContext;
import lombok.extern.slf4j.Slf4j;
import net.oschina.j2cache.CacheChannel;
import net.oschina.j2cache.CacheObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.PRE_TYPE;
/**
 * 權限驗證過濾器
 */
@Component
@Slf4j
public class AccessFilter extends BaseFilter {
    @Autowired
    private CacheChannel cacheChannel;
    @Autowired
    private ResourceApi resourceApi;
    @Override
    public String filterType() {
        return PRE_TYPE;
    }

    @Override
    public int filterOrder() {
        return FilterConstants.PRE_DECORATION_FILTER_ORDER + 10;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    /**
     * 驗證目前使用者是否擁有某個URI的通路權限
     */
    @Override
    public Object run() {
        //第1步:判斷目前請求uri是否需要忽略
        // 不進行攔截的位址
        if (isIgnoreToken()) {
            return null;
        }

        //第2步:擷取目前請求的請求方式和uri,拼接成GET/user/page這種形式,稱為權限辨別符
        RequestContext requestContext = RequestContext.getCurrentContext();
        String requestURI = requestContext.getRequest().getRequestURI();
        requestURI = StrUtil.subSuf(requestURI, zuulPrefix.length());
        requestURI = StrUtil.subSuf(requestURI, requestURI.indexOf("/", 1));
        String method = requestContext.getRequest().getMethod();
        String permission = method + requestURI;

        //第3步:從緩存中擷取所有需要進行鑒權的資源(同樣是由資源表的method字段值+url字段值拼接成),如果沒有擷取到則通過Feign調用權限服務擷取并放入緩存中
        //從緩存中擷取所有需要進行鑒權的資源
        CacheObject resourceNeed2AuthObject =
                cacheChannel.get(CacheKey.RESOURCE,
                        CacheKey.RESOURCE_NEED_TO_CHECK);
        List<String> resourceNeed2Auth =
                (List<String>) resourceNeed2AuthObject.getValue();
        if(resourceNeed2Auth == null){
            //如果沒有擷取到則通過Feign調用權限服務擷取
            resourceNeed2Auth = resourceApi.list().getData();
            if(resourceNeed2Auth != null){
                //并放入緩存中
                cacheChannel.set(CacheKey.RESOURCE,
                        CacheKey.RESOURCE_NEED_TO_CHECK,
                        resourceNeed2Auth);
            }
        }
        //第4步:判斷這些資源是否包含目前請求的權限辨別符,如果不包含目前請求的權限辨別符,則傳回未經授權錯誤提示
        if(resourceNeed2Auth != null){
            long count = resourceNeed2Auth.stream().filter((String r) -> {
                return permission.startsWith(r);
            }).count();
            if(count == 0){
                //未知請求
                errorResponse(ExceptionCode.UNAUTHORIZED.getMsg(),
                        ExceptionCode.UNAUTHORIZED.getCode(), 200);
                return null;
            }
        }

        //第5步:如果包含目前的權限辨別符,則從zuul header中取出使用者id,根據使用者id取出緩存中的使用者擁有的權限,如果沒有取到則通過Feign調用權限服務擷取并放入緩存,判斷使用者擁有的權限是否包含目前請求的權限辨別符
        String userId = requestContext.getZuulRequestHeaders().
                get(BaseContextConstants.JWT_KEY_USER_ID);
        //根據使用者id取出緩存中的使用者擁有的權限
        CacheObject cacheObject = cacheChannel.get(CacheKey.USER_RESOURCE, userId);
        List<String> userResource = (List<String>) cacheObject.getValue();
        // 如果從緩存擷取不到目前使用者的資源權限,需要查詢資料庫擷取,然後再放入緩存
        if(userResource == null){
            ResourceQueryDTO resourceQueryDTO = new ResourceQueryDTO();
            resourceQueryDTO.setUserId(new Long(userId));
            //通過Feign調用服務,查詢目前使用者擁有的權限
            R<List<Resource>> result = resourceApi.visible(resourceQueryDTO);
            if(result.getData() != null){
                List<Resource> userResourceList = result.getData();
                userResource = userResourceList.stream().map((Resource r) -> {
                    return r.getMethod() + r.getUrl();
                }).collect(Collectors.toList());
                cacheChannel.set(CacheKey.USER_RESOURCE,userId,userResource);
            }
        }

        //第6步:如果使用者擁有的權限包含目前請求的權限辨別符則說明目前使用者擁有權限,直接放行
        long count = userResource.stream().filter((String r) -> {
            return permission.startsWith(r);
        }).count();

        if(count > 0){
            //有通路權限
            return null;
        }else{
            //第7步:如果使用者擁有的權限不包含目前請求的權限辨別符則說明目前使用者沒有權限,傳回未經授權錯誤提示
            log.warn("使用者{}沒有通路{}資源的權限",userId,method + requestURI);
            errorResponse(ExceptionCode.UNAUTHORIZED.getMsg(),
                    ExceptionCode.UNAUTHORIZED.getCode(), 200);
        }
        return null;
    }
}