关于登录的安全性管理有较多的手段,包括;设备信息、IP信息、绑定的信息、验证码登各类方式。不过在一些网页版的登录中,如果有人想办法把你的验证码给我,我就可以登录你的账户,查看你的数据。对于一些不法分子通过让你进入某些应用的录屏会议后(XXX退货返现),就能拿到你的验证码,并做登录操作。还有一些是完全流氓式做法,就玩命的一些快递📦手机号+验证码频繁的撞接口,也是有概率成功登录的。因此,为了避免这种情况,我们还需要思考如何防范。
我们可以考虑在登录的阶段必须加一些恶心的图片比对码,或者滑块验证码。这也是一种方式,能尽可能降低登录的撞接口操作。之后再考虑添加一个指纹ID,对于验证码的生成与用户从浏览器设备过来的指纹做绑定。这样即使对方通过录屏拿到你的验证码,也仍然没有做登录操作。
<script>
// Initialize the agent at application startup.
const fpPromise = import('https://openfpcdn.io/fingerprintjs/v4')
.then(FingerprintJS => FingerprintJS.load())
// Get the visitor identifier when you need it.
fpPromise
.then(fp => fp.get())
.then(result => {
// This is the visitor identifier:
const visitorId = result.visitorId
console.log(visitorId)
})
</script>
有了上面这个方案,我们至少可以做一些安全的管控了。但还有臭不要脸的,一直刷你接口。这既有安全风险,又有对服务器的压力。所以我们要考虑对于这样的恶意用户进行限流和自动化黑名单
处理。
浏览器指纹的方案只需要做一个验证码绑定即可,之后限流和自动化黑名单
,则需要做一些代码的开发。通过配置的方式为每一个需要做此类功能的接口添加上服务治理。通常我们把对应用的熔断、降级、限流、切量、黑白名单、人群等,都称为服务治理
工程结构
限流拦截
切面定义
public @interface AccessInterceptor {
/** 用哪个字段作为拦截标识,未配置则默认走全部 */
String key() default "all";
/** 限制频次(每秒请求次数) */
double permitsPerSecond();
/** 黑名单拦截(多少次限制后加入黑名单)0 不限制 */
double blacklistCount() default 0;
/** 黑名单持续时间(秒) */
long blacklistDurationSeconds() default 24 * 3600; // 默认为24小时
/** 拦截后的执行方法 */
String fallbackMethod();
}
- 自定义切面注解,提供了拦截的key、限制频次、黑名单处理、黑名单持续时间、拦截后的回调方法。再通过 @Pointcut 切入配置了自定义注解的接口方法
切面拦截
@Slf4j
@Aspect
public class RateLimiterAOP {
@Resource
private RedissonClient redissonClient;
@Pointcut("@annotation(cn.bugstack.chatgpt.data.types.annotation.AccessInterceptor)")
public void aopPoint() {
}
@Around("aopPoint() && @annotation(accessInterceptor)")
public Object doRouter(ProceedingJoinPoint jp, AccessInterceptor accessInterceptor) throws Throwable {
String key = accessInterceptor.key();
if (key == null || key.isEmpty()) {
throw new IllegalArgumentException("RateLimiter key is null or empty!");
}
// 获取拦截字段
String keyAttr = getAttrValue(key, jp.getArgs());
log.info("aop attr {}", keyAttr);
// 黑名单拦截
if (!"all".equals(keyAttr) && accessInterceptor.blacklistCount() != 0 && isBlacklisted(keyAttr, accessInterceptor)) {
log.info("限流-黑名单拦截(24h):{}", keyAttr);
return fallbackMethodResult(jp, accessInterceptor.fallbackMethod());
}
// 速率限制
if (!isRateLimited(keyAttr, accessInterceptor)) {
log.info("限流-超频次拦截:{}", keyAttr);
return fallbackMethodResult(jp, accessInterceptor.fallbackMethod());
}
// 返回结果
return jp.proceed();
}
/**
* 黑名单
* @param keyAttr
* @param accessInterceptor
* @return
*/
private boolean isBlacklisted(String keyAttr, AccessInterceptor accessInterceptor) {
String blacklistKey = "blacklist:" + keyAttr;
long count = redissonClient.getAtomicLong(blacklistKey).incrementAndGet();
redissonClient.getAtomicLong(blacklistKey).expire(accessInterceptor.blacklistDurationSeconds(), TimeUnit.SECONDS);
return count > accessInterceptor.blacklistCount();
}
/**
* 限流
* @param keyAttr
* @param accessInterceptor
* @return
*/
private boolean isRateLimited(String keyAttr, AccessInterceptor accessInterceptor) {
String rateLimitKey = "ratelimit:" + keyAttr;
RRateLimiter rateLimiter = redissonClient.getRateLimiter(rateLimitKey);
// 设置速率
rateLimiter.trySetRate(RateType.OVERALL, (long) accessInterceptor.permitsPerSecond(), 1, RateIntervalUnit.SECONDS);
// 尝试获取许可
return rateLimiter.tryAcquire();
}
/**
* 调用用户配置的回调方法,当拦截后,返回回调结果。
*/
private Object fallbackMethodResult(ProceedingJoinPoint jp, String fallbackMethod) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
MethodSignature methodSignature = (MethodSignature) jp.getSignature();
Method method = jp.getTarget().getClass().getMethod(fallbackMethod, methodSignature.getParameterTypes());
return method.invoke(jp.getThis(), jp.getArgs());
}
/**
* 实际根据自身业务调整,主要是为了获取通过某个值做拦截
*/
private String getAttrValue(String attr, Object[] args) {
if (args[0] instanceof String) {
return (String) args[0];
}
String fieldValue = null;
for (Object arg : args) {
try {
if (fieldValue != null) {
break;
}
fieldValue = String.valueOf(getValueByName(arg, attr));
} catch (Exception e) {
log.error("获取属性值失败 attr:{}", attr, e);
}
}
return fieldValue;
}
/**
* 获取对象的特定属性值
*
* @param item 对象
* @param name 属性名
* @return 属性值
* @author tang
*/
private Object getValueByName(Object item, String name) {
try {
Field field = getFieldByName(item, name);
if (field == null) {
return null;
}
field.setAccessible(true);
Object value = field.get(item);
field.setAccessible(false);
return value;
} catch (IllegalAccessException e) {
return null;
}
}
/**
* 根据名称获取方法,该方法同时兼顾继承类获取父类的属性
*
* @param item 对象
* @param name 属性名
* @return 该属性对应方法
* @author tang
*/
private Field getFieldByName(Object item, String name) {
try {
Field field;
try {
field = item.getClass().getDeclaredField(name);
} catch (NoSuchFieldException e) {
field = item.getClass().getSuperclass().getDeclaredField(name);
}
return field;
} catch (NoSuchFieldException e) {
return null;
}
}
}
- 通过自定义注解中配置的拦截字段,获取对应的值。这里的值作为用户的标识使用,只对这个用户进行拦截。【也可以是一些列的信息确认,包括用户IP、设备等。】
- 这段代码流程中会根据自定义注解中的配置,对访问的用户进行限流拦截,当拦击次数达到加入黑名单的次数后,则直接存起来(Redis)在24h内直接走黑名单。—— 实际的场景中还会有风控的手段介入,以及人工来操作黑名单。
@Configuration
public class RateLimiterAOPConfig {
@Bean
public RateLimiterAOP rateLimiter(){
return new RateLimiterAOP();
}
}
最后在需要拦截的方法上添加自定义注解即可
- key: 以用户ID作为拦截,这个用户访问次数限制
- fallbackMethod:失败后的回调方法,方法出入参保持一样
- permitsPerSecond:每秒的访问频次限制。1秒1次
- blacklistCount:超过10次都被限制了,还访问的,扔到黑名单里24小时