一、限流
1.简介
限流就是限制流量,但这里的流量是一个比较笼统的概念。如果考虑各种不同的场景,限流是非常复杂的,而且和具体的业务规则密切相关
通过限流,可以控制服务请求的速率,从而提高系统应对突发大流量的能力,让系统更具弹性
可以考虑如下几种常见的场景:
- 限制某个接口一分钟内最多请求 100 次
-
限制某个用户的下载速度最多 100KB/S
-
限制某个用户同时只能对某个接口发起 5 路请求
-
限制某个 IP 来源禁止访问任何请求
专业术语
名称 | 说明 |
---|---|
Request rate limiting | 请求频率限流 |
Concurrent requests limiting | 并发量限流 |
内部通过集成过滤器和Bucket4j实现请求拦截
2.Bucket4j限流库
Bucket4j 是一个基于令牌桶算法实现的强大的限流库,它不仅支持单机限流,还支持通过诸如 Hazelcast、Ignite、Coherence、Infinispan 或其他兼容 JCache API (JSR 107) 规范的分布式缓存实现分布式限流。
核心概念
概念 | 说明 |
---|---|
Bucket | 接口代表了令牌桶的具体实现也是我们操作的入口。它提供了诸如 |
Bandwidth | 带宽,可以理解为限流的规则。Bucket4j 提供了两种方法来创建 Bandwidth:simple 和 classic 。simple 方式桶大小和填充速度是一样的,classic 方式更灵活一点,可以自定义填充速度 |
Refill | 用于填充令牌桶,可以通过它定义填充速度,Bucket4j 有两种填充令牌的策略:间隔策略(intervally) 和 贪婪策略(greedy) 间隔策略指的是每隔一段时间,一次性的填充所有令牌 贪婪策略会尽可能贪婪的填充令牌 |
Bucket4j 唯一不足的地方是它只支持请求频率限流,不支持并发量限流
3.代码实现
重要配置参数
rest:limits:tenant: #租户拦截enabled: "${TB_SERVER_REST_LIMITS_TENANT_ENABLED:false}" configuration: "${TB_SERVER_REST_LIMITS_TENANT_CONFIGURATION:100:1,2000:60}" #1秒最高100次,1分钟最高2000次customer: #客户拦截enabled: "${TB_SERVER_REST_LIMITS_CUSTOMER_ENABLED:false}"configuration: "${TB_SERVER_REST_LIMITS_CUSTOMER_CONFIGURATION:50:1,1000:60}" #1秒最高50次,1分钟最高1000次 |
代码实现
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {SecurityUser user = getCurrentUser();//获取当前请求的用户if (user != null && !user.isSystemAdmin()) {//判断用户是不是系统管理员if (perTenantLimitsEnabled) {TbRateLimits rateLimits = perTenantLimits.computeIfAbsent(user.getTenantId(), id -> new TbRateLimits(perTenantLimitsConfiguration)); //构建用户对应的速率控制类if (!rateLimits.tryConsume()) {//判断当前请求是否达到限制errorResponseHandler.handle(new TbRateLimitsException(EntityType.TENANT), (HttpServletResponse) response);return;}}if (perCustomerLimitsEnabled && user.isCustomerUser()) {TbRateLimits rateLimits = perCustomerLimits.computeIfAbsent(user.getCustomerId(), id -> new TbRateLimits(perCustomerLimitsConfiguration));if (!rateLimits.tryConsume()) {errorResponseHandler.handle(new TbRateLimitsException(EntityType.CUSTOMER), (HttpServletResponse) response);return;}}}chain.doFilter(request, response);}//构建速率控制对象public TbRateLimits(String limitsConfiguration) {LocalBucketBuilder builder = Bucket4j.builder();boolean initialized = false;for (String limitSrc : limitsConfiguration.split(",")) {long capacity = Long.parseLong(limitSrc.split(":")[0]);long duration = Long.parseLong(limitSrc.split(":")[1]);builder.addLimit(Bandwidth.simple(capacity, Duration.ofSeconds(duration)));initialized = true;}if (initialized) {bucket = builder.build();} else {throw new IllegalArgumentException("Failed to parse rate limits configuration: " + limitsConfiguration);}} |
二、加密
1.简介
我们开发时进行密码加密,可用的加密手段有很多,比如对称加密、非对称加密、信息摘要等。在一般的项目里,常用的就是信息摘要算法,也可以被称为散列加密函数,或者称为散列算法、哈希函数。
常用的散列函数有 MD5 消息摘要算法
2.散列加密原理
散列函数通过把消息或数据压缩成摘要信息,使得数据量变小,将数据的格式固定下来,然后将数据打乱混合,再重新创建成一个散列值,从而达到加密的目的。
散列值通常用一个短的随机字母和数字组成的字符串来代表,一个好的散列函数在输入域中很少出现散列冲突。在散列表和数据处理时,如果我们不抑制冲突来区别数据,会使得数据库中的记录很难找到。
但是仅仅使用散列函数还不够,如果我们只是单纯的使用散列函数而不做特殊处理,其实是有风险的!比如在两个用户密码明文相同时,生成的密文也会相同,这样就增加了密码泄漏的风险。
所以为了增加密码的安全性,一般在密码加密过程中还需要"加盐",而所谓的"盐"可以是一个随机数,也可以是用户名。”加盐“之后,即使密码的明文相同,用户生成的密码密文也不相同,这就可以极大的提高密码的安全性。
传统的加盐方式需要在数据库中利用专门的字段来记录盐值,这个字段可以是用户名字段(因为用户名唯一),也可以是一个专门记录盐值的字段,但这样的配置比较繁琐。
3.Spring Security中的密码处理方案
Spring Security对密码的处理方案,有如下3种方式:
-
对密码进行明文处理,即不采用任何加密方式;
-
采用MD5加密方式;
-
采用哈希算法加密方式。
项目中使用BCryptPasswordEncoder 加密
4. BCryptPasswordEncoder简介
Spring Security提供了多种密码加密算法,但官方推荐使用的是BCryptPasswordEncoder方案
我们开发时,用户表中的密码通常是使用MD5等不可逆算法加密后存储,但为了防止彩虹表破解,可以先使用一个特定的字符串(如域名)进行加密,然后再使用一个随机的salt(盐值)加密。
其中特定的字符串是程序代码中固定的,salt是每个密码单独随机的,我们一般会给用户表加一个字段单独存储,但这样比较麻烦。
而BCrypt算法却可以随机生成salt并混入最终加密后的密码,验证时也无需单独提供之前的salt,从而无需单独处理salt。不同于 Shiro 中需要自己处理密码加盐,在 Spring Security 中,BCryptPasswordEncoder 本身就自带了盐,所以处理起来非常方便。
另外BCryptPasswordEncoder使用BCrypt强哈希函数,我们在使用时可以选择提供strength和SecureRandom参数。strength值(取值在4~31之间,默认为10)越大,则密钥的迭代次数就越多,密钥迭代次数为2^strength
5. 配置密码加密算法
@Beanprotected BCryptPasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();} |
三、token验证
1.简介
Token 的中文意思是"令牌"。主要用来身份验证。比传统的身份验证方法,Token 有扩展性强,安全性高的特点,非常适合用在 Web 应用或者移动应用上
它是服务端生成的字符串,作为客户端进行请求的一个标识
JSON WEB TOKEN
JWT是token的一种实现方式,它将用户信息加密到token
里,服务器不保存任何用户信息。服务器通过使用保存的密钥验证token
的正确性,只要正确即通过验证
JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,
也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。
2.基于传统session认证所显露的问题
功能 | 问题描述 |
---|---|
Session | 每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大 |
扩展性 | 用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力 |
CSRF | 因为是基于cookie来进行用户识别的,cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击 |
3.基于token的鉴权机制
基于token的鉴权机制类似于http协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息
这就意味着基于token认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利
流程说明:
- 用户使用用户名密码来请求服务器
- 服务器进行验证用户的信息
- 服务器通过验证发送给用户一个token
- 客户端存储token,并在每次请求时附送上这个token值
- 服务端验证token值,并返回数据
这个token必须要在每次请求时传递给服务端,它应该保存在请求头里, 另外,服务端要支持CORS(跨来源资源共享)
策略,一般我们在服务端这么做就可以了Access-Control-Allow-Origin: *
4.代码应用
//创建token public AccessJwtToken createAccessJwtToken(SecurityUser securityUser) {if (StringUtils.isBlank(securityUser.getEmail()))throw new IllegalArgumentException("Cannot create JWT Token without username/email");if (securityUser.getAuthority() == null)throw new IllegalArgumentException("User doesn't have any privileges");UserPrincipal principal = securityUser.getUserPrincipal();String subject = principal.getValue();Claims claims = Jwts.claims().setSubject(subject);claims.put(SCOPES, securityUser.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()));claims.put(USER_ID, securityUser.getId().getId().toString());claims.put(FIRST_NAME, securityUser.getFirstName());claims.put(LAST_NAME, securityUser.getLastName());claims.put(ENABLED, securityUser.isEnabled());claims.put(IS_PUBLIC, principal.getType() == UserPrincipal.Type.PUBLIC_ID);if (securityUser.getTenantId() != null) {claims.put(TENANT_ID, securityUser.getTenantId().getId().toString());}if (securityUser.getCustomerId() != null) {claims.put(CUSTOMER_ID, securityUser.getCustomerId().getId().toString());}ZonedDateTime currentTime = ZonedDateTime.now();String token = Jwts.builder().setClaims(claims).setIssuer(settings.getTokenIssuer()).setIssuedAt(Date.from(currentTime.toInstant())).setExpiration(Date.from(currentTime.plusSeconds(settings.getTokenExpirationTime()).toInstant())).signWith(SignatureAlgorithm.HS512, settings.getTokenSigningKey()).compact();return new AccessJwtToken(token, claims);}//解析tokenpublic Jws<Claims> parseTokenClaims(JwtToken token) {try {return Jwts.parser().setSigningKey(settings.getTokenSigningKey()).parseClaimsJws(token.getToken());} catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException | SignatureException ex) {log.debug("Invalid JWT Token", ex);throw new BadCredentialsException("Invalid JWT token: ", ex);} catch (ExpiredJwtException expiredEx) {log.debug("JWT Token is expired", expiredEx);throw new JwtExpiredTokenException(token, "JWT Token expired", expiredEx);}} |