目 录CONTENT

文章目录

商城网关服务构建

zhouzz
2024-08-31 / 0 评论 / 0 点赞 / 27 阅读 / 61116 字
温馨提示:
本文最后更新于 2024-09-22,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

Spring Cloud Gateway 的主要作用包括以下几个方面:

  • 路由转发:Spring Cloud Gateway 可以根据请求的特定条件(如 URL路径、请求参数、请求头等)来将请求转发到后端的多个服务,并支持动态路由配置。

  • 过滤器功能:Gateway 提供了一套过滤器机制,允许开发人员对请求进行修改和验证,以及应用各种策略,如认证、鉴权、监控/指标、限流、日志、请求转发/重试等

  • 集成断路器:可以集成断路器,比如 Sentinel ,为微服务网关提供了容错处理的功能。

  • 集成服务发现:Gateway 可以与服务注册中心(如 Nacos)集成,动态从服务注册中心获取服务信息并进行路由。

  • 请求转发:Gateway 可以进行请求的协议转换,例如将 HTTP 请求转换成 WebSocket 请求。

  • 请求限流:Gateway 支持通过配置限流规则,对请求进行限流,防止恶意请求或异常情况下的流量冲击。

总的来说,Spring Cloud Gateway 可以作为微服务架构中的入口服务,用于处理请求的路由转发、安全校验、限流等工作,将这些共性的功能抽取到一个统一的网关服务中,避免了在每个微服务中重复实现这些功能,同时也提高了系统的扩展性和稳定性。

1.网关服务基本配置

1.1 pom依赖

<dependencies>
    <!--公共基础模块依赖-->
    <dependency>
        <groupId>com.zzmall</groupId>
        <artifactId>zzmall-common-business</artifactId>
    </dependency>
    <!--nacos注册中心依赖-->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    </dependency>
    <!--nacos配置中心依赖-->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
    </dependency>
    <!--springcloud gateway依赖-->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway</artifactId>
    </dependency>
    <!--springcloud负载均衡依赖-->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-loadbalancer</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
    <!--添加jwt 0.10.5 相关的包-->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
    </dependency>
</dependencies>

1.2 配置nacos命名空间

登录nacos,选择 命名空间菜单,新建一个。

之后,我们在菜单 配置管理 -> 配置列表 ,选择我们新建的命名空间,创建配置。

1.3 配置文件

bootstrap.yml

spring:
  application:
    name: gateway-server  # 微服务名称
  cloud:
    nacos:
      server-addr: 192.168.73.150:8848
      username: nacos
      password: nacos
      discovery: # nacos注册中心配置
        namespace: b943ac45-87c5-41e2-b83f-5863c3ac4581
        # 使用默认即可
        group: DEFAULT_GROUP
        service: ${spring.application.name}
      config: # nacos配置中心配置
        namespace: ${spring.cloud.nacos.discovery.namespace}
        group: ${spring.cloud.nacos.discovery.group}
        prefix: ${spring.application.name}
        file-extension: yml
  profiles:
    active: dev # 多环境配置

1.4 远程配置文件

gateway-server-dev.yml

server:
  port: 8000  # 服务端口号
spring:
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true # 开启gateway的动态路由,从nacos注册中心的服务列表获取服务名称,然后再动态路由到相对应的服务中去
      routes:
        - id: oauth2_route
          uri: lb://oauth2-server
          predicates:
            - Path=/oauth2/**   # 断言,路径相匹配的进行路由
        - id: member_route
          uri: lb://business-member
          predicates:
            - Path=/api/member/**   # 断言,路径相匹配的进行路由
          filters:
            - StripPrefix=1 #忽略第一个前缀,即/api
# 解析jwt信息的RSA公钥
jwt:
  secret:
    pub: MIIBIjANBgkqhkiG9w...
# 网关黑白名单
gateway:
  white:
    allow-urls:
      - /oauth2   # 该路径为项目的登录路径
      - /free     # 样例路径

1.5 新建启动类

@SpringBootApplication
@EnableDiscoveryClient
public class GatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(GatewayApplication.class, args);
    }
}

2.测试网关基础配置

启动网关服务,查看启动日志。

Connected to the target VM, address: '127.0.0.1:60084', transport: 'socket'
  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.6.3)
2024-08-31 21:40:33.257  WARN 52352 --- [           main] c.a.c.n.c.NacosPropertySourceBuilder     : Ignore the empty nacos configuration and get it based on dataId[gateway-server] & group[DEFAULT_GROUP]
2024-08-31 21:40:33.261  WARN 52352 --- [           main] c.a.c.n.c.NacosPropertySourceBuilder     : Ignore the empty nacos configuration and get it based on dataId[gateway-server.yml] & group[DEFAULT_GROUP]
...
2024-08-31 21:40:33.274  INFO 52352 --- [           main] com.zzmall.gateway.GatewayApplication    : The following profiles are active: dev
2024-08-31 21:40:33.716  INFO 52352 --- [           main] .s.d.r.c.RepositoryConfigurationDelegate : Multiple Spring Data modules found, entering strict repository configuration mode!
2024-08-31 21:40:33.718  INFO 52352 --- [           main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data Redis repositories in DEFAULT mode.
...
2024-08-31 21:40:40.279  INFO 52352 --- [           main] o.s.b.web.embedded.netty.NettyWebServer  : Netty started on port 80
2024-08-31 21:40:41.716  INFO 52352 --- [           main] o.s.cloud.commons.util.InetUtils         : Cannot determine local hostname
2024-08-31 21:40:43.141  INFO 52352 --- [           main] o.s.cloud.commons.util.InetUtils         : Cannot determine local hostname
2024-08-31 21:40:43.148  INFO 52352 --- [           main] c.a.c.n.registry.NacosServiceRegistry    : nacos registry, DEFAULT_GROUP gateway-server 192.168.254.1:80 register finished
2024-08-31 21:40:43.161  INFO 52352 --- [           main] com.zzmall.gateway.GatewayApplication    : Started GatewayApplication in 13.864 seconds (JVM running for 14.712)
2024-08-31 21:40:43.165  INFO 52352 --- [           main] c.a.c.n.refresh.NacosContextRefresher    : listening config: dataId=gateway-server-dev.yml, group=DEFAULT_GROUP
2024-08-31 21:40:43.166  INFO 52352 --- [           main] c.a.c.n.refresh.NacosContextRefresher    : listening config: dataId=gateway-server, group=DEFAULT_GROUP
2024-08-31 21:40:43.166  INFO 52352 --- [           main] c.a.c.n.refresh.NacosContextRefresher    : listening config: dataId=gateway-server.yml, group=DEFAULT_GROUP

可以看到对应的启动端口是8000,说明是远程从nacos中拉取的配置。如果是本地加载配置文件,本地配置是没有设置端口号的,默认就是8080。

另外,可以看 nacos中,服务管理 -> 服务列表中可以看到 gateway服务在列表中。

这里可以说明 gateway服务基础配置成功了。

3.网关配置白名单

在网关服务中,创建一个过滤器,专门针对黑白名单进行处理,不需要验证身份的直接放行, 需要进行身份校验的,获取请求头里的数据 Authorization判断是否是有效值。

3.1 创建白名单配置类

package com.zzmall.gateway.config;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;
import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Component
@ConfigurationProperties(prefix = "gateway.white")
@RefreshScope
public class WhiteUrlsConfig {
    /**
     * 放行的路径集合
     */
    private List<String> allowUrls;
}

3.2 创建过滤器

package com.zzmall.gateway.filter;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zzmall.common.constant.AuthConstants;
import com.zzmall.common.constant.HttpConstants;
import com.zzmall.common.enums.BusinessEnum;
import com.zzmall.common.result.Result;
import com.zzmall.gateway.config.WhiteUrlsConfig;
import lombok.extern.slf4j.Slf4j;
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.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.Date;

/**
 * 全局token过滤器
 * 前后端约定好令牌存放的位置:请求头的Authorization bearer token
 */
@Component
@Slf4j
public class AuthFilter implements GlobalFilter, Ordered {
    @Autowired
    private WhiteUrlsConfig whiteUrlsConfig;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 校验token
     * 1.获取请求路径
     * 2.判断请求路径是否可以放行
     * 放行:不需要验证身份
     * 不放行:需要对其进行身份的认证
     *
     * @param exchange
     * @param chain
     * @return
     */
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 获取请求对象
        ServerHttpRequest request = exchange.getRequest();
        // 获取请求路径
        String path = request.getPath().toString();
        // 判断当前请求路径是否需要放行,是否存在于白名单中
        if (whiteUrlsConfig.getAllowUrls().contains(path)) {
            // 包含:请求路径包含在白名单中,即需要放行
            return chain.filter(exchange);
        }
        // 请求路径不包含在白名单中,需要对其进行身份的验证
        // 从约定好的位置获取Authorization的值,值的格式为:bearer token
        String authorizationValue = request.getHeaders().getFirst(AuthConstants.AUTHORIZATION);
        // 判断是否有值
        if (StringUtils.hasText(authorizationValue)) {
            // 从Authorization的值中获取token
            String tokenValue = authorizationValue.replaceFirst(AuthConstants.BEARER, "");
            // 判断token值是否有值且是否在redis中存在
            if (StringUtils.hasText(tokenValue) && stringRedisTemplate.hasKey(AuthConstants.LOGIN_TOKEN_PREFIX + tokenValue)) {
                // 身份验证通过,放行
                return chain.filter(exchange);
            }
        }
        // 流程如果走到这:说明验证身份没有通过或请求不合法
        log.error("拦截非法请求,时间:{},请求API路径:{}", new Date(), path);
        // 获取响应对象
        ServerHttpResponse response = exchange.getResponse();
        // 设置响应头信息
        response.getHeaders().set(HttpConstants.CONTENT_TYPE, HttpConstants.APPLICATION_JSON);
        // 设置响应消息
        Result<Object> result = Result.fail(BusinessEnum.UN_AUTHORIZATION);
        // 创建一个objectMapper对象
        ObjectMapper objectMapper = new ObjectMapper();
        byte[] bytes;
        try {
            bytes = objectMapper.writeValueAsBytes(result);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
        DataBuffer dataBuffer = response.bufferFactory().wrap(bytes);
        return response.writeWith(Mono.just(dataBuffer));
    }

    @Override
    public int getOrder() {
        return -5;
    }
}

涉及到的其他常量类

public interface HttpConstants {

    /**
     * http协议中内容类型
     */
    String CONTENT_TYPE = "Content-Type";

    /**
     * http协议中内容类型值:application/json;charset=utf-8
     */
    String APPLICATION_JSON = "application/json;charset=utf-8";

    /**
     * UTF8字符编码
     */
    String UTF_8 = "utf-8";
}
public interface AuthConstants {
    /**
     * 在请求头中存放token值的KEY
     */
    String AUTHORIZATION = "Authorization";

    /**
     * token值的前缀
     */
    String BEARER = "bearer ";

    /**
     * token值存放在redis中的前缀
     */
    String LOGIN_TOKEN_PREFIX = "login_token:";

    /**
     * TOKEN有效时长(单位:秒,4个小时)
     */
    Long TOKEN_TIME = 14400L;

    /**
     * TOKEN的阈值:3600秒(1个小时)
     */
    Long TOKEN_EXPIRE_THRESHOLD_TIME = 60 * 60L;

    /**
     * header中的用户ID
     */
    String HEADER_USER_ID = "userId";
}

3.3 添加远程配置

gateway-server-dev.yml

gateway:
  white:
    allow-urls:
      - /oauth2   # 该路径为项目的登录路径
      - /free     # 样例路径

4.网关登录认证

4.1 鉴权思路分析

​ 单体架构时我们只需要完成一次用户登录、身份校验,就可以在所有业务中获取到用户信息。而微服务拆分后,每个微服务都独立部署,不再共享数据。也就意味着每个微服务都需要做登录校验,这显然不可取。

​ 我们的登录假设基于JWT来实现的,校验JWT的算法复杂,而且需要用到秘钥。如果每个微服务都去做登录校验,这就存在着两大问题:

  • 每个微服务都需要知道JWT的秘钥,不安全
  • 每个微服务重复编写登录校验代码、权限校验代码,麻烦

​既然网关是所有微服务的入口,一切请求都需要先经过网关。我们完全可以把登录校验的工作放到网关去做,这样之前说的问题就解决了:

  • 只需要在网关和用户服务保存秘钥
  • 只需要在网关开发登录校验功能

此时,登录校验的流程如图:

20240905015846.png

不过,这里任然存在几个问题:

  • 网关路由是配置的,请求转发是Gateway内部代码,我们如何在转发登录校验?
  • 网关校验JWT之后,如何将用户信息传递给微服务?
  • 微服务之间也会相互调用,这种调用不经过网关,又该如何传递用户信息?

4.2 网关过滤器

​ 登录校验必须在请求转发到微服务之前做,否则就失去了意义。而网关的请求转发是Gateway内部代码实现的,要想在请求转发之前做登录校验,就必须了解Gateway内部工作的基本原理。

20240905020234.png

如图所示:

  • 1.客户端请求进入网关后由HandlerMapping对请求做判断,找到与当前请求匹配的路由规则(Route),然后将请求交给WebHandler去处理。
  • 2.WebHandler则会加载当前路由下需要执行的过滤器链(Filter chain),然后按照顺序逐一执行过滤器(后面称为Filter)。
  • 3.图中Filter被虚线分为左右两部分,是因为Filter内部的逻辑分为prepost两部分,分别会在请求路由到微服务之前和之后被执行。
  • 4.只有所有Filterpre逻辑都依次顺序执行通过后,请求才会被路由到微服务。
  • 5.微服务返回结果后,再倒序执行Filterpost逻辑。
  • 6.最终把响应结果返回。

4.3 定义一个登录校验的过滤器

/**
 * 全局token过滤器
 */
@Component
@Slf4j
public class AuthFilter implements GlobalFilter, Ordered {

    @Autowired
    private WhiteUrlsConfig whiteUrlsConfig;

    @Autowired
    private AuthConfig authConfig;

    /**
     * 校验token
     * 1.获取请求路径
     * 2.判断请求路径是否可以放行
     * 放行:不需要验证身份
     * 不放行:需要对其进行身份的认证
     *
     * @param exchange
     * @param chain
     * @return
     */
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 获取请求对象
        ServerHttpRequest request = exchange.getRequest();
        // 获取请求路径
        String path = request.getPath().toString();
        // 判断当前请求路径是否需要放行,是否存在于白名单中
        if (whiteUrlsConfig.getAllowUrls().contains(path)) {
            // 包含:请求路径包含在白名单中,即需要放行
            return chain.filter(exchange);
        }

        // 请求路径不包含在白名单中,需要对其进行身份的验证
        // 从约定好的位置获取Authorization的值,值的格式为:bearer token
        String authorizationValue = request.getHeaders().getFirst(AuthConstants.AUTHORIZATION);
        // 判断是否有值
        if (StringUtils.isNotBlank(authorizationValue)) {
            // 从Authorization的值中获取token
            String tokenValue = authorizationValue
                    .replaceFirst(AuthConstants.BEARER, "");

            // 这里需要认证服务集成JWT,生成JWT Token
            // 校验并解析token
            PublicKey publicKey = getPublicKey();
            if (StringUtils.isNotBlank(tokenValue) && JwtUtils.verify(tokenValue, publicKey)) {
                JWTPayload<String> payload = JwtUtils.getInfoFromToken(tokenValue, publicKey, String.class);
                // 如果有效,传递用户信息
                wrapHeader(exchange, payload.getInfo());

                // 身份验证通过,放行
                return chain.filter(exchange);
            }
        }

        // 流程如果走到这:说明验证身份没有通过或请求不合法
        log.error("拦截非法请求,时间:{},请求API路径:{}", new Date(), path);

        // 获取响应对象
        ServerHttpResponse response = exchange.getResponse();
        // 设置响应头信息
        response.getHeaders().set(HttpConstants.CONTENT_TYPE, HttpConstants.APPLICATION_JSON);

        // 设置响应消息
        Result<Object> result = Result.fail(BusinessEnum.UN_AUTHORIZATION);

        // 创建一个objectMapper对象
        ObjectMapper objectMapper = new ObjectMapper();
        byte[] bytes;
        try {
            bytes = objectMapper.writeValueAsBytes(result);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
        DataBuffer dataBuffer = response.bufferFactory().wrap(bytes);
        return response.writeWith(Mono.just(dataBuffer));
    }

    /**
     * @param serverWebExchange
     * @param jsonStr
     * @return
     */
    private ServerWebExchange wrapHeader(ServerWebExchange serverWebExchange, String jsonStr) {
        log.info("jwt的用户信息:{}", jsonStr);

        JSONObject json = JSONObject.parseObject(jsonStr);
        //向headers中放文件,记得build
        ServerHttpRequest request = serverWebExchange.getRequest().mutate()
                .header(AuthConstants.HEADER_USER_ID, json.get(AuthConstants.HEADER_USER_ID) + "")
                .build();

        //将现在的request 变成 change对象
        return serverWebExchange.mutate().request(request).build();
    }

    /**
     * 获取JWT私钥
     * 1.认证服务保存私钥加密信息
     * 2.这里为了方便是直接在网关保存了公钥,
     * 实际做法是网关通过认证服务的凭证式(Client Credentials)模式:
     * a.请求认证服务获取 client_token
     * b.通过client_token请求认证服务获取公钥
     *
     * @return
     */
    private PublicKey getPublicKey() {
        Security.addProvider(new BouncyCastleProvider());

        String pubSecret = authConfig.getPub();
        X509EncodedKeySpec pubKeySpec = new X509EncodedKeySpec(Base64.decodeBase64(pubSecret));

        PublicKey publicKey;
        try {
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            publicKey = keyFactory.generatePublic(pubKeySpec);
        } catch (Exception e) {
            log.error("公钥加载失败", e);
            throw new RuntimeException("服务异常");
        }
        return publicKey;
    }

    @Override
    public int getOrder() {
        return -1;
    }
}

这个过滤器是通过 JWT+RSA完成鉴权流程的。

用非对称加密方式好处是:

  • 生成RSA密钥对,私钥存放在授权中心,公钥下发给微服务
  • 在授权中心利用私钥对JWT签名
  • 在微服务利用公钥验证签名有效性

因为非对称加密的特性,不用担心公钥泄漏问题,因为公钥是无法伪造签名的,但要确保私钥的安全和隐秘。

RsaUtils.java

public class RsaUtils {

    private static final int DEFAULT_KEY_SIZE = 2048;

    /**
     * 从文件中读取公钥
     * @param filename 公钥保存路径,相对于classpath
     * @return 公钥对象
     * @throws Exception
     */
    public static PublicKey getPublicKey(String filename) throws Exception {
        byte[] bytes = readFile(filename);
        return getPublicKey(bytes);
    }

    /**
     * 从文件中读取密钥
     * @param filename 私钥保存路径,相对于classpath
     * @return 私钥对象
     * @throws Exception
     */
    public static PrivateKey getPrivateKey(String filename) throws Exception {
        byte[] bytes = readFile(filename);
        return getPrivateKey(bytes);
    }

    /**
     * 获取公钥
     * @param bytes 公钥的字节形式
     * @return
     * @throws Exception
     */
    private static PublicKey getPublicKey(byte[] bytes) throws Exception {
        bytes = Base64.getDecoder().decode(bytes);
        X509EncodedKeySpec spec = new X509EncodedKeySpec(bytes);
        KeyFactory factory = KeyFactory.getInstance("RSA");
        return factory.generatePublic(spec);
    }

    /**
     * 获取密钥
     * @param bytes 私钥的字节形式
     * @return
     * @throws Exception
     */
    private static PrivateKey getPrivateKey(byte[] bytes) throws NoSuchAlgorithmException, InvalidKeySpecException {
        bytes = Base64.getDecoder().decode(bytes);
        PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes);
        KeyFactory factory = KeyFactory.getInstance("RSA");
        return factory.generatePrivate(spec);
    }

    /**
     * 根据密文,生存rsa公钥和私钥,并写入指定文件
     * @param publicKeyFilename  公钥文件路径
     * @param privateKeyFilename 私钥文件路径
     * @param secret             生成密钥的密文
     */
    public static void generateKey(String publicKeyFilename, String privateKeyFilename, String secret, int keySize) throws Exception {
        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
        SecureRandom secureRandom = new SecureRandom(secret.getBytes());
        keyPairGenerator.initialize(Math.max(keySize, DEFAULT_KEY_SIZE), secureRandom);
        KeyPair keyPair = keyPairGenerator.genKeyPair();
        // 获取公钥并写出
        byte[] publicKeyBytes = keyPair.getPublic().getEncoded();
        publicKeyBytes = Base64.getEncoder().encode(publicKeyBytes);
        writeFile(publicKeyFilename, publicKeyBytes);
        // 获取私钥并写出
        byte[] privateKeyBytes = keyPair.getPrivate().getEncoded();
        privateKeyBytes = Base64.getEncoder().encode(privateKeyBytes);
        writeFile(privateKeyFilename, privateKeyBytes);
    }

    private static byte[] readFile(String fileName) throws Exception {
        return Files.readAllBytes(new File(fileName).toPath());
    }

    private static void writeFile(String destPath, byte[] bytes) throws IOException {
        File dest = new File(destPath);
        if (!dest.exists()) {
            dest.createNewFile();
        }
        Files.write(dest.toPath(), bytes);
    }
}

JwtUtils.java

public class JwtUtils {

    private static final String JWT_PAYLOAD_USER_KEY = "user";

    /**
     * 私钥加密token
     * @param info       载荷中的数据
     * @param privateKey 私钥
     * @param expire     过期时间,单位分钟
     * @return JWT
     */
    public static String generateTokenExpireInMinutes(Map<String, Object> info, PrivateKey privateKey, int expire) {
        return Jwts.builder()
                //claim: 往Jwt的载荷存入数据
                .claim(JWT_PAYLOAD_USER_KEY, JSON.toJSONString(info))
                //往Jwt的载荷存入数据,设置固定id的key
                .setId(createJTI())
                //往Jwt的载荷存入数据,设置固定exp的key
                .setExpiration(addTime(new Date(), Calendar.MINUTE, expire))
                //设置token的签名
                .signWith(privateKey, SignatureAlgorithm.RS256)
                .compact();
    }

    /**
     * 私钥加密token
     * @param info       载荷中的数据
     * @param privateKey 私钥
     * @param expire     过期时间,单位秒
     * @return JWT
     */
    public static String generateTokenExpireInSeconds(Map<String, Object> info, PrivateKey privateKey, int expire) {
        return Jwts.builder()
                .claim(JWT_PAYLOAD_USER_KEY, JSON.toJSONString(info))
                .setId(createJTI())
                .setExpiration(addTime(new Date(), Calendar.SECOND, expire))
                .signWith(privateKey, SignatureAlgorithm.RS256)
                .compact();
    }

    /**
     * 根据类型添加时间
     * @param date
     * @param field
     * @param expire
     * @return
     */
    private static Date addTime(Date date, int field, int expire) {
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(date);
        calendar.add(field, expire);
        return calendar.getTime();
    }

    /**
     * 公钥解析token
     * @param token     用户请求中的token
     * @param publicKey 公钥
     * @return Jws<Claims>
     */
    private static Jws<Claims> parserToken(String token, PublicKey publicKey) {
        return Jwts.parser().setSigningKey(publicKey).parseClaimsJws(token);
    }

    private static String createJTI() {
        return new String(Base64.getEncoder().encode(UUID.randomUUID().toString().getBytes()));
    }

    /**
     * 获取token中的用户信息
     * @param token     用户请求中的令牌
     * @param publicKey 公钥
     * @return 用户信息
     */
    public static <T> Payload<T> getInfoFromToken(String token, PublicKey publicKey, Class<T> userType) {
        Jws<Claims> claimsJws = parserToken(token, publicKey);
        Claims body = claimsJws.getBody();
        Payload<T> claims = new Payload<>();
        claims.setId(body.getId());
        claims.setInfo(JSON.parseObject(body.get(JWT_PAYLOAD_USER_KEY).toString(), userType));
        claims.setExpiration(body.getExpiration());
        return claims;
    }

    /**
     * 获取token中的载荷信息
     * @param token     用户请求中的令牌
     * @param publicKey 公钥
     * @return 用户信息
     */
    public static <T> Payload<T> getInfoFromToken(String token, PublicKey publicKey) {
        Jws<Claims> claimsJws = parserToken(token, publicKey);
        Claims body = claimsJws.getBody();
        Payload<T> claims = new Payload<>();
        claims.setId(body.getId());
        claims.setExpiration(body.getExpiration());
        return claims;
    }
}

4.4 微服务获取用户信息

​ 现在,网关已经可以完成登录校验并获取登录用户身份信息。但是当网关将请求转发到微服务时,微服务又该如何获取用户身份呢?

由于网关发送请求到微服务依然采用的是Http请求,因此我们可以将用户信息以请求头的方式传递到下游微服务。然后微服务可以从请求头中获取登录用户信息。考虑到微服务内部可能很多地方都需要用到登录用户信息,因此我们可以利用SpringMVC的拦截器来实现登录用户信息获取,并存入ThreadLocal,方便后续使用。

据图流程图如下:

20240905162950.png

因此,接下来我们要做的事情有:

  • 改造网关过滤器,在获取用户信息后保存到请求头,转发到下游微服务
  • 编写微服务拦截器,拦截请求获取用户信息,保存到ThreadLocal后放行

4.4.1 保存用户到请求头

首先,我们修改登录校验拦截器的处理逻辑,保存用户信息到请求头中:

// 传递用户信息
String userInfo = userId.toString();
ServerWebExchange webExchange = exchange.mutate().request(builder -> builder.header("userId", userInfo))
    .build();

这里只是做个说明,具体的逻辑看上面的 AuthFilter

4.4.2 拦截器获取用户

需要一个用于保存登录用户的ThreadLocal工具

然后我们需要编写拦截器,获取用户信息并保存到UserContext,然后放行即可。

由于每个微服务都有获取登录用户的需求,因此拦截器我们可以写在公共部分

定义拦截器:
UserInfoInterceptor.java

public class UserInfoInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.获取请求头中的用户信息
        String userId = request.getHeader(AuthConstants.HEADER_USER_ID);
        // 2.判断是否为空
        if (StringUtils.isNotBlank(userId)) {
            // 不为空,保存到ThreadLocal
            UserContext.setUser(Long.valueOf(userId));
        }
        // 3.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户
        UserContext.removeUser();
    }
}

UserContext.java

public class UserContext {

    private final static ThreadLocal<Long> threadLocal = new ThreadLocal<>();

    public static void setUser(Long userId) {
        threadLocal.set(userId);
    }

    public static void removeUser() {
        threadLocal.remove();
    }

    public static Long getUser() {
        return threadLocal.get();
    }
}

编写SpringMVC的配置类,配置登录拦截器:

package com.zzmall.common.config;
import com.zzmall.common.interceptor.FeignRequestInterceptor;
import com.zzmall.common.interceptor.UserInfoInterceptor;
import feign.RequestInterceptor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@ConditionalOnClass(DispatcherServlet.class)
public class MallWebConfig implements WebMvcConfigurer {

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

    @Bean
    public RequestInterceptor feignRequestInterceptor() {
        return new FeignRequestInterceptor();
    }
}

不过,需要注意的是,这个配置类默认是不会生效的,因为它所在的包,与其它微服务的扫描包不一致,无法被扫描到,因此无法生效。

基于SpringBoot的自动装配原理,我们要将其添加到resources目录下的META-INF/spring.factories文件中:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.zzmall.common.config.MyBatisConfig,\
  com.zzmall.common.config.MvcConfig

4.4.3 OpenFeign传递用户信息

前端发起的请求都会经过网关再到微服务,由于我们之前编写的过滤器和拦截器功能,微服务可以轻松获取登录用户信息。

但有些业务是比较复杂的,请求到达微服务后还需要调用其它多个微服务。比如下单业务,流程如下:

1.下单的过程中,需要调用商品服务扣减库存,调用购物车服务清理用户购物车。而清理购物车时必须知道当前登录的用户身份。但是,订单服务调用购物车时并没有传递用户信息,购物车服务无法知道当前用户是谁!

2.由于微服务获取用户信息是通过拦截器在请求头中读取,因此要想实现微服务之间的用户信息传递,就必须在微服务发起调用时把用户信息存入请求头。

微服务之间调用是基于OpenFeign来实现的,并不是我们自己发送的请求。我们如何才能让每一个由OpenFeign发起的请求自动携带登录用户信息呢?

这里要借助Feign中提供的一个拦截器接口:feign.RequestInterceptor

public interface RequestInterceptor {

  /**
   * Called for every request. 
   * Add data using methods on the supplied {@link RequestTemplate}.
   */
  void apply(RequestTemplate template);
}

我们只需要实现这个接口,然后实现apply方法,利用RequestTemplate类来添加请求头,将用户信息保存到请求头中。这样以来,每次OpenFeign发起请求的时候都会调用该方法,传递用户信息。

然后在Feign配置文件中添加一个@Bean

public class FeignRequestInterceptor implements RequestInterceptor {

    /**
     * 微服务之间调用时header传递
     *
     * @param requestTemplate
     */
    @Override
    public void apply(RequestTemplate requestTemplate) {
        // 获取登录用户
        Long userId = UserContext.getUser();
        if (userId == null) {
            // 如果为空则直接跳过
            return;
        }
        // 如果不为空则放入请求头中,传递给下游微服务
        requestTemplate.header(AuthConstants.HEADER_USER_ID, userId.toString());
    }
}

之后把 FeignRequestInterceptor 加入到配置管理类中,上面已经配置就不重复说明了。

这样我们既能在网关处校验token,同时还能把用户信息从网关传递给微服务,微服务之间也能通过OpenFeign拦截器传递用户信息。

5.网关鉴权

微服务架构下的鉴权一般分为两种:

  • 每个服务各自鉴权
  • 网关统一鉴权

方案一是各自的微服务定义拦截器,对某些限制资源进行权限校验。

方案二由网关统一鉴权,这个可以注册全局过滤器或者拦截器进行精细化处理。

可参考: sa-token 的 网关统一鉴权

6.内部服务外网隔离

我们的子服务一般不能通过外网直接访问,必须通过网关转发才是一个合法的请求,这种子服务与外网的隔离一般分为两种:

物理隔离:子服务部署在指定的内网环境中,只有网关对外网开放

逻辑隔离:子服务与网关同时暴露在外网,但是子服务会有一个权限拦截层保证只接受网关发送来的请求,绕过网关直接访问子服务会被提示:无效请求

这种鉴权需求牵扯到两个环节:** 网关转发鉴权** 、 服务间内部调用鉴权

这里有两种解决方案:

  • 使用 OAuth2.0 模式的凭证式,将 Client-Token 用作各个服务的身份凭证进行权限校验
  • 使用 Same-Token 模块提供的身份校验能力,完成服务间的权限认证

具体参考地址: Same-Token

7.小结

0

评论区