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的秘钥,不安全
- 每个微服务重复编写登录校验代码、权限校验代码,麻烦
既然网关是所有微服务的入口,一切请求都需要先经过网关。我们完全可以把登录校验的工作放到网关去做,这样之前说的问题就解决了:
- 只需要在网关和用户服务保存秘钥
- 只需要在网关开发登录校验功能
此时,登录校验的流程如图:
不过,这里任然存在几个问题:
- 网关路由是配置的,请求转发是Gateway内部代码,我们如何在转发登录校验?
- 网关校验JWT之后,如何将用户信息传递给微服务?
- 微服务之间也会相互调用,这种调用不经过网关,又该如何传递用户信息?
4.2 网关过滤器
登录校验必须在请求转发到微服务之前做,否则就失去了意义。而网关的请求转发是Gateway内部代码实现的,要想在请求转发之前做登录校验,就必须了解Gateway内部工作的基本原理。
如图所示:
- 1.客户端请求进入网关后由
HandlerMapping
对请求做判断,找到与当前请求匹配的路由规则(Route
),然后将请求交给WebHandler去处理。 - 2.
WebHandler
则会加载当前路由下需要执行的过滤器链(Filter chain
),然后按照顺序逐一执行过滤器(后面称为Filter)。 - 3.图中
Filter
被虚线分为左右两部分,是因为Filter
内部的逻辑分为pre
和post
两部分,分别会在请求路由到微服务之前和之后被执行。 - 4.只有所有
Filter
的pre
逻辑都依次顺序执行通过后,请求才会被路由到微服务。 - 5.微服务返回结果后,再倒序执行
Filter
的post
逻辑。 - 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,方便后续使用。
据图流程图如下:
因此,接下来我们要做的事情有:
- 改造网关过滤器,在获取用户信息后保存到请求头,转发到下游微服务
- 编写微服务拦截器,拦截请求获取用户信息,保存到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
评论区