授权微服务--后半部分:
注:紧接着上篇cookie没有写入的问题进行分析,地址: 授权微服务–前半部分
2.4 ****解决cookie写入问题
2.4.1 问题分析
我们在之前测试时,清晰的看到了响应头中,有Set-Cookie属性,为什么在这里却什么都没有?
我们之前在讲cors跨域时,讲到过跨域请求cookie生效的条件:
- 服务的响应头中需要携带Access-Control-Allow-Credentials并且为true。
- 响应头中的Access-Control-Allow-Origin一定不能为*,必须是指定的域名
- 浏览器发起ajax需要指定withCredentials 为true
看看我们的服务端cors配置:
没有任何问题。
再看客户端浏览器的ajax配置,我们在js/common.js中对axios进行了统一配置:
一切OK。
那说明,问题一定出在响应的set-cookie头中。我们再次仔细看看刚才的响应头:
我们发现cookie的 domain属性似乎不太对。
cookie也是有域 的限制,一个网页,只能操作当前域名下的cookie,但是现在我们看到的地址是0.0.1,而页面是 www.leyou.com,域名不匹配,cookie设置肯定失败了!
总结:我们通过http://localhost:8087/login
可以看到token信息,但是通过浏览器访问http://www.leyou.com/
与http://api.leyou.com/api/auth/login
却没有写入到cookie中,可以保证我们代码没错,问题出在了路径上。由于路径过了网关和nginx,因此应逐一分析。
Set-cookie中有一个非常关键的点:domain(域),这的domain=localhost,代表的是本机,则证明是可以写的。由于cookie不能跨域,因此localhost只能在localhost下看得见。
2.4.2 跟踪CookieUtils
我们去Debug跟踪CookieUtils,看看到底是怎么回事:
通过http://api.leyou.com/api/auth/login
进行访问,我们发现内部有一个方法(request.getRequestURL()获取请求路径 ),用来获取Domain:
它获取domain是通过服务器的host来计算的,然而我们的地址竟然是:127.0.0.1:8087,因此后续的截取运算,最终得到的domain就变成了:
问题找到了:
- 我们请求时的serverName明明是 api.leyou.com,现在却被变成了:127.0.0.1,如果是api.leyou.com的话,最终就变成了 leyou.com,而leyou.com是所有相关网站的共同后缀,可以供leyou所有网站来访问,现在变成0.0.1,不是方法有问题,而是ServerName有问题,如果ServerName没有问题,最后截取的也自然没问题。因此计算domain是错误的,从而导致cookie设置失败!
2.4.3 解决host地址的变化
那么问题来了:为什么我们这里的请求serverName变成了:127.0.0.1:8087呢?
这里的server name其实就是请求的时的主机名:Host,之所以改变,有两个原因:
-
我们使用了nginx反向代理,当监听到api.leyou.com的时候,会自动将请求转发至192.168.1.109:10010,即Zuul,已经从域名变成了ip,域名已经发生了改变,因此取到的域名也就不对。
要解决这个问题,我们得先知道request.getRequestURL获取URL路径,tomcat是怎么拿到路径的。
我们可以在前台随便打开一个js请求:
上图的js路径在request中被分成了几段:
影响我们得到域名的原因就是Host ! — 于是在nginx反向代理时多设置一个Host头。
我们首先去更改nginx配置,让它不要修改我们的host:
把nginx进行重启:nginx-s reload
这样就解决了nginx这里的问题。 -
但是Zuul还会有一次转发:而后请求到达我们的网关Zuul,Zuul就会根据路径匹配,我们的请求是/api/auth,根据规则被转发到了 127.0.0.1:8087 ,即我们的授权中心,所以要去修改网关的配置。
在网关中有很多过滤器,这些过滤器默认继承自ZuulFilter
我们在run方法打个断点,查询host,调用方法ctx.getRequest().getHeader(“host”),
发现此时的host是 api.leyou.com 是正确的
再结合RequestURI,路径完全正确,说明我们的nginx配置生效了。
理论上此时放行,是不会出现错误的,如果此时出现错误,那就是网关没有将host写进去, 事实上,到此为止并没有将host写进去,原因是要进行一次if判断 :porperties.isAddHostHeader(),成立才将host写入,
点进方法发现AddHostHeader是一个boolean值,值为false,属于ZuulProperties,前缀是zuul,修改这个值很简单
我们在网关配置中将add-host-header的值设置成true
重启后,我们再次测试
最后计算得到的domain:
到这为止都没错。
2.4.4 再次测试
我们再次登录,发现依然没有cookie!!
我们通过RestClient访问下看看:
发现,响应头中根本没有set-cookie了。
这是怎么回事??
2.4.5 Zuul的敏感头过滤
Zuul内部有默认的过滤器,会对请求和响应头信息进行重组,过滤掉敏感的头信息:
会发现,这里会通过一个属性为SensitiveHeaders的属性,来获取敏感头列表,然后添加到IgnoredHeaders中,这些头信息就会被忽略。
而这个SensitiveHeaders的默认值就包含了set-cookie,此时host可以传过去,但是Set-Cookie会被过滤掉
同时,ZuulFilter下还有一个叫RibbonRoutingFilter的过滤器,是做负载均衡路由
它在构建上下文时会获取header,对头进行处理,先拿到原有头信息,对其进行判断,
如果是被允许的头信息,才会将其添加到Headers中去
判断依据是根据头名称进行判断,如果头是被忽略的,则不会被添加。但是下面的switch语句,其中就包含host,因此如果name是host,则会被忽略掉,无论怎样,我们都没有办法把host头添加进去
其实,buildZuulRequestHeaders对头做了两次判断,第一次是从request中拿到所有头信息,一个个判断要不要添加,第二次是从ZuulRequestHeaders中取出信息进行判断。
由于我们刚刚配置了add-host-header=true,PreDecorationFilter过滤器就会把host添加到ZuulRequestHeader中去,因此ZuulRequestHeaders是有host信息的,又通过if判断要不要添加,但是if判断又是刚才那个,因此即便有也会被忽略,host永远添加不进去–BUG–原因是新版本的网关中多加了一个if判断。
解决方案:
- 降低网关版本,改成2.0.0(之前是2.0.1),同时忽略掉2.0.1的版本
- 同时,把敏感头设置为null,表示所有的头都放行
到此为止,所有问题都已解决!
2.4.6 最后的测试
再次重启后测试:
其中,domain=leyoucom。
浏览器访问cookie值也存在,
cookie写入问题到此解决,一切OK!
总结::
- 首先我们认为是nginx问题,它把域名改成了ip地址,改完之后发现还不行;
- 发现网关也有问题,网关又有一次反向代理,又把域名改成了ip;
- 改了网关之后还是不行,发现是版本bug,版本降低OK;
- 之后domain对了,但是cookie没有写回去,是因为它的敏感头把cookie过滤了,把敏感头去掉OK。
3. 首页判断登录状态
虽然cookie已经成功写入,但是我们首页的顶部,登录状态依然没能判断出用户信息:
这里需要向后台发起请求,获取根据cookie获取当前用户的信息。
我们先看页面实现
3.1 页面JS代码
页面的顶部已经被我们封装为一个独立的Vue组件,在/js/pages/shortcut.js中,并且shortcut.js在很多页面中都会有。
打开js,发现里面已经定义好了Vue组件,并且在created函数中,查询用户信息:
查看网络控制台,发现发起了请求:
因为token在cookie中,因此本次请求肯定会携带token信息在头中。
3.2 后台实现校验用户接口
我们在ly-auth-service中定义用户的校验接口,通过cookie获取token,然后校验通过返回用户信息。
- 请求方式:GET
- 请求路径:/verify
- 请求参数:无,不过我们需要从cookie中获取token信息
- 返回结果:UserInfo,校验成功返回用户信息;校验失败,则返回401
代码:
| AuthController:
// 校验用户登录状态
@GetMapping("verify")
public ResponseEntity<UserInfo> verifyUser(@CookieValue("LY_TOKEN") String token) {
try {
// 获取token信息
UserInfo userInfo = JwtUtils.getInfoFromToken(token, prop.getPublicKey());
// 成功后直接返回
return ResponseEntity.ok(userInfo);
} catch (Exception e) {
// 抛出异常,证明token无效,直接返回401
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null);
}
}
注: 获取Cookie不需要request,直接@CookieValue
注解即可。
3.3 测试
页面效果:
3.4 刷新token
每当用户在页面进行新的操作,都应该刷新token的过期时间,否则30分钟后用户的登录信息就无效了。而刷新其实就是重新生成一份token,然后写入cookie即可。
那么问题来了:我们怎么知道用户有操作呢?
事实上,每当用户来查询其个人信息,就证明他正在浏览网页,此时刷新cookie是比较合适的时机。因此我们可以对刚刚的校验用户登录状态的接口进行改进,加入刷新token的逻辑。
// 校验用户登录状态
@GetMapping("verify")
public ResponseEntity<UserInfo> verify(
@CookieValue("LY_TOKEN") String token,
HttpServletResponse response,
HttpServletRequest request
){
try {
// 解析token
UserInfo info = JwtUtils.getInfoFromToken(token, prop.getPublicKey());
// 刷新token,重新生成
String newToken = JwtUtils.generateToken(info, prop.getPrivateKey(), prop.getExpire());
//写入cookie
CookieUtils.newBuilder(response).httpOnly().request(request).build(cookieName, newToken);
//已登录,返回用户信息
return ResponseEntity.ok(info);
} catch (Exception e){
// 没有token token已过期 token被篡改
throw new LyException(ExceptionEnum.NO_AUTHORIZED);
}
}
4. 网关的登录拦截器
接下来,我们在Zuul编写拦截器,对用户的token进行校验,如果发现未登录,则进行拦截。
4.1 引入jwt相关配置
既然是登录拦截,一定是前置拦截器,我们在ly-gateway中定义。
首先引入所需要的依赖:
<dependency>
<groupId>com.leyou.common</groupId>
<artifactId>ly-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.leyou.service</groupId>
<artifactId>ly-auth-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
然后编写属性文件:
ly:
jwt:
pubKeyPath: E:/course/JavaProject/javacode/idea/rsa/rsa.pub # 公钥地址
cookieName: LY_TOKEN # cookie的名称
注:解析token需要公钥,因此只提供公钥地址即可。
编写属性类,读取公钥:
4.2 编写过滤器逻辑
基本逻辑:
- 获取cookie中的token
- 通过JWT对token进行校验
- 通过:则放行;不通过:则重定向到登录页
@Component
@EnableConfigurationProperties({JwtProperties.class, FilterProperties.class})
public class AuthFilter extends ZuulFilter{
@Autowired
private JwtProperties prop;
@Autowired
private FilterProperties filterProperties;
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;//前置过滤器
}
@Override
public int filterOrder() {
return FilterConstants.PRE_DECORATION_FILTER_ORDER - 1;//官方前置过滤器-1,可以把自己定义的过滤器放在官方过滤器之前
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
// 获取上下文 获取request
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();
// 获取token
String token = CookieUtils.getCookieValue(request, prop.getCookieName());
// 解析token
try {
UserInfo user = JwtUtils.getInfoFromToken(token, prop.getPublicKey());
//TODO 权限管理
} catch (Exception e) {
// 解析失败 未登录
ctx.setSendZuulResponse(false);// 拦截功能
ctx.setResponseStatusCode(403);// 返回状态码
}
return null;
}
}
刷新页面,发现请求校验的接口也被拦截了:
证明我们的拦截器生效了,但是,似乎有什么不对的。这个路径似乎不应该被拦截啊!
4.3 白名单
要注意,并不是所有的路径我们都需要拦截,例如:
- 登录校验接口:/auth/**
- 注册接口:/user/register
- 数据校验接口:/user/check/**
- 发送验证码接口:/user/code
- 搜索接口:/search/**
另外,跟后台管理相关的接口,因为我们没有做登录和权限,因此暂时都放行,但是生产环境中要做登录校验:
- 后台商品服务:/item/**
所以,我们需要在拦截时,配置一个白名单,如果在名单内,则不进行拦截。
在application.yaml中添加规则:
filter:
allowPaths:
- /api/auth
- /api/search
- /api/user/register
- /api/user/check
- /api/user/code
- /api/item
- /api/cart
然后读取这些属性:
@Data
@ConfigurationProperties(prefix = "ly.filter")
public class FilterProperties {
// 并不是所有的路径都拦截,比如:不需要登录也可以浏览商品
private List<String> allowPaths;
}
在过滤器中的shouldFilter方法中添加判断逻辑:
代码:
@Component
@EnableConfigurationProperties({JwtProperties.class, FilterProperties.class})
public class AuthFilter extends ZuulFilter{
@Autowired
private JwtProperties prop;
@Autowired
private FilterProperties filterProperties;
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;//前置过滤器
}
@Override
public int filterOrder() {
return FilterConstants.PRE_DECORATION_FILTER_ORDER - 1;//官方前置过滤器-1,可以把自己定义的过滤器放在官方过滤器之前
}
@Override
public boolean shouldFilter() {
// 获取上下文以及request
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();
// 获取请求的URL路径
String path = request.getRequestURI();
// 判断是否在白名单内 如果在 则放行
return !isAllowPath(path);//是否过滤
}
private boolean isAllowPath(String path) {
List<String> allowPaths = filterProperties.getAllowPaths();
for (String allowPath : allowPaths) {
if(path.startsWith(allowPath)){
return true;
}
}
return false;
}
@Override
public Object run() throws ZuulException {
// 获取上下文 获取request
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();
// 获取token
String token = CookieUtils.getCookieValue(request, prop.getCookieName());
// 解析token
try {
UserInfo user = JwtUtils.getInfoFromToken(token, prop.getPublicKey());
//TODO 权限管理
} catch (Exception e) {
// 解析失败 未登录
ctx.setSendZuulResponse(false);// 拦截功能
ctx.setResponseStatusCode(403);// 返回状态码
}
return null;
}
}
再次测试:
4.4 可优化的点
授权登录还需要完善:
- 需要引入权限控制系统
- 在AuthFilter中,应该判断权限
- 授权中心还可以做服务鉴权