背景
最近一段时间发现短信费用支出飙升,注册用户有人疯狂发短信(疑似脚本攻击),不得已增加发短信前验证。大家经常使用很多网站都存在这种验证场景,用户使用上感觉增加了交互,多此一举,实际是安全性保障的必要手段。
验证码验证
开始直接引入现有的验证码验证机制,进行验证。
- 点击发送短信
- 弹出图片验证码校验框
- 输入对应验证码
- 发送短信接口先校验,成功后再发短信
然而实际功能上线后,客户方觉得太难看且繁琐了些。按照交互优化方向给出两个方案
滑块验证
看了下还是做滑块验证简单一些。后端接口返回两张图,背景图,卡片图,加密key,只需要组装出整个滑块布局的样式就可以了。
_originalImageBase64 = res.data['originalImageBase64']; // 背景图
_jigsawImageBase64 = res.data['jigsawImageBase64']; // 滑动卡片图
_blockToken = res.data['token'] ?? ''; // 校验时传入
_secretKey = res.data['secretKey'] ?? ''; // 校验时传入
验证图片及滑动卡片区域,主要显示背景图和滑动卡片图,卡片图默认x轴坐标是0
// 验证图片区域
Container(
margin: EdgeInsets.symmetric(horizontal: 30),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: Colors.white,
),
clipBehavior: Clip.antiAlias,
child: Stack(
alignment: Alignment.centerLeft,
children: [
LayoutBuilder(
builder: (context, constraints) {
return Base64ImageWidget(key: UniqueKey(), base64Image: _originalImageBase64);
},
),
ValueListenableBuilder<double>(
valueListenable: sliderNotifier,
builder: (context, value, _) {
return Transform.translate(
offset: Offset(value, 0),
child: Base64ImageWidget(key: UniqueKey(), base64Image: _jigsawImageBase64),
);
},
),
],
),
),
底部滑块区域,计算拖动滑块计算x轴位移像素,传递给接口,服务端根据正确x轴坐标和传入x轴坐标进行对比,完成校验,偏差 < 5px 就验证通过。核心是函数onHorizontalDragUpdate、onHorizontalDragEnd更新坐标,发送接口进行验证。
onHorizontalDragUpdate
onHorizontalDragUpdate: (details) {
// 对话框内容区域宽度,用于计算基准
final double dialogContentWidth = 460; // 修改此处宽度以匹配弹窗
// 滑块轨道容器的水平内边距 (左右两侧)
final double trackContainerPadding = 20 * 2; // 等于 rpx(32)
// 滑块的宽度
final double knobWidth = rpx(50);
// 计算滑块可滑动的有效轨道宽度
// 这是灰色背景条的宽度: dialogContentWidth - trackContainerPadding
final double effectiveTrackWidth = dialogContentWidth - trackContainerPadding; // 例如 rpx(248)
// 滑块左边缘的最大偏移量
// 可从 0 移动到 (effectiveTrackWidth - knobWidth)
// 确保 maxKnobOffset 不为负数。如果轨道比滑块窄,则 maxKnobOffset 为 0。
final double maxKnobOffset = (effectiveTrackWidth - knobWidth).clamp(0.0, double.infinity);
final double currentOffset = sliderNotifier.value;
// 计算新的偏移量,并将其限制在有效范围 [0, maxKnobOffset] 内
final double newOffset = (currentOffset + details.delta.dx).clamp(0.0, maxKnobOffset);
sliderNotifier.value = newOffset;
},
onHorizontalDragEnd
onHorizontalDragEnd: (_) async {
// Make it async
final dialogContext = context; // Capture dialog's context
G.loading.show(dialogContext, text: '验证中...');
bool verificationSuccess = await _getPhoneCode(sliderNotifier.value);
G.loading.hide(dialogContext);
if (verificationSuccess) {
Navigator.pop(dialogContext); // Pop dialog ONLY on success
} else {
// Verification failed, _getPhoneCode already showed a toast
G.loading.show(dialogContext, text: '刷新中...');
bool refreshSuccess = await _fetchCaptchaData(); // Fetch new captcha data
G.loading.hide(dialogContext);
if (refreshSuccess) {
sliderNotifier.value = 0; // Reset slider
// Dialog remains open, setState in _fetchCaptchaData will update images
dialogSetState(() {}); // Call dialogSetState to rebuild dialog content
} else {
toast('刷新验证码失败,请重试');
Navigator.pop(dialogContext); // Close dialog if refresh also fails
}
}
}
布局
// 滑块区域
Container(
margin: EdgeInsets.only(top: rpx(20), left: 20, right: 20),
height: rpx(30),
child: Stack(
alignment: Alignment.center,
children: [
// 滑动轨道
Container(
width: double.infinity,
height: rpx(15),
alignment: Alignment.center,
decoration: BoxDecoration(
color: Color(0xFFC8C8C8),
borderRadius: BorderRadius.circular(rpx(20)),
),
),
// 滑块
ValueListenableBuilder<double>(
valueListenable: sliderNotifier,
builder: (context, value, _) {
return Positioned(
left: value,
top: 0,
child: GestureDetector(
// Wrap the knob with GestureDetector
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (details) {
// 计算拖动滑块计算x轴位移像素
},
onHorizontalDragEnd: (_) async {
// 滑动结束后发送接口验证,服务端根据正确x轴坐标和传入x轴坐标进行对比,完成校验
},
child: Container(
width: rpx(50),
height: rpx(30),
decoration: BoxDecoration(
color: Color(0xFF0057D4),
borderRadius: BorderRadius.circular(rpx(15)),
boxShadow: [
BoxShadow(
color: Color(0xFF0057D4).withOpacity(0.3),
blurRadius: 8,
spreadRadius: 2,
offset: Offset(0, 3),
),
BoxShadow(
color: Color(0xFF0057D4).withOpacity(0.1),
blurRadius: 8,
spreadRadius: 2,
offset: Offset(0, 3),
),
],
),
child: Center(
child: Text(
'|||',
style: TextStyle(color: Colors.white, fontSize: rpx(12.0), fontWeight: FontWeight.bold, letterSpacing: 2.0),
),
),
),
),
);
},
),
],
),
),
实际上,在接口发送坐标时还需加密传送,避免脚本攻击。EncryptData.encryptAES('{"x":${actualX},"y":5,"secretKey":"$_secretKey"}', _secretKey, 'ecb')
,整个滑块验证流程完成。
优化图片
实际开始调试时,采用的是服务端jar包中的默认图片,存在图片太小300*200,对于整个弹框布局样式很难看,右侧区域存在大片留白,图片也无法拉伸、放缩,否则坐标计算对比会出问题。如果同步缩小弹框比例会很不协调。
AI生成
借助AI生成了6张素材图替换服务端jar包中的默认图片,生成完后因为存在水印的关系,需要先去掉水印再将图片设置或裁剪成目标大小400*200。处理背景图的同时也需要同步处理卡片图,要不然会出现滑动Y轴方向有偏差。
后记
最终效果
智能编码尝试
Claude-3.5-Sonnet模型实际对图片的解析编码能力更高一些。使用zulu解析编码完效果很差,又尝试Deepseek-R1去修正效果还是不尽如意。