Flutter应用开发:滑块验证

292 阅读4分钟

image.png

背景

最近一段时间发现短信费用支出飙升,注册用户有人疯狂发短信(疑似脚本攻击),不得已增加发短信前验证。大家经常使用很多网站都存在这种验证场景,用户使用上感觉增加了交互,多此一举,实际是安全性保障的必要手段。

验证码验证

开始直接引入现有的验证码验证机制,进行验证。

image.png

  • 点击发送短信
  • 弹出图片验证码校验框
  • 输入对应验证码
  • 发送短信接口先校验,成功后再发短信

然而实际功能上线后,客户方觉得太难看且繁琐了些。按照交互优化方向给出两个方案

image.png

滑块验证

看了下还是做滑块验证简单一些。后端接口返回两张图,背景图,卡片图,加密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 就验证通过。核心是函数onHorizontalDragUpdateonHorizontalDragEnd更新坐标,发送接口进行验证。

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,对于整个弹框布局样式很难看,右侧区域存在大片留白,图片也无法拉伸、放缩,否则坐标计算对比会出问题。如果同步缩小弹框比例会很不协调。

image.png

AI生成

借助AI生成了6张素材图替换服务端jar包中的默认图片,生成完后因为存在水印的关系,需要先去掉水印再将图片设置或裁剪成目标大小400*200。处理背景图的同时也需要同步处理卡片图,要不然会出现滑动Y轴方向有偏差。

image.png

后记

最终效果

image.png

智能编码尝试

Claude-3.5-Sonnet模型实际对图片的解析编码能力更高一些。使用zulu解析编码完效果很差,又尝试Deepseek-R1去修正效果还是不尽如意。

image.png