一、 Gateway filter应用
一、filter简介
1、gateway filter的生命周期
Spring Cloud Gateway同zuul类似,有“pre”和“post”两种方式的filter。客户端的请求先经过“pre”类型的filter,然后将请求转发到具体的业务服务,收到业务服务的响应之后,再经过“post”类型的filter处理,最后返回响应到客户端
- pre类型的filter:在业务逻辑之前
- post类型的filter:在业务逻辑之后
2、gateway filter的应用场景
- pre类型的过滤器:参数校验、权限校验、流量监控、日志输出、协议转换等
- post类型的过滤器:响应内容、响应头的修改,日志的输出,流量监控等
二、全局过滤器的使用
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
注:
- 配置文件中的相关路由信息已经配置在了nacos的配置中心
- springcloud gateway路由相关和引入gateway依赖和spring-boot-starter-web依赖冲突问题可以先看下面的文章
- springcloud gateway的使用 +
nacos动态路由
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端口为网关服务)
可以看到请求是成功经过了过滤器
测试传递一个错误的token
成功返回错误信息
二、 Gateway filter + JWT实现token拦截
一、jwt简介
1、官方解释什么是jwt
JWT(JSON WEB
TOKEN):JSON网络令牌,JWT是一个轻便的安全跨平台传输格式,定义了一个紧凑的自包含的方式在不同实体之间安全传输信息(JSON格式)。它是在Web环境下两个实体之间传输数据的一项标准。实际上传输的就是一个字符串。广义上讲JWT是一个标准的名称;狭义上JWT指的就是用来传递的那个token字符串
2、jwt的结构
- Header Header 主要包括两部分,分别是Token的类型(typ)和签名算法(alg)
- Payload Payload 表示有效负载,主要`是关于实体和其他数据的声明。主要有三种类型,分别是 registered(已注册信息),
public(公开信息),private(私有信息)
3、jwt的作用
由于http协议是无状态的,所以客户端每次访问都是新的请求。这样每次请求都需要验证身份,传统方式是用session+cookie来记录/传输用户信息,而JWT就是更安全方便的方式。它的特点就是简洁,紧凑和自包含,而且不占空间,传输速度快,而且有利于多端分离,接口的交互等等
JWT是一种Token规范,主要面向的还是登录、验证和授权方向,当然也可以用只来传递信息。一般都是存在header里,也可以存在cookie里
3、jwt和token的关系
- Token是服务器签发的一串加密字符串,是为了给客户端重复访问的一个令牌,作用是为了证明请求者(客户端)的身份,保持用户长期保持登录状态
- JWT就是token的一种载体,或者说JWT是一种标准,而Token是一个概念,而JWT就是这个概念执行的一种规范,通俗点说token就是一串字符串,jwt就是加上类型,信息等数据再加密包装一下成为一个新的token
二、jwt工具类
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
三、登录签发token
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)); }
测试
成功返回jwt token
四、filter拦截token验证,并对特殊接口放行
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填入即可
成功返回订单服务结果
再测试一个错误的token
可以看到返回401
3、特殊接口放行
在一些实际业务中有一些接口是不用登录的,比如登录接口,注册接口等,所以我们需要对这些接口放行。
我们先试下通过网关访问登录接口,不做特殊接口处理的情况
提示我们token不存在
然后我们对登录接口进行放行
配置文件添加如下配置,一般是将以下配置放到nacos配置中心
# 不用登录就可以访问的接口 allowed: paths: /mdx-shop-user/user/login
filter方法新增逻辑
完整代码如下
/** * @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已经放行,成功获取到结果
创作不易,点个赞吧👍
最后的最后送大家一句话
白驹过隙,沧海桑田
与君共勉