Spring Cloud系列-Zuul网关集成JWT身份验证
前言
这两三年项目中一直在使用比较流行的spring cloud框架,也算有一定积累,打算有时间就整理一些干货与大家分享。
本次分享zuul网关集成jwt身份验证
业务背景
项目开发少不了身份认证,jwt作为当下比较流行的身份认证方式之一主要的特点是无状态,把信息放在客户端,服务器端不需要保存session,适合分布式系统使用。
把jwt集成在网关的好处是业务工程不需要关心身份验证,专注业务逻辑(网关可验证token后,把解析出来的身份信息如userid,放在请求头传递给业务工程)。
顺便分享下如何自定义zuul拦截器
代码详解
一、jwtutil
为了方便,先封装好jwtutil,主要包含两个方法,创建token和解析(并验证)token
这里引用了第三方的包jjwt,简单好用,maven依赖如下
<dependency> <groupid>io.jsonwebtoken</groupid> <artifactid>jjwt</artifactid> <version>0.9.1</version> </dependency>
jwtutil封装如下
@component public class jwtutil { /** * 签名用的密钥 */ private static final string signing_key = "78sebr72umyz33i9876gc31urjgyfhgj"; /** * 用户登录成功后生成jwt * 使用hs256算法 * * @param exp jwt过期时间 * @param claims 保存在payload(有效载荷)中的内容 * @return token字符串 */ public string createjwt(date exp, map<string, object> claims) { //指定签名的时候使用的签名算法 signaturealgorithm signaturealgorithm = signaturealgorithm.hs256; //生成jwt的时间 long nowmillis = system.currenttimemillis(); date now = new date(nowmillis); //创建一个jwtbuilder,设置jwt的body jwtbuilder builder = jwts.builder() //保存在payload(有效载荷)中的内容 .setclaims(claims) //iat: jwt的签发时间 .setissuedat(now) //设置过期时间 .setexpiration(exp) //设置签名使用的签名算法和签名使用的秘钥 .signwith(signaturealgorithm, signing_key); return builder.compact(); } /** * 解析token,获取到payload(有效载荷)中的内容,包括验证签名,判断是否过期 * * @param token * @return */ public claims parsejwt(string token) { //得到defaultjwtparser claims claims = jwts.parser() //设置签名的秘钥 .setsigningkey(signing_key) //设置需要解析的token .parseclaimsjws(token).getbody(); return claims; } }
二、自定义拦截器说明
继承自zuulfilter,并注册到spring容器即可实现自定义拦截器,实现身份认证、参数校验、参数传递等功能
@component public class customfilter extends zuulfilter { /** * filtertype:过滤器类型 * <p> * pre:路由之前 * routing:路由之时 * post: 路由之后 * error:发送错误调用 * * @return */ @override public string filtertype() { return filterconstants.pre_type; // return filterconstants.post_type; } /** * filterorder:过滤的顺序 序号配置可参照 https://blog.csdn.net/u010963948/article/details/100146656 * * @return */ @override public int filterorder() { return 0; } /** * shouldfilter:判断是否要执行过滤 * * @return true表示需要过滤,将对该请求执行run方法 */ @override public boolean shouldfilter() { return true; } /** * run:具体过滤的业务逻辑,可做身份验证,校验参数等等 * * @return */ @override public object run() throws zuulexception { //获取请求上下文对象 requestcontext ctx = requestcontext.getcurrentcontext(); //获取request对象 httpservletrequest request = ctx.getrequest(); //获取response对象 httpservletresponse response = ctx.getresponse(); //添加请求头,传递到业务服务 ctx.addzuulrequestheader("xxx", "xxx"); //添加响应头,返回给前端 ctx.addzuulresponseheader("xxx", "xxx"); return null; } }
三、loginaddjwtpostfilter,拦截登录方法,登录成功时创建token,返回给前端
要点:
- 拦截类型是filterconstants.post_type,在路由方法响应之后拦截
- 判断请求的uri是否是登录接口(与配置文件中设置的登录uri是否匹配),需要在配置文件配置登录接口地址
- 判断登录方法返回成功,创建token,并添加到 response body或response header,返回给前端
@component @slf4j public class loginaddjwtpostfilter extends zuulfilter { @autowired objectmapper objectmapper; @autowired jwtutil jwtutil; @autowired datafilterconfig datafilterconfig; /** * pre:路由之前 * routing:路由之时 * post: 路由之后 * error:发送错误调用 * * @return */ @override public string filtertype() { return filterconstants.post_type; } /** * filterorder:过滤的顺序 * * @return */ @override public int filterorder() { return filterconstants.send_response_filter_order - 2; } /** * shouldfilter:这里可以写逻辑判断,是否要过滤 * * @return */ @override public boolean shouldfilter() { //路径与配置的相匹配,则执行过滤 requestcontext ctx = requestcontext.getcurrentcontext(); for (string pathpattern : datafilterconfig.getuserloginpath()) { if (pathutil.ispathmatch(pathpattern, ctx.getrequest().getrequesturi())) { return true; } } return false; } /** * 执行过滤器逻辑,登录成功时给响应内容增加token * * @return */ @override public object run() { requestcontext ctx = requestcontext.getcurrentcontext(); try { inputstream stream = ctx.getresponsedatastream(); string body = streamutils.copytostring(stream, standardcharsets.utf_8); result<hashmap<string, object>> result = objectmapper.readvalue(body, new typereference<result<hashmap<string, object>>>() { }); //result.getcode() == 0 表示登录成功 if (result.getcode() == 0) { hashmap<string, object> jwtclaims = new hashmap<string, object>() {{ put("userid", result.getdata().get("userid")); }}; date expdate = datetime.now().plusdays(7).todate(); //过期时间 7 天 string token = jwtutil.createjwt(expdate, jwtclaims); //body json增加token result.getdata().put("token", token); //序列化body json,设置到响应body中 body = objectmapper.writevalueasstring(result); ctx.setresponsebody(body); //响应头设置token ctx.addzuulresponseheader("token", token); } } catch (exception e) { e.printstacktrace(); } return null; } }
四、jwtauthprefilter,拦截业务接口,验证token
要点:
- 拦截类型是filterconstants.pre_type,在调用业务接口之前拦截
- 判断请求的uri是否是需要身份验证的接口(与配置文件中设置的uri是否匹配),需要在配置文件配置业务接口地址
- 判断token验证是否通过,通过则路由,不通过返回错误提示
@component @slf4j public class jwtauthprefilter extends zuulfilter { @autowired objectmapper objectmapper; @autowired jwtutil jwtutil; @autowired datafilterconfig datafilterconfig; /** * pre:路由之前 * routing:路由之时 * post: 路由之后 * error:发送错误调用 * * @return */ @override public string filtertype() { return filterconstants.pre_type; } /** * filterorder:过滤的顺序 序号配置可参照 https://blog.csdn.net/u010963948/article/details/100146656 * * @return */ @override public int filterorder() { return 2; } /** * shouldfilter:逻辑是否要过滤 * * @return */ @override public boolean shouldfilter() { //路径与配置的相匹配,则执行过滤 requestcontext ctx = requestcontext.getcurrentcontext(); for (string pathpattern : datafilterconfig.getauthpath()) { if (pathutil.ispathmatch(pathpattern, ctx.getrequest().getrequesturi())) { return true; } } return false; } /** * 执行过滤器逻辑,验证token * * @return */ @override public object run() { requestcontext ctx = requestcontext.getcurrentcontext(); httpservletrequest request = ctx.getrequest(); string token = request.getheader("token"); claims claims; try { //解析没有异常则表示token验证通过,如有必要可根据自身需求增加验证逻辑 claims = jwtutil.parsejwt(token); log.info("token : {} 验证通过", token); //对请求进行路由 ctx.setsendzuulresponse(true); //请求头加入userid,传给业务服务 ctx.addzuulrequestheader("userid", claims.get("userid").tostring()); } catch (expiredjwtexception expiredjwtex) { log.error("token : {} 过期", token ); //不对请求进行路由 ctx.setsendzuulresponse(false); responseerror(ctx, -402, "token expired"); } catch (exception ex) { log.error("token : {} 验证失败" , token ); //不对请求进行路由 ctx.setsendzuulresponse(false); responseerror(ctx, -401, "invalid token"); } return null; } /** * 将异常信息响应给前端 */ private void responseerror(requestcontext ctx, int code, string message) { httpservletresponse response = ctx.getresponse(); result errresult = new result(); errresult.setcode(code); errresult.setmessage(message); ctx.setresponsebody(tojsonstring(errresult)); response.setcharacterencoding(standardcharsets.utf_8.name()); response.setcontenttype("application/json;charset=utf-8"); } private string tojsonstring(object o) { try { return objectmapper.writevalueasstring(o); } catch (jsonprocessingexception e) { log.error("json序列化失败", e); return null; } } }
五、配置文件和路径匹配
在配置文件application.yml中配置登录接口路径 和 业务接口(需要身份验证的接口)路径,可配置多个,可使用通配符(基于ant path匹配)
data-filter: auth-path: #需要验证token的请求地址,可设置多个,会触发jwtauthprefilter - /business/data/** - /business/report/** user-login-path: #登录请求地址,可设置多个,会触发loginaddjwtpostfilter - /business/login/**
pathutil,封装路径匹配方法,用于判断请求的接口是否是需要拦截的接口
public class pathutil { private static antpathmatcher matcher = new antpathmatcher(); public static boolean ispathmatch(string pattern, string path) { return matcher.match(pattern, path); } }
请求测试
一、测试登录接口
请求登录接口 http://localhost:8040/business/login/loginbypassword
看到响应body和header里都有了token:eyjhbgcioijiuzi1nij9.eyjlehaioje1nzyzmta3mdgsinvzzxjjzci6ijewmdeilcjpyxqioje1nzu3mdu5mdl9.06mmrkgs5mk3nw5m6eaqtkkbvixqccpg33nx1af5zfw
把token的第二段 eyjlehaioje1nzyzmta3mdgsinvzzxjjzci6ijewmdeilcjpyxqioje1nzu3mdu5mdl9 使用base64解码
可以看到明文{"exp":1576310708,"userid":"1001","iat":1575705909}
包含了过期时间、用户id、签发时间
二、测试业务接口
请求业务接口 http://localhost:8040/business/data/getdata 请求头不传token或传错误的token
可以看到返回了错误信息
{
"code": -401,
"message": "invalid token",
"data": null
}
请求业务接口 http://localhost:8040/business/data/getdata 传入正确的token
可以看到返回了业务数据,说明已经请求到了业务接口,验证成功