Gateway权限认证方案分析实现网关层限流
1. 权限认证方案分析1.1. 传统单应用的用户鉴权使用session保存登录状态,通过存放的key-value来进行鉴权,对于一台机器无法同步session到其他机器的时候,我们的问题就来了,如何进行服务应用的鉴权1.2. 分布式环境下的解决方案1.2.1. 同步sessionsession复制是最容易先想到的解决方案,可以将一台机器中的session复制到集群中其他的机器里,比如Tomcat中
1. 权限认证方案分析
1.1. 传统单应用的用户鉴权
使用session保存登录状态,通过存放的key-value来进行鉴权,对于一台机器无法同步session到其他机器的时候,我们的问题就来了,如何进行服务应用的鉴权
1.2. 分布式环境下的解决方案
1.2.1. 同步session
session复制是最容易先想到的解决方案,可以将一台机器中的session复制到集群中其他的机器里,比如Tomcat中也有内置的session的同步方案,但是这并不是一个非常优雅的解决方案,他会带来以下两个问题
- Timing问题:同步需要花费一定的时间,我们无法保证session同步的及时性,也就是说,当用户发起两个请求分别落在不同的机器上的时候,前一个请求写入session的信息可能还没有同步到所有的机器,后一个请求就已经开始执行业务逻辑了,这就会引起脏读和幻读
- **数据冗余:**所有的服务器都需要保存一份session的全集,这就产生了大量的冗余数据
1.2.2. 反向代理:绑定IP或一致性hash
这个方案是在Nginx网关层来做的,我们可以指定某些ip请求落在某个指定的机器上,这样一来session始终只会存在同一个机器上,不过相比前一种session复制的方法来说,绑定IP的方式更明显缺陷如下:
- 负载均衡:在绑定IP的情况下无法在网关层应用负载均衡策略的,而且某个服务器出现故障会对指定IP的来访用户产生较大的影响,对网关层来讲这种路由规则的配置也比较麻烦
- IP变更:很多运营商的IP时不时就会进行切换,这就会导致更换IP后的请求被路由到不同的服务节点处理,这样一来就读不到前面设置的session信息了
为了解决第二个问题,可以通过一致性hash路由的方式来做,比如根据用户ID做hash,不同的hash值落在不同的机器上,保证足够均衡的分配,这样也就避免了IP切换的问题,但依然无法解决第一点里提到的负载均衡的问题
1.2.3. Redis解决方案
通过将session中心化,从服务器的存储上转移到redis中
在tomcat层面可以直接使用组件将容器的session放入到redis中,另一个方案可以借助springboot的管理session方式,将session存储进redis中
1.3. 分布式Session的替代方案
1.3.1. OAuth 2.0
OAuth 2.0是一个开放授权标准协议,它允许第三方应用访问该用户在某服务的特定私有资源,但不提供账号密码信息给第三方应用
1.3.2. JWT鉴权
JWT也是一种基于Token的鉴权机制,他的基本思想是通过用户名+密码换取一个Access Token
鉴权流程
1、用户名+密码访问鉴权服务
- 验证通过:服务器返回一个Access Token给客户端,并将Token保存在服务端某个地方用于后面的访问控制(可以保存在数据库里也可以保存在Redis中)
- 验证失败:不生成Token
2、客户端使用令牌访问资源,服务器验证令牌有效性
- 令牌错误或过期:拦截请求,让客户端重新申请令牌
- 令牌正确:允许放行
2. 实现JWT鉴权
通过以下几步完成鉴权操作
- 创建auth-service(登录、鉴权服务)
- 添加JwtService类实现token创建和验证
- 网关层集成auth-service(添加AuthFilter到网关层,如果没有登录则返回403)
在gateway里创建一个auth-service-api的module
添加POM依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
</dependencies>
创建一个entity包,创建一个账户实体对象
package com.icodingedu.springcloud.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Account implements Serializable {
private String username;
private String token;
//当token接近失效的时候可以用refreshToken生成一个新的token
private String refreshToken;
}
在entity包下面创建一个AuthResponseCode类
package com.icodingedu.springcloud.entity;
public class AuthResponseCode {
public static final Long SUCCESS = 1L;
public static final Long INCORRECT_PWD = 1000L;
public static final Long USER_NOT_FOUND = 1001L;
public static final Long INVALID_TOKEN = 1002L;
}
在entity包下创建一个AuthResponse处理结果类
package com.icodingedu.springcloud.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthResponse {
private Account account;
private Long code;
}
创建一个service包在里面创建接口AuthService
package com.icodingedu.springcloud.service;
import com.icodingedu.springcloud.entity.AuthResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
@FeignClient("auth-service")
public interface AuthService {
@PostMapping("/login")
@ResponseBody
public AuthResponse login(@RequestParam("username") String username,
@RequestParam("password") String password);
@GetMapping("/verify")
@ResponseBody
public AuthResponse verify(@RequestParam("token") String token,
@RequestParam("username") String username);
@PostMapping("/refresh")
@ResponseBody
public AuthResponse refresh(@RequestParam("refresh") String refreshToken);
}
创建服务实现的auth-service的module,还是放在gateway目录下
导入POM依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.7.0</version>
</dependency>
<dependency>
<groupId>com.icodingedu</groupId>
<artifactId>auth-service-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
创建启动类application
package com.icodingedu.springcloud;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@EnableDiscoveryClient
@SpringBootApplication
public class AuthServiceApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(AuthServiceApplication.class)
.web(WebApplicationType.SERVLET)
.run(args);
}
}
创建一个service包,建立JwtService类
package com.icodingedu.springcloud.service;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.icodingedu.springcloud.entity.Account;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Date;
@Slf4j
@Service
public class JwtService {
//生产环境中应该从外部加密后传入
private static final String KEY = "you must change it";
//生产环境中应该从外部加密后传入
private static final String ISSUER = "gavin";
//定义过期时间
private static final long TOKEN_EXP_TIME = 60000;
//定义传入的参数名
private static final String USERNAME = "username";
/**
* 生成token
* @param account 账户信息
* @return token
*/
public String token(Account account){
//生成token时间
Date now = new Date();
//生成token所要用到的算法
Algorithm algorithm = Algorithm.HMAC256(KEY);
String token = JWT.create()
.withIssuer(ISSUER) //发行方
.withIssuedAt(now) //发行时间
.withExpiresAt(new Date(now.getTime()+TOKEN_EXP_TIME)) //token过期时间
.withClaim(USERNAME,account.getUsername()) //传入发行的username
.sign(algorithm); //用前面设置算法签发
log.info("jwt generated user={}",account.getUsername());
return token;
}
/**
* 验证token
* @param token
* @param username
* @return
*/
public boolean verify(String token,String username){
log.info("verify jwt - user={}",username);
try{
//加密解密算法一样
Algorithm algorithm = Algorithm.HMAC256(KEY);
//构建一个验证器:验证JWT的内容,是个接口
JWTVerifier verifier = JWT.require(algorithm)
.withIssuer(ISSUER)
.withClaim(USERNAME,username)
.build();
//进行验证,没有错误就直接通过
verifier.verify(token);
return true;
}catch(Exception ex){
log.error("auth failed",ex);
return false;
}
}
}
创建controller包,建立JwtController类
package com.icodingedu.springcloud.controller;
import com.icodingedu.springcloud.entity.Account;
import com.icodingedu.springcloud.entity.AuthResponse;
import com.icodingedu.springcloud.entity.AuthResponseCode;
import com.icodingedu.springcloud.service.AuthService;
import com.icodingedu.springcloud.service.JwtService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
@Slf4j
@RestController
public class JwtController implements AuthService {
@Autowired
private JwtService jwtService;
@Autowired
private RedisTemplate redisTemplate;
@Override
public AuthResponse login(String username, String password) {
Account account = Account.builder()
.username(username)
.build();
//TODO 0-这一步需要验证用户名密码,一般是在数据库中,假定已经验证通过了
//TODO 如果验证失败在这里就要return
//1-生成token
String token = jwtService.token(account);
account.setToken(token);
//2-这里保存拿到新token的key
account.setRefreshToken(UUID.randomUUID().toString());
//3-保存token,把token保存取来在refresh时才知道更新关联哪个token
redisTemplate.opsForValue().set(account.getRefreshToken(),account);
//4-返回token
return AuthResponse.builder()
.account(account)
.code(AuthResponseCode.SUCCESS)
.build();
}
@Override
public AuthResponse verify(String token, String username) {
boolean flag = jwtService.verify(token, username);
return AuthResponse.builder()
.code(flag?AuthResponseCode.SUCCESS:AuthResponseCode.INVALID_TOKEN)
.build();
}
@Override
public AuthResponse refresh(String refreshToken) {
//当使用redisTemplate保存对象时,对象必须时一个可被序列化的对象
Account account = (Account) redisTemplate.opsForValue().get(refreshToken);
if(account == null){
return AuthResponse.builder()
.code(AuthResponseCode.USER_NOT_FOUND)
.build();
}
//获取一个新token
String token = jwtService.token(account);
account.setToken(token);
//更新新的refreshToken
account.setRefreshToken(UUID.randomUUID().toString());
//将原来的删除
redisTemplate.delete(refreshToken);
//添加新的token
redisTemplate.opsForValue().set(account.getRefreshToken(),account);
return AuthResponse.builder()
.account(account)
.code(AuthResponseCode.SUCCESS)
.build();
}
}
设置application配置文件
spring.application.name=auth-service
server.port=50081
eureka.client.serviceUrl.defaultZone=http://localhost:10080/eureka/
spring.redis.host=localhost
spring.redis.database=0
spring.redis.port=6379
info.app.name=auth-service
info.app.description=test
management.security.enabled=false
management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always
可以启动验证了:eureka-server、auth-service
在PostMan里进行了验证:login、verify、refresh
开启改造gateway-server
POM里引入依赖,增加三个依赖
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.7.0</version>
</dependency>
<!--因为springcloud gateway是基于webflux的,如果需要web则是导入starter-webflux而不是starter-web-->
<dependency>
<groupId>com.icodingedu</groupId>
<artifactId>auth-service-api</artifactId>
<version>${project.version}</version>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.5</version>
</dependency>
在gateway-server中创建鉴权的service类GatewayAuthService
package com.icodingedu.springcloud.service;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Service
@Slf4j
public class GatewayAuthService {
//生产环境中应该从外部加密后传入
private static final String KEY = "you must change it";
//生产环境中应该从外部加密后传入
private static final String ISSUER = "gateway";
//定义传入的参数名
private static final String USERNAME = "username";
/**
* 验证token
* @param token
* @param username
* @return
*/
public boolean verify(String token,String username){
log.info("verify jwt - user={}",username);
try{
//加密解密算法一样
Algorithm algorithm = Algorithm.HMAC256(KEY);
//构建一个验证器:验证JWT的内容,是个接口
JWTVerifier verifier = JWT.require(algorithm)
.withIssuer(ISSUER)
.withClaim(USERNAME,username)
.build();
//进行验证,没有错误就直接通过
verifier.verify(token);
return true;
}catch(Exception ex){
log.error("auth failed",ex);
return false;
}
}
}
创建一个新的类:AuthFilter
package com.icodingedu.springcloud.filter;
import com.icodingedu.springcloud.entity.AuthResponse;
import com.icodingedu.springcloud.entity.AuthResponseCode;
import com.icodingedu.springcloud.service.GatewayAuthService;
import io.netty.util.internal.StringUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Slf4j
@Component("authFilter")
public class AuthFilter implements GatewayFilter, Ordered {
private static final String AUTH = "Authorization";
private static final String USERNAME = "icodingedu-username";
@Autowired
private GatewayAuthService authService;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
log.info("Auth Start");
ServerHttpRequest request = exchange.getRequest();
HttpHeaders headers = request.getHeaders();
String token = headers.getFirst(AUTH);
String username = headers.getFirst(USERNAME);
ServerHttpResponse response = exchange.getResponse();
if(StringUtils.isBlank(token)){
log.error("token not found");
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
AuthResponse resp = AuthResponse.builder()
.code(authService.verify(token,username)?AuthResponseCode.SUCCESS:AuthResponseCode.INVALID_TOKEN)
.build();
if(resp.getCode() != 1L){
log.error("invalid token");
response.setStatusCode(HttpStatus.FORBIDDEN);
return response.setComplete();
}
//TODO 将用户信息再次存放在请求的header中传递给下游业务
ServerHttpRequest.Builder mutate = request.mutate();
mutate.header(USERNAME, username);
ServerHttpRequest buildRequest = mutate.build();
//TODO 如果响应中需要放数据,也可以放在response的header中
response.setStatusCode(HttpStatus.OK);
response.getHeaders().add("icoding-user",username);
return chain.filter(
exchange.mutate()
.request(buildRequest)
.response(response)
.build());
}
@Override
public int getOrder() {
return 0;
}
}
将AuthFilter注入到configuration中
@Configuration
public class GatewayConfiguration {
@Autowired
private AuthFilter authFilter;
@Bean
@Order
public RouteLocator customerRouters(RouteLocatorBuilder builder){
LocalDateTime ldt = LocalDateTime.of(2020,10,24,21,30,10);
return builder.routes()
.route(r -> r.path("/gatewayjava/**")
.and().method(HttpMethod.GET)
.filters(f -> f.stripPrefix(1)
.addResponseHeader("java-param","gateway-config")
.filter(authFilter)
)
.uri("lb://FEIGN-CLIENT")
)
.route(r -> r.path("/secondkill/**")
.and().after(ZonedDateTime.of(ldt, ZoneId.of("Asia/Shanghai")))
.filters(f -> f.stripPrefix(1))
.uri("lb://FEIGN-CLIENT/")
)
.build();
}
}
启动服务进行验证:eureka-server、auth-service、feign-client、gateway-server
12. 实现网关层限流
创建一个限流的配置类RedisLimiterConfiguration
package com.icodingedu.springcloud.config;
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import reactor.core.publisher.Mono;
@Configuration
public class RedisLimiterConfiguration {
//我们这里根据用户请求IP地址进行限流
@Bean
@Primary //一个系统不止一个KeyResolver
public KeyResolver remoteAddressKeyResolver(){
return exchange -> Mono.just(
exchange.getRequest()
.getRemoteAddress()
.getAddress()
.getHostAddress()
);
}
@Bean("redisLimiterUser")
@Primary
public RedisRateLimiter redisRateLimiterUser(){
//这里相当于一个令牌桶,我们也可以自己创建一个限流脚本
//defaultReplenishRate:限流桶速率,每秒10个
//defaultBurstCapacity:桶的容量,60
return new RedisRateLimiter(10,60);
}
@Bean("redisLimiterProduct")
public RedisRateLimiter redisRateLimiterProduct(){
//桶的容量100,创建令牌的速度是每秒20
return new RedisRateLimiter(20,100);
}
}
配置application.yaml的redis信息
spring:
application:
name: gateway-server
cloud:
gateway:
discovery:
locator:
enabled: true
lower-case-service-id: true
redis:
host: localhost
port: 6379
database: 0
main:
allow-bean-definition-overriding: true
使用的时候需要在GatewayConfiguration中进行配置加入RedisLimiter的配置
@Configuration
public class GatewayConfiguration {
@Autowired
private KeyResolver hostNameResolver;
@Autowired
@Qualifier("redisLimiterUser")
private RateLimiter rateLimiter;
@Autowired
private AuthFilter authFilter;
@Bean
@Order
public RouteLocator customerRouters(RouteLocatorBuilder builder){
LocalDateTime ldt = LocalDateTime.of(2020,10,24,21,30,10);
return builder.routes()
.route(r -> r.path("/gatewayjava/**")
.and().method(HttpMethod.GET)
.filters(f -> f.stripPrefix(1)
.addResponseHeader("java-param","gateway-config")
.filter(authFilter)
.requestRateLimiter(
c ->{
c.setKeyResolver(hostNameResolver);
c.setRateLimiter(rateLimiter);
c.setStatusCode(HttpStatus.BAD_GATEWAY);
})
)
.uri("lb://FEIGN-CLIENT")
)
.route(r -> r.path("/secondkill/**")
.and().after(ZonedDateTime.of(ldt, ZoneId.of("Asia/Shanghai")))
.filters(f -> f.stripPrefix(1))
.uri("lb://FEIGN-CLIENT/")
)
.build();
}
}
更多推荐
所有评论(0)