前言
上一篇文章中写了全局的异常拦截和请求返回结果的封装,这里将其改造一下,common
包中的拦截器部分移动走,新建一个模块spring-boot-starter-interceptor
,这个模块将存放所有的拦截器,然后common
包将存放一些标准的工具类和模型相关。
新增gateway
模块,作为网关服务,将在这里面进行全局的rest
拦截和鉴权操作。
技术栈
SpringBoot 3.0.2
SpringCloudAlibaba 2022.0.0.0-RC2
JDK 17
需要集成SpringCloudAlibabaNacos
,作为配置中心,这个大家自己搭建一个nacos
,单机启动即可。
思路
首先gateway
作为网关,负责统一的鉴权和路由转发,通过nacos
配置中心完成一些配置文件,比如忽略鉴权的路由数组,比如我们登录接口需要忽略鉴权,然后登录之后拿到token
之后才可以访问其他接口。同时使用配置刷新的能力,动态维护忽略路由,避免增加忽略路由配置导致网关服务重新部署的情况。
common模块
这个模块增加了token
生成的相关工具类
相关依赖
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-impl</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-core</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
token所需属性配置(TokenProperties)
java
@ConfigurationProperties(prefix = "org.yulbo.security.token")
@Data
@RefreshScope //自动刷新
public class TokenProperties {
private String secret;
private Integer expire;
}
这个@RefreshScope
依赖于nacos
配置中心的配置文件,需要引用common
包的服务配置配置文件,下面会有。
用户信息模型(UserInfo)后面的ThreadLocal中也会使用,这个是生成token必备的
java
@Data
public class UserInfo {
/**
* 用户id
* @author yulbo
* @date 2023/12/30 18:47
*/
private String userId;
/**
* 用户名
* @author yulbo
* @date 2023/12/30 18:47
*/
private String userName;
/**
* 用户租户
* @author yulbo
* @date 2023/12/30 18:48
*/
private String tenantId;
public UserInfo(String userId, String userName, String tenantId) {
this.userId = userId;
this.userName = userName;
this.tenantId = tenantId;
}
}
token生成工具TokenUtil
java
@Component
public class TokenUtil {
@Resource
private TokenProperties tokenProperties;
//token名,作为head的key,可以自己设定
public static final String TOKEN = "token";
//签名算法
private SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.HS512;
/**
* token生成方法
* @author yulbo
* @date 2023/12/30 18:53
*/
public String generateToken(UserInfo userInfo) {
return Jwts.builder()
.setSubject(JSON.toJSONString(userInfo))
.setIssuedAt(new Date())
.setExpiration(generateExpirationDate(tokenProperties.getExpire()))
.signWith( SIGNATURE_ALGORITHM, tokenProperties.getSecret())
.compact();
}
/**
* 验证token
* @author yulbo
* @date 2023/12/30 18:53
*/
public Boolean validateToken(String token) {
final UserInfo userInfo = getUserFromToken(token);
final Date expiration = getExpirationFromToken(token);
return ( userInfo!=null && expiration.after(new Date()));
}
/**
* 从token中拿用户信息
* @author yulbo
* @date 2023/12/30 19:15
*/
public UserInfo getUserFromToken(String token) {
UserInfo user;
try {
final Claims claims = this.getAllClaimsFromToken(token);
user = JSON.parseObject(claims.getSubject(), UserInfo.class);
} catch (Exception e) {
user = null;
}
return user;
}
private Claims getAllClaimsFromToken(String token) {
Claims claims;
try {
claims = Jwts.parser()
.setSigningKey(tokenProperties.getSecret())
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
claims = null;
}
return claims;
}
private Date getExpirationFromToken(String token) {
Date exp;
try {
final Claims claims = this.getAllClaimsFromToken(token);
exp = claims.getExpiration();
} catch (Exception e) {
exp = null;
}
return exp;
}
private Date generateExpirationDate(Integer expiresIn) {
return new Date(System.currentTimeMillis() + expiresIn * 1000L);
}
}
配置类CommonConfiguration
java
@Configuration
public class CommonConfiguration {
@Bean
@ConditionalOnMissingBean
public TokenUtil tokenUtil(){
return new TokenUtil();
}
@Bean
@ConditionalOnMissingBean
public TokenProperties tokenProperties(){
return new TokenProperties();
}
}
自动配置的引入请参照我的上一篇文章,在SpringBoot2 和3的版本,自动配置的方式是不同的。
gateway模块
gateway
引入common
模块就具备了使用token
工具类的能力,然后配置上nacos
对token
相关的配置文件。
相关依赖
xml
<dependency>
<groupId>org.yulbo</groupId>
<artifactId>common</artifactId>
<version>${learning.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
spring-cloud-starter-bootstrap
这个依赖的目的是为了使用bootstrap.yml
,springCloudAlibabaNacos
启动的时候需要这个才能启动容器,跟加载配置文件的顺序有关系,其次就是spring-cloud-starter-loadbalancer
,高版本的gateway
移除了ribbon
,所以需要引入一个负载均衡的组件。
ym配置文件
详情请看注释
yml
server:
port: 8888
spring:
profiles:
active: dev
application:
name: api-gateway
cloud:
gateway:
# gateway的相关配置,使用下面这种方式可以自动发现nacos的服务,可以简略配置。如果需要其他复杂的配置就配置routes
discovery:
locator:
enabled: true #让gateway发现nacos中的服务
filters:
- StripPrefix=1 #去除第一层路由(也就是微服务的应用名)
nacos:
discovery:
sever-addr: 127.0.0.1:8848
namespace: 470d4b50-8543-4e7a-8516-4f4739087d8f #做了环境隔离
group: yulbo
config:
server-addr: 127.0.0.1:8848
namespace: 470d4b50-8543-4e7a-8516-4f4739087d8f
file-extension: yaml
extension-configs:
#第一个配置文件是gateway的配置文件,我在里面增加了一个配置,就是忽略鉴权的url
- group: yulbo
data-id: ${spring.application.name}-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}
refresh: true
# 第二个配置文件配置了一些全局配置,目前配置了token的秘钥和失效时间
- group: yulbo
data-id: common-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}
refresh: true
refresh-enabled: true
nacos配置文件api-gateway-dev.yaml
yaml
gateway:
ignoreurl:
- /rest/v1/user/login
nacos配置文件common-dev.yaml
yaml
org:
yulbo:
security:
token:
secret: yulbo666
expire: 120

WebFilterUrlProperty网关过滤url配置
这个就是对应的上面的第一个nacos配置文件,也就是api-gateway-dev.yaml中的配置
java
/**
* 网关过滤url配置
* @author yulbo
* @date 2024/01/06 15:26
*/
@Data
@Configuration
@RefreshScope//自动刷新
@ConfigurationProperties(prefix = "gateway")
public class WebFilterUrlProperty {
private List<String> ignoreurl = new ArrayList<>();
}
鉴权验证过滤器ForwardAuthFilter
java
/**
* 鉴权拦截器
* @author yulbo
* @date 2024/01/06 15:55
*/
@Component
public class ForwardAuthFilter implements GlobalFilter {
@Resource
private WebFilterUrlProperty webFilterUrlProperty;
@Resource
private TokenUtil tokenUtil;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = "";
//忽略标识
boolean ignore = true;
//如果nacos配置文件中配置了该请求忽略的话,那么就可以跳过鉴权
if(!webFilterUrlProperty.getIgnoreurl().contains(exchange.getRequest().getPath().value())){
token = exchange.getRequest().getHeaders().getFirst(TokenUtil.TOKEN);
ignore = false;
}
//如果不能忽略并且没有验证通过就返回鉴权失败
if(!ignore && !verify(exchange)){
return authError(exchange.getResponse(),JSON.toJSONString(ResponseResult.fail("token失效")));
}
//向下传递token,放在header中,继续转发
ServerHttpRequest request = exchange
.getRequest()
.mutate()
.header(TokenUtil.TOKEN, token)
.build();
ServerWebExchange newExchange = exchange.mutate().request(request).build();
return chain.filter(newExchange);
}
/**
* 验证接口
* @author yulbo
* @date 2024/01/06 15:57
*/
private boolean verify(ServerWebExchange exchange){
//从header中拿token然后调用方法验证
String token = exchange.getRequest().getHeaders().getFirst(TokenUtil.TOKEN);
if(StringUtils.isBlank(token)){
return false;
}
return tokenUtil.validateToken(token);
}
/**
* 验证失败,返回错误信息
* @author yulbo
* @date 2024/01/06 15:58
*/
private Mono<Void> authError(ServerHttpResponse resp, String mess) {
resp.setStatusCode(HttpStatus.UNAUTHORIZED);
resp.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
DataBuffer buffer = resp.bufferFactory().wrap(mess.getBytes(StandardCharsets.UTF_8));
return resp.writeWith(Flux.just(buffer));
}
}
这个就可以完成鉴权的拦截了,然后向下传递token
,之后想增加忽略认证的接口,那么就修改nacos
中对应的配置文件。下面开始弄业务拦截器的部分.
spring-boot-starter-interceptor
这个模块的存在就是存放业务系统中使用的各种通用的拦截器,比如业务系统使用的用户信息拦截,将网关传递的token
进行解析,然后将用户信息存入到ThreadLocal
中,然后方便业务系统使用。并且引入了阿里的transmittable-thread-local
,可以解决多线程环境下ThreadLocal
失效的问题,具体使用,参照官网 transmittable-thread-local
相关依赖
xml
<dependency>
<groupId>org.yulbo</groupId>
<artifactId>common</artifactId>
<version>${learning.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
<version>2.12.6</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
用户线程变量模型ThreadLocalVariable
存储token,用户信息,和用户租户信息
java
@Data
public class ThreadLocalVariable {
private String token;
private UserInfo userInfo;
private String tenant;
}
用户线程变量AuthThreadLocalVariables
存储了用户线程变量,并提供获取用户,token,租户,和清除ThreadLocal的方法
java
@Component
public class AuthThreadLocalVariables implements Serializable {
private static final ThreadLocal<ThreadLocalVariable> THREAD_LOCAL_VAR = new TransmittableThreadLocal<>();
public String getUserId() {
UserInfo user = getUser();
if(user==null){
return null;
}
return String.valueOf(user.getUserId());
}
public UserInfo getUser() {
ThreadLocalVariable variable = THREAD_LOCAL_VAR.get();
if (variable == null) {
return null;
}
return variable.getUserInfo();
}
public String getToken() {
ThreadLocalVariable variable = THREAD_LOCAL_VAR.get();
if (variable == null) {
return null;
}
return variable.getToken();
}
public String getTenant() {
ThreadLocalVariable variable = THREAD_LOCAL_VAR.get();
if(variable == null){
return null;
}
return variable.getTenant();
}
public ThreadLocalVariable getVariable() {
return THREAD_LOCAL_VAR.get();
}
public void setVariable(ThreadLocalVariable variable) {
THREAD_LOCAL_VAR.set(variable);
}
public void cleanup() {
THREAD_LOCAL_VAR.remove();
}
}
认证拦截器AuthHandler完成ThreadLocal的写入和使用后的清除
java
@RequiredArgsConstructor
@Component
public class AuthHandler implements HandlerInterceptor {
private final AuthThreadLocalVariables authThreadLocalVariables;
private final TokenUtil tokenUtil;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (HttpMethod.OPTIONS.name().equalsIgnoreCase(request.getMethod())) {
return true;
}
//静态资源忽略安全认证
if (handler instanceof org.springframework.web.servlet.resource.ResourceHttpRequestHandler) {
return true;
}
String token = getToken(request);
if(StringUtils.isBlank(token)){
return true;
}
if(!tokenUtil.validateToken(token)){
return false;
}
UserInfo userInfo = tokenUtil.getUserFromToken(token);
setVariables(token,userInfo);
return true;
}
private String getToken(HttpServletRequest request){
return request.getHeader(TokenUtil.TOKEN);
}
private void setVariables(String token,UserInfo userInfo){
ThreadLocalVariable variable = new ThreadLocalVariable();
variable.setTenant(userInfo.getTenantId());
variable.setToken(token);
variable.setUserInfo(userInfo);
authThreadLocalVariables.setVariable(variable);
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
authThreadLocalVariables.cleanup();
}
}
到这里的话就可以实现业务系统无侵入的写入用户线程变量,和清理了。然后将它注册到到mvc
拦截器中.
注册到MVC配置中AuthMvcConfiguration
java
@Component
@RequiredArgsConstructor
public class AuthMvcConfiguration implements WebMvcConfigurer {
private final AuthHandler authHandler;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authHandler);
}
}
最后声明配置InterceptorConfiguration
java
@Configuration
@ComponentScan
@RequiredArgsConstructor
public class InterceptorConfiguration {
@ConditionalOnMissingBean
@Bean
public ResponseBodyHandler responseBodyHandler() {
return new ResponseBodyHandler();
}
@ConditionalOnMissingBean
@Bean
public GlobalExceptionHandler globalExceptionHandler() {
return new GlobalExceptionHandler();
}
@ConditionalOnMissingBean
@Bean
public AuthHandler authHandler(AuthThreadLocalVariables authThreadLocalVariables, TokenUtil tokenUtil) {
return new AuthHandler(authThreadLocalVariables, tokenUtil);
}
}
到这里所有的鉴权方案,ThreadLocal
的管理都完成了,就可以在业务系统中使用了。