1、gateway filter的生命周期
Spring Cloud Gateway同zuul类似,有“pre”和“post”两种方式的filter。客户端的请求先经过“pre”类型的filter,然后将请求转发到具体的业务服务,收到业务服务的响应之后,再经过“post”类型的filter处理,最后返回响应到客户端
2、gateway filter的应用场景
1、引入依赖和application.yml配置文件
引入依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
<version>3.0.7</version>
</dependency>
<dependency>
<groupId>com.mdx</groupId>
<artifactId>mdx-shop-common</artifactId>
<version>1.0.0</version>
</dependency>
application.yml配置
server:
port: 9010
spring:
application:
name: mdx-shop-gateway
cloud:
nacos:
discovery:
server-addr: localhost:8848
namespace: mdx
group: mdx
gateway:
discovery:
locator:
enabled: true #开启通过服务中心的自动根据 serviceId 创建路由的功能
gateway:
routes:
config:
data-id: gateway-routes #动态路由
group: shop
namespace: mdx
注:
2、创建自定义全局过滤器
新建自定义filter类,需要实现GlobalFilter, Ordered类
其中GlobalFilter是gateway的全局过滤类
他的实现类如下:
Ordered类是过滤器的执行级别,数值越小执行顺序越靠前
MdxAuthFilter完整代码
注:先简单的模拟了一个token验证的流程
package com.mdx.gateway.filter;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
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.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
/**
* @author : jiagang
* @date : Created in 2022/8/8 15:30
*/
@Component
@Slf4j
public class MdxAuthFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
log.info("=========================请求进入filter=========================");
// 模拟token验证
String token = exchange.getRequest().getHeaders().getFirst("token");
if (!"token123".equals(token)){
log.error("token验证失败...");
return writeResponse(exchange.getResponse(),401,"token验证失败");
}
log.info("token验证成功...");
return chain.filter(exchange);
}
/**
* 值越小执行顺序越靠前
* @return
*/
@Override
public int getOrder() {
return 0;
}
/**
* 构建返回内容
*
* @param response ServerHttpResponse
* @param code 返回码
* @param msg 返回数据
* @return Mono
*/
protected Mono<Void> writeResponse(ServerHttpResponse response, Integer code, String msg) {
JSONObject message = new JSONObject();
message.put("code", code);
message.put("msg", msg);
byte[] bits = message.toJSONString().getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(bits);
response.setStatusCode(HttpStatus.OK);
// 指定编码,否则在浏览器中会中文乱码
response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
return response.writeWith(Mono.just(buffer));
}
}
writeResponse是对返回错误信息结果的封装
这里有个比较容易犯错的点,我们通常会在业务系统中建立全局异常处理器,来建立友好的错误返回信息,但是对于filter来说是不生效的,因为filter是在controller之前执行的,所全局异常处理器是不生效的
测试
通过访问网关服务路由到其他服务,查看filter反应
我这里是启动了一个网关服务和一个订单服务
访问接口并在请求头添加token(9010端口为网关服务)
可以看到请求是成功经过了过滤器
1、官方解释什么是jwt
JWT(JSON WEB
TOKEN):JSON网络令牌,JWT是一个轻便的安全跨平台传输格式,定义了一个紧凑的自包含的方式在不同实体之间安全传输信息(JSON格式)。它是在Web环境下两个实体之间传输数据的一项标准。实际上传输的就是一个字符串。广义上讲JWT是一个标准的名称;狭义上JWT指的就是用来传递的那个token字符串
2、jwt的结构
3、jwt的作用
由于http协议是无状态的,所以客户端每次访问都是新的请求。这样每次请求都需要验证身份,传统方式是用session+cookie来记录/传输用户信息,而JWT就是更安全方便的方式。它的特点就是简洁,紧凑和自包含,而且不占空间,传输速度快,而且有利于多端分离,接口的交互等等
JWT是一种Token规范,主要面向的还是登录、验证和授权方向,当然也可以用只来传递信息。一般都是存在header里,也可以存在cookie里
3、jwt和token的关系
1、引入jwt依赖
<!-- jwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
2、jwt工具类
package com.mdx.gateway.utils;
import com.mdx.common.utils.LocalDateUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* JWTProvider需要至少提供两个方法,一个用来创建我们的token,另一个根据token获取Authentication。
* provider需要保证Key密钥是唯一的,使用init()构建,否则会抛出异常。
* @author : jiagang
* @date : Created in 2022/2/9 14:12
*/
@Component
@Slf4j
public class JWTProvider {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
/**
* 根据用户信息生成token
*
* @param userName
* @return
*/
public String generateToken(String userName) {
Map<String, Object> claims = new HashMap<>();
claims.put("CLAIM_KEY_USERNAME", userName);
claims.put("CLAIM_KEY_CREATED", new Date());
return generateToken(claims);
}
/**
* 从token中获取登录用户名
* @param token
* @return
*/
public String getUserNameFromToken(String token){
String username;
try {
Claims claims = getClaimsFormToken(token);
// username = claims.getSubject();
username = claims.get("CLAIM_KEY_USERNAME").toString();
} catch (Exception e) {
username = null;
}
return username;
}
/**
* 验证token是否有效
* @param token
* @param userName
* @return
*/
public boolean validateToken(String token,String userName){
String username = getUserNameFromToken(token);
return username.equals(userName) && !isTokenExpired(token);
}
/**
* 判断token是否可以被刷新
* @param token
* @return
*/
public boolean canRefresh(String token){
return !isTokenExpired(token);
}
/**
* 刷新token
* @param token
* @return
*/
public String refreshToken(String token){
Claims claims = getClaimsFormToken(token);
claims.put("CLAIM_KEY_CREATED",new Date());
return generateToken(claims);
}
/**
* 判断token是否失效
* @param token
* @return
*/
private boolean isTokenExpired(String token) {
Date expireDate = getExpiredDateFromToken(token);
return expireDate.before(new Date());
}
/**
* 从token中获取过期时间
* @param token
* @return
*/
public Date getExpiredDateFromToken(String token) {
Claims claims = getClaimsFormToken(token);
return claims.getExpiration();
}
/**
* 从token中获取荷载
* @param token
* @return
*/
private Claims getClaimsFormToken(String token) {
Claims claims = null;
try {
claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
e.printStackTrace();
}
return claims;
}
/**
* 根据荷载生成JWT TOKEN
*
* @param claims
* @return
*/
private String generateToken(Map<String, Object> claims) {
return Jwts.builder()
.setSubject(claims.get("CLAIM_KEY_USERNAME").toString())
.setClaims(claims)
.setExpiration(generateExpirationDate())
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
/**
* 生成token失效时间
*
* @return
*/
private Date generateExpirationDate() {
// 向后推7天
return new Date(System.currentTimeMillis() + expiration * 1000);
}
public static void main(String[] args) {
Date date = new Date(System.currentTimeMillis() + 604800 * 1000);
String s = LocalDateUtil.dateToString(date, "yyyy-MM-dd HH:mm:ss");
System.out.println(s);
}
}
2、配置文件
# 这个是 jwt 的配置
jwt:
tokenHeader: Authorization
secret: mdx-secrt000001
expiration: 604800 #秒 7天
prefix: Bearer
1、引入mysql和jpa依赖(redis也会用到)
注:此文章是连载文章,因为之前没有用到mysql,在这里登录要查数据库,所以在这里引入mysql
SpringCloud Alibaba 入门可以从这里看:
springcloud alibaba微服务工程搭建(保姆级)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.0.9</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
注意: 因为gateway模块引用了webflux,webflux是无法使用mysql的,所以如果你的mysql是放在了最父级的pom中,启动gateway是会报错的,所以建议将mysql相关依赖放到其他子模块,或者可以在gateway启动类增加注解:@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
2、配置文件
server:
port: 9090
spring:
application:
name: mdx-shop-user
datasource:
type: com.alibaba.druid.pool.DruidDataSource
url: jdbc:mysql://127.0.0.1:3306/mdx_shop?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
driverClassName: com.mysql.cj.jdbc.Driver
username: root
password: Bendi+Ceshi+
cloud:
nacos:
discovery:
server-addr: localhost:8848
namespace: mdx
group: mdx
sentinel:
transport:
dashboard: localhost:8080 #配置Sentinel dashboard地址
port: 8719
redis:
database: 0
host: localhost
port: 6379
jedis:
pool:
max-active: 100
max-idle: 3
max-wait: -1
min-idle: 0
timeout: 2000
feign:
sentinel:
enabled: true
# 这个是 jwt 的配置
jwt:
tokenHeader: Authorization
secret: mdx-secrt000001
expiration: 604800 #秒
prefix: Bearer
3、表结构
/*
Navicat Premium Data Transfer
Source Server : 本地2
Source Server Type : MySQL
Source Server Version : 50724
Source Host : localhost:3306
Source Schema : mdx_shop
Target Server Type : MySQL
Target Server Version : 50724
File Encoding : 65001
Date: 09/08/2022 10:07:37
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for mdx_user
-- ----------------------------
DROP TABLE IF EXISTS `mdx_user`;
CREATE TABLE `mdx_user` (
`user_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户id',
`user_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户名',
`password` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '密码',
`nick` varchar(6) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '昵称',
`phone` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '手机号',
`email` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '电子邮件',
`status` int(1) NULL DEFAULT NULL COMMENT '状态 0 启用 1禁用',
`sex` int(1) NULL DEFAULT NULL COMMENT '性别 0 男 1 女',
`remarks` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '个人描述',
`last_login_time` datetime(0) NOT NULL COMMENT '上次登录时间',
`create_time` datetime(0) NOT NULL COMMENT '创建时间',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`user_id`) USING BTREE,
INDEX `idx_phone`(`phone`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of mdx_user
-- ----------------------------
INSERT INTO `mdx_user` VALUES (1, 'admin', '$2a$10$c./nfmokuQSEn1KKbXbGw.AgTyT5a.Hs3O/qaXQ5BTjb8xRivgytK', '管理员', '13612345678', '123456789@qq.com', 0, 18, NULL, '2022-02-08 17:15:11', '2022-02-08 17:15:03', NULL);
SET FOREIGN_KEY_CHECKS = 1;
4、用户登录接口实现
jpa接口
/**
* @author : jiagang
* @date : Created in 2022/2/8 17:01
*/
@Repository
public interface MdxUserRepository extends JpaRepository<MdxUser,Long> {
/**
* 获取用户信息
* @param userName
* @return
*/
MdxUser findByUserName(String userName);
}
service实现类
@Service
public class UserServiceImpl implements UserService {
@Autowired
private OrderFeign orderFeign;
@Autowired
private MdxUserRepository userRepository;
@Autowired
private JWTProvider jwtProvider;
@Autowired
private RedisManager redisManager;
@Value("${jwt.prefix}")
private String prefix;
/**
* 登录
* @param mdxUserDTO
* @return
*/
@Override
public LoginVo login(MdxUserDTO mdxUserDTO) {
MdxUser mdxUser = userRepository.findByUserName(mdxUserDTO.getUserName());
if (mdxUser == null){
throw new BizException("用户不存在");
}
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
// 判断用户名密码是否正确
if (StringUtils.isEmpty(mdxUser.getUserName()) ||
! encoder.matches(mdxUserDTO.getPassword(), mdxUser.getPassword())){
throw new BizException("用户名或者密码错误");
}
// 生成token
String token = jwtProvider.generateToken(mdxUser.getUserName());
// 将token存入redis
redisManager.set(UserConstant.USER_TOKEN_KEY_REDIS + mdxUser.getUserName(),token,604800);
return LoginVo.builder()
.userId(mdxUser.getUserId().toString())
.userName(mdxUser.getUserName())
.token(prefix + " " + token).build();
}
public static void main(String[] args) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String password = encoder.encode("admin");
System.out.println(password);
}
@Override
public String getOrderNo(String userId, String tenantId, HttpServletRequest request) {
return orderFeign.getOrderNo(userId,tenantId, request.getHeader("token"));
}
}
密码加密方法
public static void main(String[] args) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String password = encoder.encode("admin");
System.out.println(password);
}
controller
@Autowired
private UserService userService;
/**
* 登录
* @param mdxUserDTO
* @return
*/
@PostMapping("login")
public CommonResponse<LoginVo> login(@RequestBody MdxUserDTO mdxUserDTO){
return CommonResponse.success(userService.login(mdxUserDTO));
}
1、重新修改我们之前gateway服务中的filter
完成代码如下:
@Component
@Slf4j
public class MdxAuthFilter implements GlobalFilter, Ordered {
@Autowired
private RedisManager redisManager;
@Autowired
private JWTProvider jwtProvider;
@Value("${jwt.tokenHeader}")
private String tokenHeader;
@Value("${jwt.prefix}")
private String prefix;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
log.info("=========================请求进入filter=========================");
// 验证token
String authHeader = exchange.getRequest().getHeaders().getFirst(tokenHeader);
if (authHeader != null && authHeader.startsWith(prefix)){
String authToken = authHeader.substring(prefix.length());
String userName = jwtProvider.getUserNameFromToken(authToken);
// 查询redis
Object token = redisManager.get(UserConstant.USER_TOKEN_KEY_REDIS + userName);
if (token == null){
log.error("token验证失败或已过期...");
return writeResponse(exchange.getResponse(),401,"token验证失败或已过期...请重新登录");
}
// 这里也可以使用 jwtProvider.validateToken() 来验证token,使用redis是因为管理员可以在任意时间将用户token踢出
// 去除首尾空格
String trimAuthToken = authToken.trim();
if (! trimAuthToken.equals(token.toString())){
log.error("token验证失败或已过期...");
return writeResponse(exchange.getResponse(),401,"token验证失败或已过期...请重新登录");
}
}else {
return writeResponse(exchange.getResponse(),500,"token不存在");
}
log.info("token验证成功...");
return chain.filter(exchange);
}
/**
* 值越小执行顺序越靠前
* @return
*/
@Override
public int getOrder() {
return 0;
}
/**
* 构建返回内容
*
* @param response ServerHttpResponse
* @param code 返回码
* @param msg 返回数据
* @return Mono
*/
protected Mono<Void> writeResponse(ServerHttpResponse response, Integer code, String msg) {
JSONObject message = new JSONObject();
message.put("code", code);
message.put("msg", msg);
byte[] bits = message.toJSONString().getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(bits);
response.setStatusCode(HttpStatus.OK);
// 指定编码,否则在浏览器中会中文乱码
response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
return response.writeWith(Mono.just(buffer));
}
}
2、测试
通过访问gateway服务路由到order服务的接口来看下效果
我们先不传token,访问接口(其中9010端口为gateway服务)
提示token不存在
添加一个正确token,将我们上面登录时获取的token填入即可
成功返回订单服务结果
3、特殊接口放行
在一些实际业务中有一些接口是不用登录的,比如登录接口,注册接口等,所以我们需要对这些接口放行。
我们先试下通过网关访问登录接口,不做特殊接口处理的情况
提示我们token不存在
然后我们对登录接口进行放行
配置文件添加如下配置,一般是将以下配置放到nacos配置中心
# 不用登录就可以访问的接口
allowed:
paths: /mdx-shop-user/user/login
/**
* @author : jiagang
* @date : Created in 2022/8/8 15:30
*/
@Component
@Slf4j
public class MdxAuthFilter implements GlobalFilter, Ordered {
@Autowired
private RedisManager redisManager;
@Autowired
private JWTProvider jwtProvider;
@Value("${jwt.tokenHeader}")
private String tokenHeader;
@Value("${jwt.prefix}")
private String prefix;
@Value("${allowed.paths}")
private String paths; // 不需要登录就能访问的路径
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
log.info("=========================请求进入filter=========================");
ServerHttpRequest request = exchange.getRequest();
String requestPath = request.getPath().toString();
boolean allowedPath = false;
if (paths != null && !paths.equals("")){
allowedPath = StringUtil.checkSkipAuthUrls(requestPath, paths.split(","));
}
if (allowedPath || StringUtils.isEmpty(requestPath)){
return chain.filter(exchange);
}
// 验证token
String authHeader = exchange.getRequest().getHeaders().getFirst(tokenHeader);
if (authHeader != null && authHeader.startsWith(prefix)){
String authToken = authHeader.substring(prefix.length());
String userName = jwtProvider.getUserNameFromToken(authToken);
// 查询redis
Object token = redisManager.get(UserConstant.USER_TOKEN_KEY_REDIS + userName);
if (token == null){
log.error("token验证失败或已过期...");
return writeResponse(exchange.getResponse(),401,"token验证失败或已过期...请重新登录");
}
// 这里也可以使用 jwtProvider.validateToken() 来验证token,使用redis是因为管理员可以在任意时间将用户token踢出
// 去除首尾空格
String trimAuthToken = authToken.trim();
if (! trimAuthToken.equals(token.toString())){
log.error("token验证失败或已过期...");
return writeResponse(exchange.getResponse(),401,"token验证失败或已过期...请重新登录");
}
}else {
return writeResponse(exchange.getResponse(),500,"token不存在");
}
log.info("token验证成功...");
return chain.filter(exchange);
}
/**
* 值越小执行顺序越靠前
* @return
*/
@Override
public int getOrder() {
return 0;
}
/**
* 构建返回内容
*
* @param response ServerHttpResponse
* @param code 返回码
* @param msg 返回数据
* @return Mono
*/
protected Mono<Void> writeResponse(ServerHttpResponse response, Integer code, String msg) {
JSONObject message = new JSONObject();
message.put("code", code);
message.put("msg", msg);
byte[] bits = message.toJSONString().getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(bits);
response.setStatusCode(HttpStatus.OK);
// 指定编码,否则在浏览器中会中文乱码
response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
return response.writeWith(Mono.just(buffer));
}
}
checkSkipAuthUrls工具类方法
/**
* 将通配符表达式转化为正则表达式
*
* @param path
* @return
*/
public static String getRegPath(String path) {
char[] chars = path.toCharArray();
int len = chars.length;
StringBuilder sb = new StringBuilder();
boolean preX = false;
for (int i = 0; i < len; i++) {
if (chars[i] == '*') {// 遇到*字符
if (preX) {// 如果是第二次遇到*,则将**替换成.*
sb.append(".*");
preX = false;
} else if (i + 1 == len) {// 如果是遇到单星,且单星是最后一个字符,则直接将*转成[^/]*
sb.append("[^/]*");
} else {// 否则单星后面还有字符,则不做任何动作,下一把再做动作
preX = true;
continue;
}
} else {// 遇到非*字符
if (preX) {// 如果上一把是*,则先把上一把的*对应的[^/]*添进来
sb.append("[^/]*");
preX = false;
}
if (chars[i] == '?') {// 接着判断当前字符是不是?,是的话替换成.
sb.append('.');
} else {// 不是?的话,则就是普通字符,直接添进来
sb.append(chars[i]);
}
}
}
return sb.toString();
}
public static boolean checkSkipAuthUrls(String reqPath,String[] skipAuthUrls) {
for (String skipAuthUrl:skipAuthUrls) {
if(wildcardEquals(skipAuthUrl, reqPath)) {
return true;
}
}
return false;
}
/**
* 通配符模式
*
* @param skipAuthUrl - 需要跳过的地址
* @param reqPath - 请求地址
* @return
*/
public static boolean wildcardEquals(String skipAuthUrl, String reqPath) {
String regPath = getRegPath(skipAuthUrl);
return Pattern.compile(regPath).matcher(reqPath).matches();
}
再次访问登录接口
可以看到gateway已经放行,成功获取到结果
创作不易,点个赞吧
最后的最后送大家一句话
白驹过隙,沧海桑田
与君共勉
上一篇文章
springcloud gateway的使用 + nacos动态路由
文章持续更新,可以关注下方公众号或者微信搜一搜「 最后一支迷迭香 」第一时间阅读,获取更完整的链路资料。回复【mdx-shop】查看项目源码