likes
comments
collection
share

玩转SpringBoot项目第二篇--SpringCloudGateway+鉴权方案的实现+ThreadLocal用户信息上下文的生成

作者站长头像
站长
· 阅读数 2

前言

上一篇文章中写了全局的异常拦截和请求返回结果的封装,这里将其改造一下,common包中的拦截器部分移动走,新建一个模块spring-boot-starter-interceptor,这个模块将存放所有的拦截器,然后common包将存放一些标准的工具类和模型相关。

新增gateway模块,作为网关服务,将在这里面进行全局的rest拦截和鉴权操作。

技术栈

SpringBoot 3.0.2

SpringCloudAlibaba 2022.0.0.0-RC2

JDK 17

需要集成SpringCloudAlibabaNacos,作为配置中心,这个大家自己搭建一个nacos,单机启动即可。

思路

首先gateway作为网关,负责统一的鉴权和路由转发,通过nacos配置中心完成一些配置文件,比如忽略鉴权的路由数组,比如我们登录接口需要忽略鉴权,然后登录之后拿到token之后才可以访问其他接口。同时使用配置刷新的能力,动态维护忽略路由,避免增加忽略路由配置导致网关服务重新部署的情况。

common模块

这个模块增加了token生成的相关工具类

相关依赖

<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter</artifactId>  
</dependency>  
<dependency>  
    <groupId>io.jsonwebtoken</groupId>  
    <artifactId>jjwt</artifactId>  
    <version>0.9.1</version>  
</dependency>  
<dependency>  
    <groupId>com.alibaba</groupId>  
    <artifactId>fastjson</artifactId>  
</dependency>  
<dependency>  
    <groupId>org.projectlombok</groupId>  
    <artifactId>lombok</artifactId>  
</dependency>  
<dependency>  
    <groupId>com.alibaba.cloud</groupId>  
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>  
</dependency>  
<dependency>  
    <groupId>javax.xml.bind</groupId>  
    <artifactId>jaxb-api</artifactId>  
    <version>2.3.0</version>  
</dependency>  
<dependency>  
    <groupId>com.sun.xml.bind</groupId>  
    <artifactId>jaxb-impl</artifactId>  
    <version>2.3.0</version>  
</dependency>  
<dependency>  
    <groupId>com.sun.xml.bind</groupId>  
    <artifactId>jaxb-core</artifactId>  
    <version>2.3.0</version>  
</dependency>  
<dependency>  
    <groupId>javax.activation</groupId>  
    <artifactId>activation</artifactId>  
    <version>1.1.1</version>  
</dependency>

token所需属性配置(TokenProperties)

@ConfigurationProperties(prefix = "org.yulbo.security.token")  
@Data  
@RefreshScope //自动刷新
public class TokenProperties {  
  
    private String secret;  

    private Integer expire;  
}

这个@RefreshScope依赖于nacos配置中心的配置文件,需要引用common包的服务配置配置文件,下面会有。

用户信息模型(UserInfo)后面的ThreadLocal中也会使用,这个是生成token必备的

@Data  
public class UserInfo {  
  
    /**  
    * 用户id  
    * @author yulbo  
    * @date 2023/12/30 18:47  
    */  
    private String userId;  

    /**  
    * 用户名  
    * @author yulbo  
    * @date 2023/12/30 18:47  
    */  
    private String userName;  

    /**  
    * 用户租户  
    * @author yulbo  
    * @date 2023/12/30 18:48  
    */  
    private String tenantId;  

    public UserInfo(String userId, String userName, String tenantId) {  
        this.userId = userId;  
        this.userName = userName;  
        this.tenantId = tenantId;  
    }  
}

token生成工具TokenUtil

@Component  
public class TokenUtil {  
  
    @Resource  
    private TokenProperties tokenProperties;  

    //token名,作为head的key,可以自己设定  
    public static final String TOKEN = "token";  

    //签名算法  
    private SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.HS512;  

    /**  
    * token生成方法  
    * @author yulbo  
    * @date 2023/12/30 18:53  
    */  
    public String generateToken(UserInfo userInfo) {  
        return Jwts.builder()  
            .setSubject(JSON.toJSONString(userInfo))  
            .setIssuedAt(new Date())  
            .setExpiration(generateExpirationDate(tokenProperties.getExpire()))  
            .signWith( SIGNATURE_ALGORITHM, tokenProperties.getSecret())  
            .compact();  
    }  

    /**  
    * 验证token  
    * @author yulbo  
    * @date 2023/12/30 18:53  
    */  
    public Boolean validateToken(String token) {  
        final UserInfo userInfo = getUserFromToken(token);  
        final Date expiration = getExpirationFromToken(token);  
        return ( userInfo!=null && expiration.after(new Date()));  
    }  

    /**  
    * 从token中拿用户信息  
    * @author yulbo  
    * @date 2023/12/30 19:15  
    */  
    public UserInfo getUserFromToken(String token) {  
        UserInfo user;  
        try {  
            final Claims claims = this.getAllClaimsFromToken(token);  
            user = JSON.parseObject(claims.getSubject(), UserInfo.class);  
        } catch (Exception e) {  
            user = null;  
        }  
        return user;  
    }  

    private Claims getAllClaimsFromToken(String token) {  
        Claims claims;  
        try {  
            claims = Jwts.parser()  
                .setSigningKey(tokenProperties.getSecret())  
                .parseClaimsJws(token)  
                .getBody();  
        } catch (Exception e) {  
            claims = null;  
        }  
        return claims;  
    }  

    private Date getExpirationFromToken(String token) {  
        Date exp;  
        try {  
            final Claims claims = this.getAllClaimsFromToken(token);  
            exp = claims.getExpiration();  
        } catch (Exception e) {  
            exp = null;  
        }  
        return exp;  
    }  

    private Date generateExpirationDate(Integer expiresIn) {  
        return new Date(System.currentTimeMillis() + expiresIn * 1000L);  
    }  
}

配置类CommonConfiguration

@Configuration  
public class CommonConfiguration {  
  
    @Bean  
    @ConditionalOnMissingBean  
    public TokenUtil tokenUtil(){  
        return new TokenUtil();  
    }  

    @Bean  
    @ConditionalOnMissingBean  
    public TokenProperties tokenProperties(){  
        return new TokenProperties();  
    }  
}

自动配置的引入请参照我的上一篇文章,在SpringBoot23的版本,自动配置的方式是不同的。

gateway模块

gateway引入common 模块就具备了使用token工具类的能力,然后配置上nacostoken相关的配置文件。

相关依赖

<dependency>  
    <groupId>org.yulbo</groupId>  
    <artifactId>common</artifactId>  
    <version>${learning.version}</version>  
</dependency>  
<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter</artifactId>  
</dependency>  
<dependency>  
    <groupId>org.springframework.cloud</groupId>  
    <artifactId>spring-cloud-starter-gateway</artifactId>  
</dependency>  
<dependency>  
    <groupId>com.alibaba.cloud</groupId>  
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>  
</dependency>  
<dependency>  
    <groupId>org.springframework.cloud</groupId>  
    <artifactId>spring-cloud-starter-bootstrap</artifactId>  
</dependency>  
<dependency>  
    <groupId>com.alibaba.cloud</groupId>  
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>  
</dependency>  
<dependency>  
    <groupId>org.springframework.cloud</groupId>  
    <artifactId>spring-cloud-starter-loadbalancer</artifactId>  
</dependency>  
<dependency>  
    <groupId>org.projectlombok</groupId>  
    <artifactId>lombok</artifactId>  
</dependency>

spring-cloud-starter-bootstrap这个依赖的目的是为了使用bootstrap.ymlspringCloudAlibabaNacos启动的时候需要这个才能启动容器,跟加载配置文件的顺序有关系,其次就是spring-cloud-starter-loadbalancer,高版本的gateway移除了ribbon,所以需要引入一个负载均衡的组件。

ym配置文件

详情请看注释

server:  
  port: 8888  
spring:  
  profiles:  
    active: dev  
  application:  
    name: api-gateway  
  cloud:  
    gateway:  
    # gateway的相关配置,使用下面这种方式可以自动发现nacos的服务,可以简略配置。如果需要其他复杂的配置就配置routes  
      discovery:  
        locator:  
          enabled: true #让gateway发现nacos中的服务  
          filters:  
            - StripPrefix=1 #去除第一层路由(也就是微服务的应用名)  
  
    nacos:  
      discovery:  
        sever-addr: 127.0.0.1:8848  
        namespace: 470d4b50-8543-4e7a-8516-4f4739087d8f #做了环境隔离  
        group: yulbo  
  
    config:  
      server-addr: 127.0.0.1:8848  
      namespace: 470d4b50-8543-4e7a-8516-4f4739087d8f  
      file-extension: yaml  
      extension-configs:  
      #第一个配置文件是gateway的配置文件,我在里面增加了一个配置,就是忽略鉴权的url  
        - group: yulbo  
          data-id: ${spring.application.name}-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}  
          refresh: true  
      # 第二个配置文件配置了一些全局配置,目前配置了token的秘钥和失效时间  
        - group: yulbo  
          data-id: common-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension} 
          refresh: true  
     refresh-enabled: true

nacos配置文件api-gateway-dev.yaml

gateway:
  ignoreurl:
    - /rest/v1/user/login

nacos配置文件common-dev.yaml

org:
  yulbo:
    security:
      token:
        secret: yulbo666
        expire: 120

玩转SpringBoot项目第二篇--SpringCloudGateway+鉴权方案的实现+ThreadLocal用户信息上下文的生成

WebFilterUrlProperty网关过滤url配置

这个就是对应的上面的第一个nacos配置文件,也就是api-gateway-dev.yaml中的配置

/**  
* 网关过滤url配置  
* @author yulbo  
* @date 2024/01/06 15:26  
*/  
@Data  
@Configuration  
@RefreshScope//自动刷新
@ConfigurationProperties(prefix = "gateway")  
public class WebFilterUrlProperty {  
  
    private List<String> ignoreurl = new ArrayList<>();  
  
}

鉴权验证过滤器ForwardAuthFilter

/**  
* 鉴权拦截器  
* @author yulbo  
* @date 2024/01/06 15:55  
*/  
@Component  
public class ForwardAuthFilter implements GlobalFilter {  
  
    @Resource  
    private WebFilterUrlProperty webFilterUrlProperty;  

    @Resource  
    private TokenUtil tokenUtil;  

    @Override  
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {  
        String token = "";  
        //忽略标识  
        boolean ignore = true;  
        //如果nacos配置文件中配置了该请求忽略的话,那么就可以跳过鉴权  
        if(!webFilterUrlProperty.getIgnoreurl().contains(exchange.getRequest().getPath().value())){  
            token = exchange.getRequest().getHeaders().getFirst(TokenUtil.TOKEN);  
            ignore = false;  
        }  
        //如果不能忽略并且没有验证通过就返回鉴权失败  
        if(!ignore && !verify(exchange)){  
            return authError(exchange.getResponse(),JSON.toJSONString(ResponseResult.fail("token失效")));  
        }  
        //向下传递token,放在header中,继续转发  
        ServerHttpRequest request = exchange  
                                        .getRequest()  
                                        .mutate()  
                                        .header(TokenUtil.TOKEN, token)  
                                        .build();  
        ServerWebExchange newExchange = exchange.mutate().request(request).build();  
        return chain.filter(newExchange);  
    }  

    /**  
    * 验证接口  
    * @author yulbo  
    * @date 2024/01/06 15:57  
    */  
    private boolean verify(ServerWebExchange exchange){  
        //从header中拿token然后调用方法验证  
        String token = exchange.getRequest().getHeaders().getFirst(TokenUtil.TOKEN);  
        if(StringUtils.isBlank(token)){  
            return false;  
        }  
        return tokenUtil.validateToken(token);  
    }  

    /**  
    * 验证失败,返回错误信息  
    * @author yulbo  
    * @date 2024/01/06 15:58  
    */  
    private Mono<Void> authError(ServerHttpResponse resp, String mess) {  
        resp.setStatusCode(HttpStatus.UNAUTHORIZED);  
        resp.getHeaders().add("Content-Type", "application/json;charset=UTF-8");  
        DataBuffer buffer = resp.bufferFactory().wrap(mess.getBytes(StandardCharsets.UTF_8));  
        return resp.writeWith(Flux.just(buffer));  
    }  
}

这个就可以完成鉴权的拦截了,然后向下传递token,之后想增加忽略认证的接口,那么就修改nacos中对应的配置文件。下面开始弄业务拦截器的部分.

spring-boot-starter-interceptor

这个模块的存在就是存放业务系统中使用的各种通用的拦截器,比如业务系统使用的用户信息拦截,将网关传递的token进行解析,然后将用户信息存入到ThreadLocal中,然后方便业务系统使用。并且引入了阿里的transmittable-thread-local,可以解决多线程环境下ThreadLocal失效的问题,具体使用,参照官网 transmittable-thread-local

相关依赖

<dependency>  
    <groupId>org.yulbo</groupId>  
    <artifactId>common</artifactId>  
    <version>${learning.version}</version>  
</dependency>  
<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter</artifactId>  
</dependency>  
<dependency>  
    <groupId>com.alibaba</groupId>  
    <artifactId>transmittable-thread-local</artifactId>  
    <version>2.12.6</version>  
</dependency>  
<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-web</artifactId>  
</dependency>  
<dependency>  
    <groupId>com.alibaba</groupId>  
    <artifactId>fastjson</artifactId>  
</dependency>  
<dependency>  
    <groupId>org.projectlombok</groupId>  
    <artifactId>lombok</artifactId>  
</dependency>

用户线程变量模型ThreadLocalVariable

存储token,用户信息,和用户租户信息

@Data  
public class ThreadLocalVariable {  
  
    private String token;  

    private UserInfo userInfo;  

    private String tenant;  
}

用户线程变量AuthThreadLocalVariables

存储了用户线程变量,并提供获取用户,token,租户,和清除ThreadLocal的方法

@Component  
public class AuthThreadLocalVariables implements Serializable {  
    private static final ThreadLocal<ThreadLocalVariable> THREAD_LOCAL_VAR = new TransmittableThreadLocal<>();  


    public String getUserId() {  
        UserInfo user = getUser();  
        if(user==null){  
            return null;  
        }  
        return String.valueOf(user.getUserId());  
    }  


    public UserInfo getUser() {  
        ThreadLocalVariable variable = THREAD_LOCAL_VAR.get();  
        if (variable == null) {  
            return null;  
        }  

        return variable.getUserInfo();  
    }  


    public String getToken() {  
        ThreadLocalVariable variable = THREAD_LOCAL_VAR.get();  
        if (variable == null) {  
            return null;  
        }  

        return variable.getToken();  
    }  

    public String getTenant() {  
        ThreadLocalVariable variable = THREAD_LOCAL_VAR.get();  
        if(variable == null){  
            return null;  
        }  
        return variable.getTenant();  
    }  
    public ThreadLocalVariable getVariable() {  
        return THREAD_LOCAL_VAR.get();  
    }  

    public void setVariable(ThreadLocalVariable variable) {  
        THREAD_LOCAL_VAR.set(variable);  
    }  

    public void cleanup() {  
        THREAD_LOCAL_VAR.remove();  
    }  
}

认证拦截器AuthHandler完成ThreadLocal的写入和使用后的清除

@RequiredArgsConstructor  
@Component  
public class AuthHandler implements HandlerInterceptor {  
  
    private final AuthThreadLocalVariables authThreadLocalVariables;  

    private final TokenUtil tokenUtil;  

    @Override  
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {  
        if (HttpMethod.OPTIONS.name().equalsIgnoreCase(request.getMethod())) {  
            return true;  
        }  

        //静态资源忽略安全认证  
        if (handler instanceof org.springframework.web.servlet.resource.ResourceHttpRequestHandler) {  
            return true;  
        }  

        String token = getToken(request);  
        if(StringUtils.isBlank(token)){  
            return true;  
        }  
        if(!tokenUtil.validateToken(token)){  
            return false;  
        }  
        UserInfo userInfo = tokenUtil.getUserFromToken(token);  
        setVariables(token,userInfo);  
        return true;  
    }  

    private String getToken(HttpServletRequest request){  
        return request.getHeader(TokenUtil.TOKEN);  
    }  

    private void setVariables(String token,UserInfo userInfo){  
        ThreadLocalVariable variable = new ThreadLocalVariable();  
        variable.setTenant(userInfo.getTenantId());  
        variable.setToken(token);  
        variable.setUserInfo(userInfo);  
        authThreadLocalVariables.setVariable(variable);  
    }  

    @Override  
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {  
        authThreadLocalVariables.cleanup();  
    }  
}

到这里的话就可以实现业务系统无侵入的写入用户线程变量,和清理了。然后将它注册到到mvc拦截器中.

注册到MVC配置中AuthMvcConfiguration

@Component  
@RequiredArgsConstructor  
public class AuthMvcConfiguration implements WebMvcConfigurer {  
  
    private final AuthHandler authHandler;  

    @Override  
    public void addInterceptors(InterceptorRegistry registry) {  
        registry.addInterceptor(authHandler);  
    }  
}

最后声明配置InterceptorConfiguration

@Configuration  
@ComponentScan  
@RequiredArgsConstructor  
public class InterceptorConfiguration {  
  
    @ConditionalOnMissingBean  
    @Bean  
    public ResponseBodyHandler responseBodyHandler() {  
        return new ResponseBodyHandler();  
    }  

    @ConditionalOnMissingBean  
    @Bean  
    public GlobalExceptionHandler globalExceptionHandler() {  
        return new GlobalExceptionHandler();  
    }  

    @ConditionalOnMissingBean  
    @Bean  
    public AuthHandler authHandler(AuthThreadLocalVariables authThreadLocalVariables, TokenUtil tokenUtil) {  
        return new AuthHandler(authThreadLocalVariables, tokenUtil);  
    }  
}

到这里所有的鉴权方案,ThreadLocal的管理都完成了,就可以在业务系统中使用了。