Skip to content

分布式认证鉴权实战

示例项目地址: https://git.code.tencent.com/xinzhang0618/oa.git

参考文档:

使用JDK生成公钥: https://blog.csdn.net/mmingxiang/article/details/108611390

公钥的使用: https://blog.csdn.net/qq_15973399/article/details/106903103

实现思路

(使用oauth2+jwt+gateway+rsa)

做认证主要是登录, 访问刷新, token过期, 登出

  1. 登录, 前端组装oauth2的url访问gateway, 直接跳转auth服务获取token;
  2. 访问刷新, gateway添加后置过滤器, 当token快过期时, 重新请求auth服务获取token, 请求头添加token刷新的事件, 前端接收后刷新token
  3. token过期, jwt过期
  4. 登录, redis添加token的黑名单, 过期时间为token的过期时间, 每次访问校验token不能存在于黑名单

尴尬点:

  1. oauth2本用作第三方登录, 用在这里只为做一个token生成器, 若应用有多个前端(web, app等), 则更有应用场景一些; 且oauth2有诸多限制, 获取token以及刷新有固定的url, 导致appSecret暴露在url且刷新极为不便; 导致这套体系之下, token的刷新使用"重新获取"更便利些; 所以, 建议在一般的分布式认证中, 直接用jwt就够了
  2. 在gateway重新获取token依然不便, 不能将用户密码以及appSecret等暴露在jwt中, 因此重新获取token, 还是需要在gateway请求auth服务拿到用户以及客户端信息, 然后拼接url重新请求oauth2
  3. jwt是无状态的, 但是用户登陆必须要可以登出; 此处有两种方案, 一是利用短时效的token, 允许用户登出后token依然有效; 另一种就是借助redis给token加上过期的状态, 但每次访问就都要走redis校验了
  4. 此外oauth2的sso方案之前尝试过, 配置虽然简单, 但资源服务器会在每次请求时都访问鉴权服务器以校验token, 增加了延时, 不建议用
  5. 纯吐槽, oauth2的配置网上的版本颇多, 甚至不同依赖不同配置, 坑也非常非常多, 这部分我尽量写详细

配置解析

项目采用通用的结构

  • auth-认证鉴权服务
  • gateway-网关
  • web-资源服务

认证鉴权服务

xml
 <!--oauth2-->
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-oauth2</artifactId>
      <version>Greenwich.SR3</version>
    </dependency>
 <!--jwt-->
    <dependency>
      <groupId>com.auth0</groupId>
      <artifactId>java-jwt</artifactId>
      <version>3.10.3</version>
    </dependency>

AuthServerConfig, oauth2认证服务器配置

  1. @EnableResourceServer, @EnableAuthorizationServer俩注解都不能掉, 它是认证服务器的同事也是资源服务器, 这样才能保证携带token能访问auth服务(便于后续的token刷新/重新获取)
  2. auth服务存放.jks文件(gateway存放pubkey.txt), 至于秘钥库和公钥的生成, 以及配置, 详见文档顶部的参考文档
  3. 自定义客户端的获取(oauth2的客户端模式通用)--CustomClientDetailsService, 自定义用户信息的获取(oauth2的密码模式通用)--CustomUserDetailsService, JWT的使用--JWTTokenEnhancer
  4. AuthenticationManager这个bean一定要定义, 参见下方WebSecurityConfig中定义了
java
package top.xinzhang0618.oa.config;

import java.security.KeyPair;
import java.util.ArrayList;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.bootstrap.encrypt.KeyProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.common.exceptions.OAuth2Exception;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.error.DefaultWebResponseExceptionTranslator;
import org.springframework.security.oauth2.provider.error.WebResponseExceptionTranslator;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory;

import javax.annotation.Resource;

/**
 * oauth2认证服务器配置
 *
 * @author xinzhang
 * @date 2020/11/16 17:19
 */
@Configuration
@EnableAuthorizationServer
@EnableResourceServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {
    @Resource(name = "keyProp")
    private KeyProperties keyProperties;

    @Bean("keyProp")
    public KeyProperties keyProperties() {
        return new KeyProperties();
    }

    @Autowired
    private AuthenticationManager authenticationManager;
    @Autowired
    private JWTTokenEnhancer jwtTokenEnhancer;
    @Autowired
    private CustomClientDetailsService clientDetailsService;
    @Autowired
    private CustomUserDetailsService userDetailsService;

//    /**
//     * 辅助排查问题, 保留
//     * @return
//     */
//    @Bean
//    public WebResponseExceptionTranslator loggingExceptionTranslator() {
//        return new DefaultWebResponseExceptionTranslator() {
//            @Override
//            public ResponseEntity<OAuth2Exception> translate(Exception e) throws Exception {
//                e.printStackTrace();
//                ResponseEntity<OAuth2Exception> responseEntity = super.translate(e);
//                HttpHeaders headers = new HttpHeaders();
//                headers.setAll(responseEntity.getHeaders().toSingleValueMap());
//                OAuth2Exception excBody = responseEntity.getBody();
//                return new ResponseEntity<>(excBody, headers, responseEntity.getStatusCode());
//            }
//        };
//    }

    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        KeyPair keyPair = (new KeyStoreKeyFactory(this.keyProperties.getKeyStore().getLocation(),
                this.keyProperties.getKeyStore().getSecret().toCharArray())).getKeyPair(this.keyProperties.getKeyStore().getAlias(),
                this.keyProperties.getKeyStore().getPassword().toCharArray());
        converter.setKeyPair(keyPair);
        return converter;
    }

    @Bean
    public JwtTokenStore jwtTokenStore() {
        return new JwtTokenStore(accessTokenConverter());
    }


    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        List<TokenEnhancer> enhancers = new ArrayList<>(2);
        enhancers.add(jwtTokenEnhancer);
        enhancers.add(accessTokenConverter());
        tokenEnhancerChain.setTokenEnhancers(enhancers);
        endpoints.tokenStore(jwtTokenStore())
                .tokenEnhancer(tokenEnhancerChain)
                .authenticationManager(authenticationManager)
                .accessTokenConverter(accessTokenConverter())
                .userDetailsService(userDetailsService)
                .allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST);
//                .exceptionTranslator(loggingExceptionTranslator());

    }


    @Override
    public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
        oauthServer.tokenKeyAccess("permitAll()")
                .checkTokenAccess("permitAll()")
                .allowFormAuthenticationForClients();
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.withClientDetails(clientDetailsService);
    }
}

WebSecurityConfig, web安全配置

  1. PasswordEncoder这个bean一定要定义
  2. AuthenticationManager这个bean一定要定义
java
package top.xinzhang0618.oa.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.BeanIds;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

/**
 * @author xinzhang
 * @date 2020/11/16 18:13
 */
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/auth/oauth/**").permitAll()
                .and()
                .csrf().disable();
    }
}

上面俩爹已经搞定了, 剩余的配置基本就贴代码了, 比较简单, 不多说

CustomClientDetailsService, 注意客户端密码加密

java
package top.xinzhang0618.oa.config;

import java.util.Arrays;
import java.util.Collections;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.provider.ClientDetails;
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.ClientRegistrationException;
import org.springframework.security.oauth2.provider.client.BaseClientDetails;
import org.springframework.stereotype.Service;
import top.xinzhang0618.oa.domain.base.Client;
import top.xinzhang0618.oa.service.base.ClientService;

/**
 * @author xinzhang
 * @date 2020/11/16 18:05
 */
@Service
public class CustomClientDetailsService implements ClientDetailsService {
    @Autowired
    private ClientService clientService;

    @Override
    public ClientDetails loadClientByClientId(String appKey) throws ClientRegistrationException {
        Client client = clientService.getOne(new LambdaQueryWrapper<Client>().eq(Client::getAppKey, appKey));
        BaseClientDetails clientDetails = new BaseClientDetails();
        clientDetails.setClientId(appKey);
        clientDetails.setClientSecret( new BCryptPasswordEncoder().encode(client.getAppSecret()));
        clientDetails.setAuthorizedGrantTypes(Arrays.asList(client.getGrantType().split(",")));
        clientDetails.setScope(Collections.singletonList("all"));
        clientDetails.setAccessTokenValiditySeconds(client.getTokenValidityHours() * 60 * 60);
        clientDetails.setRefreshTokenValiditySeconds(client.getRefreshTokenValidityHours() * 60 * 60);
        return clientDetails;
    }
}

客户端表结构设计

java
CREATE TABLE `oa_client` (
  `client_id` bigint(20) unsigned NOT NULL COMMENT '客户端id',
  `created_time` datetime NOT NULL COMMENT '创建时间',
  `modified_time` datetime NOT NULL COMMENT '更新时间',
  `tenant_id` bigint(20) unsigned NOT NULL COMMENT '租户id',
  `is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否删除',
  `app_key` varchar(50) NOT NULL COMMENT '应用key',
  `app_secret` varchar(100) NOT NULL COMMENT '应用密码',
  `grant_type` varchar(200) NOT NULL COMMENT '授权类型',
  `token_validity_hours` int(11) NOT NULL COMMENT 'token有效期',
  `refresh_token_validity_hours` int(11) NOT NULL COMMENT 'refreshToken有效期',
  PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户端';

CustomUserDetailsService, 注意用户名密码加密, 以及此处权限的处理

java
package top.xinzhang0618.oa.config;

import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import top.xinzhang0618.oa.Assert;
import top.xinzhang0618.oa.domain.base.User;
import top.xinzhang0618.oa.service.base.PrivilegeService;
import top.xinzhang0618.oa.service.base.UserService;

import javax.annotation.Resource;

/**
 * @author xinzhang
 * @date 2020/11/16 17:53
 */
@Service
public class CustomUserDetailsService implements UserDetailsService {
    @Autowired
    private UserService userService;
    @Autowired
    private PrivilegeService privilegeService;

    @Override
    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
        User user = userService.getOne(new LambdaQueryWrapper<User>()
                        .eq(User::getUserName, userName)
                        .eq(User::isEnable, true));
        if (Assert.isNull(user)) {
            throw new UsernameNotFoundException("用户不存在");
        }
//        String encode = new BCryptPasswordEncoder().encode(user.getPassword());
        user.setPassword( new BCryptPasswordEncoder().encode(user.getPassword()));
        List<Long> privileges = privilegeService.listUserPrivileges(user.getUserId());
        Set<SimpleGrantedAuthority> authorities =
                privileges.stream().map(p -> new SimpleGrantedAuthority(String.valueOf(privileges))).collect(Collectors.toSet());
     return new UserBO(user,authorities);
    }
}

UserBO

java
package top.xinzhang0618.oa.config;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import top.xinzhang0618.oa.domain.base.User;

import java.util.Collection;
import java.util.Set;

/**
 * @author xinzhang
 * @date 2020/11/17 19:28
 */
public class UserBO implements UserDetails {
    public UserBO(User user, Collection<? extends GrantedAuthority> authorities) {
        this.userId = user.getUserId();
        this.userName = user.getUserName();
        this.password = user.getPassword();
    }

    private Long userId;
    private String userName;
    private String password;
    private String tenantId;
    private Collection<? extends GrantedAuthority> authorities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }

    @Override
    public String getPassword() {
        return this.password;
    }

    @Override
    public String getUsername() {
        return this.userName;
    }

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

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

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

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

    public Long getUserId() {
        return userId;
    }

    public void setUserId(Long userId) {
        this.userId = userId;
    }

    public String getUserName() {
        return userName;
    }

    public void setUserName(String userName) {
        this.userName = userName;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public void setAuthorities(Collection<? extends GrantedAuthority> authorities) {
        this.authorities = authorities;
    }

    public String getTenantId() {
        return tenantId;
    }

    public void setTenantId(String tenantId) {
        this.tenantId = tenantId;
    }
}

其余

AuthController

java
package top.xinzhang0618.oa;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.web.bind.annotation.*;
import top.xinzhang0618.oa.bo.auth.AuthInfoBO;
import top.xinzhang0618.oa.constant.AuthConstant;
import top.xinzhang0618.oa.domain.base.Client;
import top.xinzhang0618.oa.domain.base.User;
import top.xinzhang0618.oa.service.base.ClientService;
import top.xinzhang0618.oa.service.base.UserService;

import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;

/**
 * @author xinzhang
 * @date 2020/11/19 14:19
 */
@RestController
public class AuthController {
    @Autowired
    private UserService userService;
    @Autowired
    private ClientService clientService;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private JwtAccessTokenConverter accessTokenConverter;

    @GetMapping("/info/{appKey}/{userId}")
    public AuthInfoBO getAuthInfo(@PathVariable("appKey") String appKey, @PathVariable("userId") Long userId) {
        User user = userService.getById(userId);
        Client client = clientService.getOne(new LambdaQueryWrapper<Client>().eq(Client::getAppKey, appKey));
        AuthInfoBO authInfoBO = new AuthInfoBO();
        authInfoBO.setUserName(user.getUserName());
        authInfoBO.setPassword(user.getPassword());
        authInfoBO.setAppKey(client.getAppKey());
        authInfoBO.setAppSecret(client.getAppSecret());
        return authInfoBO;
    }

    @PostMapping("/exit")
    public void logout(@RequestHeader("Authorization") String authorization) {
        String token = authorization.replace(AuthConstant.TOKEN_PREFIX, "");
        OAuth2AccessToken oAuth2AccessToken = new JwtTokenStore(accessTokenConverter).readAccessToken(token);
        String key = AuthConstant.TOKEN_BLACK_LIST_PREFIX + authorization;
        redisTemplate.opsForValue().set(key, LocalDateTime.now().toString());
        redisTemplate.expire(key, oAuth2AccessToken.getExpiresIn(), TimeUnit.SECONDS);
    }
}

.jks的配置

java
encrypt:
  key-store:
    location: classpath:config/oa.jks
    secret: xxx
    alias: xx
    password: xxx

获取token的路径示例

注意其中客户端以及密码均采用密文

java
http://localhost:40002/auth/oauth/token?grant_type=password&client_id=test&client_secret=$2a$10$ATXY1ablVzcML1aNFkZrbuD3oVvddBw62JXfOSZ4zQgrJAOqOfOM2&username=test&password=$2a$10$ATXY1ablVzcML1aNFkZrbuD3oVvddBw62JXfOSZ4zQgrJAOqOfOM2

1745224588876

网关

java
 <!--webflux-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>
        <!--gateway-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
         <!--oauth2-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-resource-server</artifactId>
        </dependency>
java
spring:
  cloud:
    gateway:
      routes:
        # web服务路由
        - id: web-router
          uri: lb://WEB
          predicates:
          - Path=/api/**
        # auth服务路由
        - id: auth-router
          uri: lb://AUTH
          predicates:
          - Path=/auth/**
  redis:
    host: 39.106.55.179
    port: 6379
    database: 0
    password: xxx
hystrix:
  command:
    default:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 5000

以下的几个核心配置类相当僵硬, 稍有改动可能会导致Bean加载顺序出问题而报错, 但仍有优化空间

ResourceServerConfig, oauth2资源服务器配置

  1. 配置全局跨域
  2. gateway存放pubkey.txt(auth服务存放.jks文件), 配置token转换器方便解析token
  3. 注册权限验证器ReactiveAuthenticationManager和认证验证器ReactiveAuthorizationManager<AuthorizationContext>
java
package top.xinzhang0618.oa.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.security.oauth2.server.resource.web.server.ServerBearerTokenAuthenticationConverter;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authentication.AuthenticationWebFilter;
import org.springframework.web.cors.reactive.CorsUtils;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import top.xinzhang0618.oa.util.FileUtils;

/**
 * @author xinzhang
 * @date 2020/11/17 19:43
 */
@Configuration
@EnableWebFluxSecurity
public class ResourceServerConfig {

    private static final String MAX_AGE = "18000L";

    @Autowired
    private AccessManager accessManager;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 跨域配置
     */
    public WebFilter corsFilter() {
        return (ServerWebExchange ctx, WebFilterChain chain) -> {
            ServerHttpRequest request = ctx.getRequest();
            if (CorsUtils.isCorsRequest(request)) {
                HttpHeaders requestHeaders = request.getHeaders();
                ServerHttpResponse response = ctx.getResponse();
                HttpMethod requestMethod = requestHeaders.getAccessControlRequestMethod();
                HttpHeaders headers = response.getHeaders();
                headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, requestHeaders.getOrigin());
                headers.addAll(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS,
                        requestHeaders.getAccessControlRequestHeaders());
                if (requestMethod != null) {
                    headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, requestMethod.name());
                }
                headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
                headers.add(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, "*");
                headers.add(HttpHeaders.ACCESS_CONTROL_MAX_AGE, MAX_AGE);
                if (request.getMethod() == HttpMethod.OPTIONS) {
                    response.setStatusCode(HttpStatus.OK);
                    return Mono.empty();
                }
            }
            return chain.filter(ctx);
        };
    }

    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setVerifierKey(FileUtils.read("config/pubkey.txt"));
        return converter;
    }

    @Bean
    SecurityWebFilterChain webFluxSecurityFilterChain(ServerHttpSecurity http) {
        //token管理器-jwt实现类
        ReactiveAuthenticationManager tokenAuthenticationManager =
                new JwtAuthenticationManager(new JwtTokenStore(accessTokenConverter()),redisTemplate);
        //认证过滤器
        AuthenticationWebFilter authenticationWebFilter = new AuthenticationWebFilter(tokenAuthenticationManager);
        authenticationWebFilter.setServerAuthenticationConverter(new ServerBearerTokenAuthenticationConverter());

        http.httpBasic().disable()
                .csrf().disable()
                .authorizeExchange()
                .pathMatchers(HttpMethod.OPTIONS).permitAll()
                .anyExchange().access(accessManager)
                .and()
                .addFilterAt(corsFilter(), SecurityWebFiltersOrder.CORS)
                .addFilterAt(authenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION);
        return http.build();
    }
}

AccessManager, 实现ReactiveAuthorizationManager<AuthorizationContext>接口做鉴权

这里可以设定放开的静态资源路径, 对客户端做路径的权限控制, 利用正则对用户请求路径做权限控制等,

对于分布式后端做鉴权, 两种方案:

  1. 使用restFul风格的url, 那只能使用正则表达式以实现对资源路径的校验;
  2. 全部使用POST作为请求方式, 同样数据库维护url的资源权限;

不论哪种, 维护都较为繁琐, 且使用正则性能低, 目前个人没发现好的解决方案, 因此示例项目这部分没做, 仅由前端控制权限

java
package top.xinzhang0618.oa.config;

import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.HashSet;
import java.util.Set;

/**
 * @author xinzhang
 * @date 2020/11/18 17:40
 */
@Component
public class AccessManager implements ReactiveAuthorizationManager<AuthorizationContext> {

    private final Set<String> permitAll = new HashSet<>();

    private static final AntPathMatcher antPathMatcher = new AntPathMatcher();

    public AccessManager() {
        permitAll.add("/");
        permitAll.add("/auth/oauth/**");
    }

    @Override
    public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
        ServerWebExchange exchange = authorizationContext.getExchange();
        String requestPath = exchange.getRequest().getURI().getPath();
        if (permitAll(requestPath)) {
            return Mono.just(new AuthorizationDecision(true));
        }
        return mono.map(auth -> new AuthorizationDecision(checkAuthorities(auth, requestPath)))
                .defaultIfEmpty(new AuthorizationDecision(false));
    }

    /**
     * 校验是否属于静态资源
     *
     * @param requestPath 请求路径
     * @return
     */
    private boolean permitAll(String requestPath) {
        return permitAll.stream().anyMatch(r -> antPathMatcher.match(r, requestPath));
    }

    /**
     * 权限校验
     *
     * @param auth        Authentication
     * @param requestPath 请求路径
     * @return
     */
    private boolean checkAuthorities(Authentication auth, String requestPath) {
        if (auth instanceof OAuth2Authentication) {
            OAuth2Authentication oAuth2Authentication = (OAuth2Authentication) auth;
            String clientId = oAuth2Authentication.getOAuth2Request().getClientId();
            // 权限校验
        }
        return true;
    }
}

JwtAuthenticationManager实现ReactiveAuthenticationManager接口做认证

认证就一个authenticate方法较为简单, 注意实现的逻辑有:

  1. 先过滤redis黑名单
  2. jwt的解析
  3. token的过期校验
  4. token鉴权
java
package top.xinzhang0618.oa.config;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.common.exceptions.InvalidTokenException;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken;
import reactor.core.publisher.Mono;

/**
 * @author xinzhang
 * @date 2020/11/17 19:53
 */
public class JwtAuthenticationManager implements ReactiveAuthenticationManager {

    private RedisTemplate<String, Object> redisTemplate;
    private TokenStore tokenStore;

    public JwtAuthenticationManager(TokenStore tokenStore, RedisTemplate<String, Object> redisTemplate) {
        this.tokenStore = tokenStore;
        this.redisTemplate = redisTemplate;
    }

    @Override
    public Mono<Authentication> authenticate(Authentication authentication) {
        return Mono.justOrEmpty(authentication)
                .filter(a -> a instanceof BearerTokenAuthenticationToken)
                .cast(BearerTokenAuthenticationToken.class)
                .map(BearerTokenAuthenticationToken::getToken)
                .flatMap((accessToken -> {
                    // redis黑名单
                    if (redisTemplate.opsForValue().get("TOKEN_BLACK_LIST:Bearer " + accessToken) != null) {
                        return Mono.error(new InvalidTokenException("请重新登录"));
                    }

                    OAuth2AccessToken oAuth2AccessToken;
                    try {
                        oAuth2AccessToken = this.tokenStore.readAccessToken(accessToken);
                    } catch (Exception e) {
                        return Mono.error(new InvalidTokenException("token无效, 请重新登录"));
                    }
                    if (oAuth2AccessToken == null) {
                        return Mono.error(new InvalidTokenException("请先登录"));
                    } else if (oAuth2AccessToken.isExpired()) {
                        return Mono.error(new InvalidTokenException("登录已过期, 请重新登录"));
                    }
                    OAuth2Authentication oAuth2Authentication = this.tokenStore.readAuthentication(accessToken);
                    if (oAuth2Authentication == null) {
                        return Mono.error(new InvalidTokenException("登录无效, 请重新登录"));
                    } else {
                        return Mono.just(oAuth2Authentication);
                    }
                })).cast(Authentication.class);
    }
}

AuthGlobalFilter实现GlobalFilter, 作为校验通过后的过滤器

  1. 注意这里有个骚操作是"往SecurityContext中塞入token, 方便restTemplate取出并携带", 是为了后续访问auth服务重新获取token所必须的, oauth2+webflux下的的SecurityContext中啥都没有
  2. 判断token有效期, 只剩5分钟时, 重新获取token, 并在请求头添加token刷新的事件
  3. 解析jwt内容塞入上下文
java
package top.xinzhang0618.oa.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import top.xinzhang0618.oa.BizContext;
import top.xinzhang0618.oa.constant.GatewayConstant;
import top.xinzhang0618.oa.util.StringUtils;
import top.xinzhang0618.oa.util.TokenUtil;

import java.util.Map;

/**
 * @author xinzhang
 * @date 2020/11/17 19:55
 */
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {

    @Autowired
    private JwtAccessTokenConverter accessTokenConverter;
    @Autowired
    private TokenUtil tokenUtil;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String token = exchange.getRequest().getHeaders().getFirst(GatewayConstant.AUTHORIZATION);
        if (StringUtils.isEmpty(token)) {
            return chain.filter(exchange);
        }
        // 往SecurityContext中塞入token, 方便restTemplate取出并携带
        SecurityContext context = SecurityContextHolder.getContext();
        context.setAuthentication(new BearerTokenAuthenticationToken(token));

        OAuth2AccessToken oAuth2AccessToken = new JwtTokenStore(accessTokenConverter).readAccessToken(token.replace(
                GatewayConstant.TOKEN_PREFIX, ""));
        Map<String, Object> map = oAuth2AccessToken.getAdditionalInformation();
        String userId = (String) map.get(GatewayConstant.USER_ID);
        String appKey = (String) map.get(GatewayConstant.APP_KEY);

        // token有效期只剩5分钟时刷新token
        if (oAuth2AccessToken.getExpiresIn() < GatewayConstant.TOKEN_REFRESH_TIME_LIMIT) {
            String newToken = tokenUtil.generateToken((String) map.get(GatewayConstant.USER_ID), (String) map.get(GatewayConstant.APP_KEY));
            HttpHeaders headers = exchange.getResponse().getHeaders();
            headers.add(GatewayConstant.ACCESS_TOKEN, newToken);
            headers.add(GatewayConstant.EVENT, GatewayConstant.TOKEN_REFRESHED);
        }

        // 设置上下文
        BizContext.setUserId((Long) map.get(GatewayConstant.USER_ID));
        BizContext.setUserName((String) map.get(GatewayConstant.USER_NAME));
        BizContext.setTenantId((Long) map.get(GatewayConstant.TENANT_ID));
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

补充:

RestTemplateConfig, 注意下拦截器, 从SecurityContext中取出token并携带

java
package top.xinzhang0618.oa.config.rest;

import com.alibaba.fastjson.serializer.SerializerFeature;
import com.alibaba.fastjson.support.config.FastJsonConfig;
import com.alibaba.fastjson.support.spring.FastJsonHttpMessageConverter;

import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;

import org.apache.http.client.HttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken;
import org.springframework.web.client.RestTemplate;
import top.xinzhang0618.oa.constant.GatewayConstant;

/**
 * RestTemplate配置类
 *
 * @author gavin
 * @date 2020-07-01
 * 文档: https://docs.spring.io/spring/docs/4.3.9.RELEASE/spring-framework-reference/html/remoting.html#rest-client-access
 * api: https://docs.spring.io/spring-framework/docs/4.3.9
 * .RELEASE/javadoc-api/org/springframework/web/client/RestTemplate.html
 */
@Configuration
public class RestTemplateConfig {

    private static final Logger LOGGER = LoggerFactory.getLogger(RestTemplateConfig.class);

    @Bean
    @LoadBalanced
    public RestTemplate restTemplate() {
        RestTemplate restTemplate = new RestTemplate(clientHttpRequestFactory());
        restTemplate.getInterceptors().add(authorizedRequestInterceptor());
        replaceJackson2FastJson(restTemplate);
        return restTemplate;
    }

    /**
     * 自定义拦截器携带authorization
     */
    @Bean(name = "authorizedRequestInterceptor")
    public ClientHttpRequestInterceptor authorizedRequestInterceptor() {
        return (httpRequest, bytes, clientHttpRequestExecution) -> {
            HttpHeaders headers = httpRequest.getHeaders();
            BearerTokenAuthenticationToken authentication =
                    (BearerTokenAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
            headers.add(GatewayConstant.AUTHORIZATION, authentication.getToken());
            return clientHttpRequestExecution.execute(httpRequest, bytes);
        };
    }

    /**
     * 替换默认的jackson转换器为fastJson转换器
     */
    private void replaceJackson2FastJson(RestTemplate restTemplate) {
        List<HttpMessageConverter<?>> converters = restTemplate.getMessageConverters();
        //原有的String是ISO-8859-1编码 替换成 UTF-8编码
        converters.removeIf(c -> c instanceof StringHttpMessageConverter);
        converters.add(new StringHttpMessageConverter(StandardCharsets.UTF_8));
        converters.add(0, fastJsonHttpMessageConverter());
    }

    /**
     * 配置fastJson转换器
     */
    @Bean
    public HttpMessageConverter fastJsonHttpMessageConverter() {
        FastJsonConfig fastJsonConfig = new FastJsonConfig();
        fastJsonConfig.setSerializerFeatures(SerializerFeature.WriteMapNullValue, SerializerFeature.QuoteFieldNames,
                SerializerFeature.WriteNullStringAsEmpty, SerializerFeature.WriteNullListAsEmpty,
                SerializerFeature.DisableCircularReferenceDetect);
        FastJsonHttpMessageConverter fastJsonHttpMessageConverter = new FastJsonHttpMessageConverter();
        fastJsonHttpMessageConverter.setSupportedMediaTypes(Arrays.asList(MediaType.APPLICATION_JSON_UTF8,
                MediaType.TEXT_HTML));
        fastJsonHttpMessageConverter.setFastJsonConfig(fastJsonConfig);
        return fastJsonHttpMessageConverter;
    }

    /**
     * 配置clientHttpRequestFactory
     */
    @Bean
    public HttpComponentsClientHttpRequestFactory clientHttpRequestFactory() {
        try {
            HttpClientBuilder httpClientBuilder = HttpClientBuilder.create();
            //设置连接池
            PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
            //最大连接数
            connectionManager.setMaxTotal(20);
            //同路由并发数
            connectionManager.setDefaultMaxPerRoute(10);
            httpClientBuilder.setConnectionManager(connectionManager);

            HttpClient httpClient = httpClientBuilder.build();
            // httpClient连接配置
            HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(
                    httpClient);
            //连接超时
            requestFactory.setConnectTimeout(60 * 1000);
            //数据读取超时时间
            requestFactory.setReadTimeout(60 * 1000);
            //连接不够用的等待时间
            requestFactory.setConnectionRequestTimeout(60 * 1000);
            return requestFactory;
        } catch (Exception e) {
            LOGGER.error(String.format("初始化clientHttpRequestFactory失败, 错误信息: %s", e));
        }
        return null;
    }
}

网关全局异常处理

ExceptionConfig, 主要是定义ErrorWebExceptionHandler的Bean

java
package top.xinzhang0618.oa.config.exception;

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.web.ResourceProperties;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.reactive.error.ErrorAttributes;
import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.web.reactive.result.view.ViewResolver;

import java.util.Collections;
import java.util.List;

/**
 * @author xinzhang
 * @date 2020/11/20 16:22
 */
@Configuration
@EnableConfigurationProperties({ServerProperties.class, ResourceProperties.class})
public class ExceptionConfig {
    private final ServerProperties serverProperties;

    private final ApplicationContext applicationContext;

    private final ResourceProperties resourceProperties;

    private final List<ViewResolver> viewResolvers;

    private final ServerCodecConfigurer serverCodecConfigurer;

    public ExceptionConfig(ServerProperties serverProperties,
                           ResourceProperties resourceProperties,
                           ObjectProvider<List<ViewResolver>> viewResolversProvider,
                           ServerCodecConfigurer serverCodecConfigurer,
                           ApplicationContext applicationContext) {
        this.serverProperties = serverProperties;
        this.applicationContext = applicationContext;
        this.resourceProperties = resourceProperties;
        this.viewResolvers = viewResolversProvider.getIfAvailable(Collections::emptyList);
        this.serverCodecConfigurer = serverCodecConfigurer;
    }

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public ErrorWebExceptionHandler errorWebExceptionHandler(ErrorAttributes errorAttributes) {
        JsonExceptionHandler exceptionHandler = new JsonExceptionHandler(
                errorAttributes,
                this.resourceProperties,
                this.serverProperties.getError(),
                this.applicationContext);
        exceptionHandler.setViewResolvers(this.viewResolvers);
        exceptionHandler.setMessageWriters(this.serverCodecConfigurer.getWriters());
        exceptionHandler.setMessageReaders(this.serverCodecConfigurer.getReaders());
        return exceptionHandler;
    }
}

JsonExceptionHandler, 实现默认的DefaultErrorWebExceptionHandler异常处理器, 重写方法

此处的status可能有线程安全问题, 待优化, 这个status是请求返回的http状态码, 但项目中我自定义的异常码都是10000-10003这种, 在getErrorAttributes方法又覆盖了原有的返回值, 因此会导致getHttpStatus方法报错, 暂时存放一个类变量以保存原有的status码

java
package top.xinzhang0618.oa.config.exception;

import com.alibaba.fastjson.JSON;
import org.springframework.boot.autoconfigure.web.ErrorProperties;
import org.springframework.boot.autoconfigure.web.ResourceProperties;
import org.springframework.boot.autoconfigure.web.reactive.error.DefaultErrorWebExceptionHandler;
import org.springframework.boot.web.reactive.error.ErrorAttributes;
import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpStatus;
import org.springframework.web.reactive.function.server.*;
import top.xinzhang0618.oa.constant.GatewayConstant;
import top.xinzhang0618.oa.rest.response.ErrorCode;
import top.xinzhang0618.oa.rest.response.RestResponse;

import java.util.Map;

/**
 * 自定义异常处理器
 * 参考: https://cloud.tencent.com/developer/article/1650123
 * 参考: https://blog.csdn.net/github_38924695/article/details/104374037
 *
 * @author xinzhang
 * @date 2020/11/20 16:02
 */
public class JsonExceptionHandler extends DefaultErrorWebExceptionHandler {
    /**
     * 原ErrorAttributes中状态码
     */
    private int status;

    public JsonExceptionHandler(ErrorAttributes errorAttributes, ResourceProperties resourceProperties,
                                ErrorProperties errorProperties, ApplicationContext applicationContext) {
        super(errorAttributes, resourceProperties, errorProperties, applicationContext);
    }

    /**
     * 获取异常属性
     */
    @Override
    protected Map<String, Object> getErrorAttributes(ServerRequest request, boolean includeStackTrace) {
        Throwable error = super.getError(request);
        this.status= (int) super.getErrorAttributes(request, includeStackTrace).get(GatewayConstant.STATUS);
        return JSON.parseObject(JSON.toJSONString(RestResponse.failure(ErrorCode.UNKNOWN_ERROR.getValue(),
                error.getMessage())));
    }

    /**
     * 指定响应处理方法为JSON处理的方法
     *
     * @param errorAttributes
     */
    @Override
    protected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes) {
        return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse);
    }

    /**
     * 根据code获取对应的HttpStatus
     *
     * @param errorAttributes
     */
    @Override
    protected HttpStatus getHttpStatus(Map<String, Object> errorAttributes) {
        return HttpStatus.valueOf(status);
    }

}

日志处理

网关可以添加拦截器, 记录请求的参数以及返回值, 个人觉得不太好, 增加了响应时间, 日志应该在各服务业务处理的地方做记录比较合适

断路器

hystrix是一定要配置的, 详见上面配置

限流

暂不配置, 略