本文主旨搭建一个无状态统一身份认证的系统,基于Spring cloud微服务架构,Eureka 实现服务的注册与发现,Zuul网关实现服务路由,请求过滤和限流功能,使用JWT规范实现客户登陆信息的服务端无状态话,相关文章参考《Spring cloud架构解析和框架搭建》,《Web用户认证和授权机制的演进》
架构
搭建eureka服务注册中心,将业务服务和认证授权中心服务注册进去,zuul网关路由业务服务和认证中心服务,并实现请求过滤和限流功能,认证的流程如下:
1.客户端发起访问服务的请求,zuul过滤请求,如果请求不需要身份验证则放行路由到指定服务,如果请求需要身份验证则校验token,如果token校验失败拒绝请求
2.客户端登陆,带入用户名密码
3.Zuul路由到认证授权中心,校验用户名密码,通过后生成JWT并返回客户端
4.客户端本地保存JWT
5.客户端访问需要验证身份的服务,并带入JWT
6.Zuul校验JWT,成功后放行路由到该服务
搭建
创建JWT工具类
创建JWT的生成,解码方法
package com.iwc.cloudBoss.common.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwsHeader;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* JWT校验工具类
* <ol>
* <li>iss: jwt签发者</li>
* <li>sub: jwt所面向的用户</li>
* <li>aud: 接收jwt的一方</li>
* <li>exp: jwt的过期时间,这个过期时间必须要大于签发时间</li>
* <li>nbf: 定义在什么时间之前,该jwt都是不可用的</li>
* <li>iat: jwt的签发时间</li>
* <li>jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击</li>
* </ol>
*/
public class JWTUtil {
private final static Logger log= LoggerFactory.getLogger(JWTUtil.class);
/**
* JWT 加解密类型
*/
private static final SignatureAlgorithm ALGORITHM = SignatureAlgorithm.HS256;
/**
* JWT 生成密钥使用的密码
*/
private static final String SECRET = "iwc.cloudBoss";
/**
* JWT 添加至HTTP HEAD中的前缀
*/
private static final String SEPARATOR = "Bearer ";
/**
* JWT 添加至PAYLOAD的签发者
*/
private static final String ISSUE = "iwc";
/**
* JWT 添加至PAYLOAD的有效期(秒)
*/
private static final int TIMEOUT = 60 * 60 * 24;
/**
* 生成JWT
*
* @param userId
* @return
*/
public static String genJWT(String userId) {
// 创建payload的私有声明(根据特定的业务需要添加,如果要拿这个做验证,一般是需要和jwt的接收方提前沟通好验证方式的)
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
long currentTime = System.currentTimeMillis();
return Jwts.builder()
.setId(UUID.randomUUID().toString()) //jwt唯一id
.setIssuedAt(new Date(currentTime)) //签发时间
.setIssuer(ISSUE) //签发者信息
.signWith(ALGORITHM, SECRET) //加密方式
.setExpiration(new Date(currentTime + TIMEOUT * 1000)) //过期时间戳
.addClaims(claims) //cla信息
.compact();
}
/**
* 获取token中的claims信息
*
* @param token
* @return
*/
private static Jws<Claims> getJws(String token) {
return Jwts.parser()
.setSigningKey(SECRET)
.parseClaimsJws(token);
}
public static String getSignature(String token) {
try {
return getJws(token).getSignature();
} catch (Exception ex) {
return "";
}
}
/**
* 获取token中head信息
*
* @param token
* @return
*/
public static JwsHeader getHeader(String token) {
try {
return getJws(token).getHeader();
} catch (Exception ex) {
return null;
}
}
/**
* 获取payload body信息
*
* @param token
* @return
*/
public static Claims getClaimsBody(String token) {
return getJws(token).getBody();
}
/**
* 获取body某个值
*
* @param token
* @param key
* @return
*/
public static Object getVal(String token, String key) {
return getJws(token).getBody().get(key);
}
/**
* 是否过期
*
* @param token
* @return
*/
public static boolean isExpiration(String token) {
try {
return getClaimsBody(token)
.getExpiration()
.before(new Date());
} catch (ExpiredJwtException ex) {
return true;
}
}
}
认证授权中心
依赖包
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
认证服务
在登陆校验成功后生成JWT并返回前端
/**
* JWT方案
*/
String token = JWTUtil.genJWT(custMember.getId().toString());
Member member = DtoUtil.TbMemer2Member(custMember);
member.setToken(token);
member.setState(1);
return member;
网关
依赖包
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
配置路由规则
zuul:
#忽略所有服务,只路由指定的服务
ignored-services: "*"
#ignoredPatterns: /**/cust/** 不允许转发的路径
routes:
cloudBoss-customerCenter: /**
身份验证
token过滤
继承ZuulFileter类,实现过滤类型,过滤优先级,是否过滤,和执行过滤四个方法
在是否过滤方法中可以通过URL判断是否需要过滤,在执行过滤方法中获得HTTP请求头中的token,并校验
@Component
public class TokenFilter extends ZuulFilter {
private final static Logger log= LoggerFactory.getLogger(TokenFilter.class);
/**
四种类型:pre,routing,error,post
pre:主要用在路由映射的阶段是寻找路由映射表的
routing:具体的路由转发过滤器是在routing路由器,具体的请求转发的时候会调用
error:一旦前面的过滤器出错了,会调用error过滤器。
post:当routing,error运行完后才会调用该过滤器,是在最后阶段的
*/
@Override public String filterType() {
return "pre";
}
/**
* 自定义过滤器执行的顺序,数值越大越靠后执行,越小就越先执行
*/
@Override public int filterOrder() {
return 0;
}
/**
* 控制过滤器生效不生效,可以在里面写一串逻辑来控制
*/
@Override public boolean shouldFilter() {
//共享RequestContext,上下文对象
RequestContext requestContext = RequestContext.getCurrentContext();
HttpServletRequest request = requestContext.getRequest();
//不需要权限校验URL
if ("/cust/login/login".equalsIgnoreCase(request.getRequestURI())) {
return false;
} else if ("/member/checkLogin".equalsIgnoreCase(request.getRequestURI())) {
return false;
} else if ("/member/loginOut".equalsIgnoreCase(request.getRequestURI())) {
return false;
} else if ("/member/register".equalsIgnoreCase(request.getRequestURI())) {
return false;
}
/* else if ("/cust/register/getPreFix".equalsIgnoreCase(request.getRequestURI())) {
return false;
} */
else if ("/member/sendSmsCode".equalsIgnoreCase(request.getRequestURI())) {
return false;
} else if ("/member/checkUser".equalsIgnoreCase(request.getRequestURI())) {
return false;
} else if ("/member/forgetPassword".equalsIgnoreCase(request.getRequestURI())) {
return false;
} else if ("/member/signUp".equalsIgnoreCase(request.getRequestURI())) {
return false;
} else if ("/member/payOrderForPG".equalsIgnoreCase(request.getRequestURI())) {
return false;
} else if ("/error".equalsIgnoreCase(request.getRequestURI())) {
return false;
} else if ("/goods/".equalsIgnoreCase(request.getRequestURI().substring(0, 7))) {
return false;
}
return true;
}
/**
* 执行过滤逻辑
* 只有上面返回true的时候,才会进入到该方法
*/
@Override public Object run() {
RequestContext context = RequestContext.getCurrentContext();
HttpServletRequest request = context.getRequest();
/**JWT方案
*请求头获取JWT
*/
//如果不存在token拒绝请求
String token = request.getHeader("Authentication-Token");
if (StringUtils.isEmpty(token)) {
context.setSendZuulResponse(false);
context.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
context.setResponseBody("unAuthrized no token");
return null;
}
//jwt验证,获取userId
try {
String userId = JWTUtil.getVal(token, "userId").toString();
//token值有问题拒绝请求
if (StringUtils.isEmpty(userId)) {
context.setSendZuulResponse(false);
context.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
context.setResponseBody("token auth fail");
return null;
}
//token过期拒绝请求
else if (JWTUtil.isExpiration(token)) {
context.setSendZuulResponse(false);
context.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
context.setResponseBody("token expired");
return null;
}
}
catch (Exception e) {
log.error("token auth fail");
context.setSendZuulResponse(false);
context.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
context.setResponseBody("token auth fail");
}
return null;
}
}
限流
Redis存储限流令牌
配置jedis pool
@Configuration public class ConfigJedisPool {
@Bean(name = "jedis.pool") @Autowired
public JedisPool jedisPool(@Value("${spring.redis.host}") String host, @Value("${spring.redis.port}") int port) {
return new JedisPool(host, port);
}
}
Redis实现令牌桶机制
@Component
public class RedisRaterLimiter {
final static Logger log= LoggerFactory.getLogger(RedisRaterLimiter.class);
@Autowired
private JedisPool jedisPool;
private static final String BUCKET = "BUCKET_";
private static final String BUCKET_COUNT = "BUCKET_COUNT";
private static final String BUCKET_MONITOR = "BUCKET_MONITOR_";
public String acquireTokenFromBucket(String point, int limit, long timeout) {
Jedis jedis = jedisPool.getResource();
try{
//UUID令牌
String token = UUID.randomUUID().toString();
long now = System.currentTimeMillis();
//开启事务
Transaction transaction = jedis.multi();
//删除信号量 移除有序集中指定区间(score)内的所有成员 ZREMRANGEBYSCORE key min max
transaction.zremrangeByScore((BUCKET_MONITOR + point).getBytes(), "-inf".getBytes(), String.valueOf(now - timeout).getBytes());
//为每个有序集分别指定一个乘法因子(默认设置为 1) 每个成员的score值在传递给聚合函数之前都要先乘以该因子
ZParams params = new ZParams();
params.weightsByDouble(1.0, 0.0);
//计算给定的一个或多个有序集的交集
transaction.zinterstore(BUCKET + point, params, BUCKET + point, BUCKET_MONITOR + point);
//计数器自增
transaction.incr(BUCKET_COUNT);
List<Object> results = transaction.exec();
long counter = (Long) results.get(results.size() - 1);
transaction = jedis.multi();
//Zadd 将一个或多个成员元素及其分数值(score)加入到有序集当中
transaction.zadd(BUCKET_MONITOR + point, now, token);
transaction.zadd(BUCKET + point, counter, token);
transaction.zrank(BUCKET + point, token);
results = transaction.exec();
//获取排名,判断请求是否取得了信号量
long rank = (Long) results.get(results.size() - 1);
if (rank < limit) {
return token;
} else {
//没有获取到信号量,清理之前放入redis中垃圾数据
transaction = jedis.multi();
//Zrem移除有序集中的一个或多个成员
transaction.zrem(BUCKET_MONITOR + point, token);
transaction.zrem(BUCKET + point, token);
transaction.exec();
}
}catch (Exception e){
log.error("限流出错"+e.toString());
}finally {
if(jedis!=null){
jedis.close();
}
}
return null;
}
限流过滤
在执行过滤逻辑中访问redis令牌库
/**
* 执行过滤逻辑
* 只有上面返回true的时候,才会进入到该方法
*/
@Override public Object run() {
RequestContext context = RequestContext.getCurrentContext();
HttpServletRequest request = context.getRequest();
if(rateLimitEnable){
String token1 = redisRaterLimiter.acquireTokenFromBucket("cloudBoss"+ IPInfoUtil.getIpAddr(request), ipLimit, ipTimeout * 1000);
if (StringUtils.isBlank(token1)) {
throw new CloudBossException("You have too many current requests, please try again later");
}
String token2 = redisRaterLimiter.acquireTokenFromBucket("cloudBoss_All", limit, timeout * 1000);
if (StringUtils.isBlank(token2)) {
throw new CloudBossException("There are too many current requests, please try again later");
}
}
return null;
}
测试
身份验证
请求需要验证身份的服务
Post测试调用zuul的地址http://localhost:9881/cust/register/getPreFix
返回无token
登陆
请求登陆服务,zuul放行,登陆成功
并返回JWT串
这里我们手动复制该串在后面的请求中带入
再次请求第一个服务
将复制的JWT串带入HTTP头
访问通过
限流
Zuul的限流配置
#启用全局限流
cloudBoss.rateLimit.enable: true
#每n秒内
cloudBoss.rateLimit.timeout: 10
#限制n个请求
cloudBoss.rateLimit.limit: 100
#单个ip限制
cloudBoss.rateLimit.perIP.timeout: 10
#限制n个请求
cloudBoss.rateLimit.perIP.limit: 5
验证单个ip限流10秒钟内5次
JMeter压测不限流服务
访问http://localhost:9881/cust/register/getPreFix
十秒钟访问20次
测试结果全部通过,没有限流
JMeter压测限流服务
访问http://localhost:9881/cust/login/login
十秒钟访问20次
成功限流10秒钟内5次访问成功