已经将近三个月没写博客了,实在是没什么时间去写,今天来继续完善我的脚手架吧,以后尽量加快更新速度哈哈哈。
前言:今天来完善我们的鉴权逻辑,至于之前在RBAC那篇文章写的通过鉴权中心的方案,另外开一个鉴权服务有点复杂,要设置各种回调机制、熔断机制、失败重试机制等等,所以我们在这里的方案采用的是:在网关处鉴权。
Step1: 引入JWT相关依赖。
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>${jjwt.version}</version>
</dependency>
我们使用的是jjwt这个Token工具依赖,直接把这个依赖加入到公共core模块中,以便于全局都能够调用。
Step2:Gateway模块构建JWT所需配置的配置类
首先,我们要知道Token在系统中的作用:
①身份验证:客户端向服务端提交请求时,通常会在请求的头部附带Token来验证用户的身份,通过有效的Token,服务端可以知道该请求来自哪个用户。
②会话管理:由于HTTP协议是无状态的,为了跟踪用户的会话状态,服务端可以返回一个Session Token到客户端,后续客户端的每次请求都会带上这个Token,以此来确认用户的会话信息。
③安全性:使用Token认证的方式,避免了用户密码在网络间传输,增加了系统的安全性。此外,Token还可以设置过期时间,增加系统防止恶意攻击的能力。
知道以上几点后,我们可以开始构建我们的Token认证体系,由于鉴权是个非常频繁的操作,我们采用了Redis缓存进行验证,所以我们需要以下几个必要的属性:
①Token加密秘钥:tokenKey
②请求头携带token指定的名称:tokenHeaderName
③token过期时间:tokenExpiration
以上几个变量名都可以随便取,代码如下:(下面的代码可能会多出几个其它属性,我们这里只关注上面提到的这三个)
package com.lt.gateway.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Configuration;
import java.util.Set;
/**
* 网关配置类。(支持热更新)
*
* @Author ToneyMa
* @Date 2023-09-27
**/
@Data
@RefreshScope
@ConfigurationProperties(prefix = "application")
@Configuration
public class ApplicationConfig {
/**
* token加密用的秘钥,长度至少为10个字符。(过短会报错)
*/
private String tokenKey;
/**
* 请求头携带token指定的name。
*/
private String tokenHeaderName;
/**
* token过期时间。
*/
private Long tokenExpiration;
/**
* 授信ip列表,不填表示全部信任,多ip用逗号分隔。
*/
private String credentialIpList;
/**
* Session会话和用户权限在Redis中的过期时间(秒)。
* 缺省值是 one day
*/
private int sessionExpiredSeconds = 86400;
/**
* 基于全等的url白名单地址集合,过滤效率高于whitelistUrlPattern。
*/
private Set<String> whiteListUrl;
/**
* 基于Ant Pattern模式判定规则的白名单地址集合。如:/aa/**。
*/
private Set<String> whitelistUrlPattern;
}
此时,我们只需要在yml配置文件中写入相应的配置即可。
spring: application: name: gateway tokenKey: ltTravelTokenKey tokenHeaderName: Auth-token
Step3:编写网关业务常量。
在网关中,我们需要一些和业务有关的常量,有些无需鉴权的接口需要跳过鉴权,如登录接口、获取短信验证码URL、校验短信验证码URL、找回密码URL、SessionKey等等。
package com.lt.gateway.constant;
/**
* GatewayConstant,网关业务常量。
*
* @Author ToneyMa
* @Date 2023-09-28
**/
public final class GatewayConstant {
/**
* 请求进入网关的开始时间。
*/
public static final String START_TIME_ATTRIBUTE = "startTime";
/**
* 登录URL。
*/
public static final String ADMIN_LOGIN_URL = "/admin/upms/login/doLogin";
/**
* 获取短信验证码URL。
*/
public static final String ADMIN_VERIFICATION_CODE_URL = "/admin/upms/login/verificationCode";
/**
* 校验短信验证码URL。
*/
public static final String ADMIN_CHECK_VERIFICATION_CODE_URL = "/admin/upms/login/checkVerificationCode";
/**
* 手机号验证码登录URL。
*/
public static final String ADMIN_LOGIN_BY_PHONE_URL = "/admin/upms/login/doLoginByPhone";
/**
* 找回密码URL。
*/
public static final String ADMIN_FORGET_PASSWORD_URL = "/admin/upms/login/forgetPassword";
/**
* 登出URL。
*/
public static final String ADMIN_LOGOUT_URL = "/admin/upms/login/doLogout";
/**
* sessionId的键名称。
*/
public static final String SESSION_ID_KEY_NAME = "sessionId";
/**
* 私有构造函数,明确标识该常量类的作用。
*/
private GatewayConstant() {
}
}
Step4:编写认证前置过滤器。
Gateway给我们提供了一个GlobalFilter过滤器接口,通过实现该接口就可以实现自定义网关过滤器,我们就在这个网关过滤器中编写鉴权代码。
在这里我们注入我们的配置类和Redisson。
package com.lt.gateway.filter;
import com.lt.gateway.config.ApplicationConfig;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RedissonClient;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import javax.annotation.Resource;
/**
* 全局前端过滤器
*
* @Author: ToneyMa
* @Date: 2023-09-28
**/
@Slf4j
public class AuthenticationPreFilter implements GlobalFilter, Ordered {
@Resource
private ApplicationConfig config;
@Resource
private RedissonClient redissonClient;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return null;
}
@Override
public int getOrder() {
return HIGHEST_PRECEDENCE + 10000;
}
}
上面这段代码,是一个空过滤器示例代码,下面这段是我写好的一段基于我开发的权限过滤逻辑的前置过滤器代码,其中JWT生成方法是我自己写的一个工具类(我会将此工具类放在最后),仅供参考。
Step5:完善网关鉴权前置过滤器(判断请求&获取或生成Token&生成包含了用户信息的传递至下游服务的Request)。
/**
* 全局前端过滤器
*
* @Author: ToneyMa
* @Date: 2023-09-28
**/
@Slf4j
public class AuthenticationPreFilter implements GlobalFilter, Ordered {
@Resource
private ApplicationConfig config;
@Resource
private RedissonClient redissonClient;
/**
* Ant Pattern模式的白名单地址匹配器。
*/
private final AntPathMatcher antMatcher = new AntPathMatcher();
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 获取请求对象
ServerHttpRequest request = exchange.getRequest();
// 获取响应对象
ServerHttpResponse response = exchange.getResponse();
// 获取请求地址
String url = request.getURI().getPath();
// 获取token信息
String token = this.getTokenFromRequest(request);
// 是否登录标记。
boolean noNeedLoginUrl = false;
// 判断是否为白名单请求,以及一些内置不需要验证的请求。(登录请求也包含其中)。
// 如果当前请求中包含token令牌不为空的时候,也会继续验证Token的合法性,这样就能保证
// Token中的用户信息被业务接口正常访问到了。而如果当token为空的时候,白名单的接口可以
// 被网关直接转发,无需登录验证。当然被转发的接口,也无法获取到用户的token身份数据了。
if (this.shouldNotFilter(url)) {
noNeedLoginUrl = true;
if (StringUtils.isBlank(token)) {
return chain.filter(exchange);
}
}
// 解析令牌
Claims c = JwtUtil.parseToken(token, config.getTokenSignKey());
// 判断是否过期或为空
if (JwtUtil.isNullOrExpired(c)) {
log.warn("EXPIRED request [{}] from REMOTE-IP [{}].", url, IpUtil.getRemoteIpAddress(request));
// 设置响应状态码
response.setStatusCode(HttpStatus.UNAUTHORIZED);
// 设置响应类型
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
// 响应结果转化为字节数组
byte[] responseBody = JSON.toJSONString(ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN,
"用户登录已过期或尚未登录,请重新登录!")).getBytes(StandardCharsets.UTF_8);
return response.writeWith(Flux.just(response.bufferFactory().wrap(responseBody)));
}
// 这里判断是否需要定时刷新token
if (JwtUtil.needToRefresh(c)) {
// 如果需要则把配置好的刷新后的Header中的tokenName和tokenValue设置到响应头中。
exchange.getAttributes().put(config.getRefreshedTokenHeaderKey(),
JwtUtil.generateToken(c, config.getTokenExpiration(), config.getTokenSignKey()));
}
// 获取令牌中的sessionId
String sessionId = (String) c.get(GatewayConstant.SESSION_ID_KEY_NAME);
// 生成sessionId在Redis中对应的key
String sessionIdKey = RedisKeyUtil.makeSessionIdKey(sessionId);
// 获取Redis中的session数据
RBucket<String> sessionData = redissonClient.getBucket(sessionIdKey);
JSONObject tokenData = null;
// 如果session存在,则获取session中的数据
if (sessionData.isExists()) {
tokenData = JSONObject.parseObject(sessionData.get());
}
// 如果TokenData不存在,则打印日志,并写出错误信息
if (ObjectUtil.isEmpty(tokenData)) {
log.warn("UNAUTHORIZED request [{}] from REMOTE-IP [{}] because no sessionId exists in redis.",
url, IpUtil.getRemoteIpAddress(request));
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
byte[] responseBody = JSON.toJSONString(ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN,
"用户会话已失效,请重新登录!")).getBytes(StandardCharsets.UTF_8);
// 写出响应
return response.writeWith(Flux.just(response.bufferFactory().wrap(responseBody)));
}
// 获取userId
String userId = tokenData.getString("userId");
if (StringUtils.isBlank(userId)) {
log.warn("UNAUTHORIZED request [{}] from REMOTE-IP [{}] because userId is empty in redis.",
url, IpUtil.getRemoteIpAddress(request));
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
byte[] responseBody = JSON.toJSONString(ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN,
"用户登录验证信息已过期,请重新登录!")).getBytes(StandardCharsets.UTF_8);
return response.writeWith(Flux.just(response.bufferFactory().wrap(responseBody)));
}
// 获取昵称
String showName = tokenData.getString("showName");
// 因为http header中不支持中文传输,所以需要编码。
try {
URLEncoder.encode(showName, StandardCharsets.UTF_8.name());
tokenData.put("showName", showName);
} catch (UnsupportedEncodingException e) {
log.error("Failed to call AuthenticationPreFilter.filter.", e);
}
// 判断是否为管理员
boolean isAdmin = tokenData.getBoolean("isAdmin");
// 如果不是无需登录的url 并且 不是管理员 并且 对当前url没有权限,则写出无权限错误。
if (!noNeedLoginUrl && Boolean.FALSE.equals(isAdmin) && !this.hasPermission(redissonClient, sessionId, url)) {
log.warn("FORBIDDEN request [{}] from REMOTE-IP [{}] for USER [{} -- {}] no perm!",
url, IpUtil.getRemoteIpAddress(request), userId, showName);
response.setStatusCode(HttpStatus.FORBIDDEN);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
byte[] responseBody = JSON.toJSONString(ResponseResult.error(ErrorCodeEnum.NO_OPERATION_PERMISSION,
"用户对该URL没有访问权限,请核对!")).getBytes(StandardCharsets.UTF_8);
return response.writeWith(Flux.just(response.bufferFactory().wrap(responseBody)));
}
// 将session中关联的用户信息,添加到当前的Request中。转发后,业务服务可以根据需要自定读取。
tokenData.put("sessionId", sessionId);
exchange.getAttributes().put(GatewayConstant.SESSION_ID_KEY_NAME, sessionId);
// 这里按照HTTP协议的规定,一旦Http的请求或响应被创建和发送,它就是不可变的,不能被修改。
// 这是因为HTTP协议是无状态的,每一个请求和响应都是独立的,不能对过去的请求或未来的请求产生副作用。
// 所以需要在这里用mutate重新构建一个新的请求对象和exchange对象,然后把新的request添加到新构建的Exchange中,然后再放行请求。
ServerHttpRequest mutableReq = exchange.getRequest()
.mutate()
.header(TokenData.REQUEST_ATTRIBUTE_NAME, JSON.toJSONString(tokenData))
.build();
ServerWebExchange mutableExchange = exchange
.mutate()
.request(mutableReq)
.build();
return chain.filter(mutableExchange);
}
private boolean hasPermission(RedissonClient redissonClient, String sessionId, String url) {
// 如果是登出操作则无需校验权限
if (url.equals(GatewayConstant.ADMIN_LOGOUT_URL)) {
return true;
}
// 获取Perm权限在Redis中的key
String permKey = RedisKeyUtil.makeSessionPermIdKey(sessionId);
// 校验url权限
return redissonClient.getSet(permKey).contains(url);
}
/**
* 判断当前请求URL是否为白名单地址,以及一些内置的不用登录的接口,
*
* @param url 请求的url。
* @return 是返回true,否返回false。
*/
private boolean shouldNotFilter(String url) {
// 过滤swagger相关url
if (url.endsWith("/v2/api-docs") || url.endsWith("/v2/api-docs-ext")) {
return true;
}
if (GatewayConstant.ADMIN_LOGIN_URL.equals(url)
|| GatewayConstant.ADMIN_LOGIN_BY_PHONE_URL.equals(url)
|| GatewayConstant.ADMIN_LOGOUT_URL.equals(url)
|| GatewayConstant.ADMIN_FORGET_PASSWORD_URL.equals(url)
|| GatewayConstant.ADMIN_VERIFICATION_CODE_URL.equals(url)
|| GatewayConstant.ADMIN_CHECK_VERIFICATION_CODE_URL.equals(url)
|| url.startsWith("/captcha")) {
return true;
}
// 先过滤白名单
if (CollectionUtils.isNotEmpty(config.getWhiteListUrl())) {
if (config.getWhiteListUrl().contains(url)) {
return true;
}
}
// 过滤ant pattern模式的白名单url
if (CollectionUtils.isNotEmpty(config.getWhitelistUrlPattern())) {
for (String urlPattern : config.getWhitelistUrlPattern()) {
if (antMatcher.match(urlPattern, url)) {
return true;
}
}
}
return false;
}
/**
* 从请求中获取token。
*
* @param request
* @return
*/
private String getTokenFromRequest(ServerHttpRequest request) {
// 先从请求头获取。
String token = request.getHeaders().getFirst(config.getTokenHeaderName());
if (StringUtils.isBlank(token)) {
// 再到查询参数中获取。
token = request.getQueryParams().getFirst(config.getTokenHeaderName());
}
return token;
}
@Override
public int getOrder() {
return HIGHEST_PRECEDENCE + 10000;
}
}
1、第一步,我们先拿到我们的Request请求对象和Response响应对象,方便我们后续对其进行操作,如获取地址、请求头信息、请求头里的Token、请求头加工等操作。
2、然后对于白名单URL(如登录URL等)校验放行,并且当前请求不存在token,我们就放行请求,不做Token和权限的校验。
但是:如果存在token(如登出URL),那么接下来还是要对token进行校验,以确保后面的业务能获取到相关的用户信息,但是如果没有token,后面的业务也就无法获取到token携带的信息了。
3、如果不是白名单的URL或携带了token信息,则解密解析其中携带的token令牌。
4、校验token是否过期或是空信息,并且判断是否需要定时刷新token,如果需要刷新,则通过后端定义的存储新token的请求头字段放入请求头中。
5、取出token中的Session信息,然后生成规定的SessionData在Redis中的Key,然后取出Redis中对应的SessionData(后面将其转换为名为tokenData的JSONObject对象)。
6、校验tokenData、是否存在userId、获取并编码showName(因为Request中不支持中文,所以需要进行UTF-8编码在写入Request中)。
7、(重点关注)取出tokenData中的是否管理员的信息,然后判断:如果不是无需登录的url 并且 不是管理员 并且 对当前url没有权限,则写出无权限错误。
8、写入用户信息到新构建的Request和Exchange中,这里按照HTTP协议的规定,一旦Http的请求或响应被创建和发送,它就是不可变的,不能被修改的。这是因为HTTP协议是无状态的,每一个请求和响应都是独立的,不能对过去的请求或未来的请求产生副作用。所以需要在这里用mutate重新构建一个新的请求对象和exchange对象,然后把新的request添加到新构建的Exchange中,然后再放行请求。
至此,网关前置过滤器实现鉴权逻辑到这里就实现了,如果有什么错误或者不清楚的地方,欢迎各位评论。