一种前后端滑动验证码方案

概述

实际开发中,很常见的一种需求,即拦截机器人登陆,增加略微复杂的滑动验证码(相对于静态验证码而言)。

网络上有很多的实现,并且封装成maven可以检索的jar包。

实现

先给出后端的接口:

后端

/**
 * 校验滑动验证码
 */
@RequestMapping("checkcaptcha")
public String checkCaptcha(@RequestBody JSONObject jsonObject) {
    String captchaToken = jsonObject.getString("captchtoken");
    Integer moveLength = jsonObject.getInteger("moveLength");
    if (StringUtils.isEmpty(captchaToken)) {
        return JSONObject.toJSONString("验证不通过");
    }
    try {
        String token = RedisTool.getValueByKey(jedisCluster, captchaToken);
        if (StringUtils.isEmpty(token)) {
            return JSONObject.toJSONString("验证过期,请重试");
        }
        Integer xWidth = Integer.parseInt(RedisTool.getValueByKey(jedisCluster, captchaToken).trim());
        // 精度控制
        if (Math.abs(xWidth - moveLength) > 10) {
            RedisTool.delKey(jedisCluster, captchaToken);
            return JSONObject.toJSONString("验证不通过");
        }
    } catch (Exception e) {
        RedisTool.delKey(jedisCluster, captchaToken);
        return JSONObject.toJSONString(e.getMessage());
    }
    return JSONObject.toJSONString("success");
}

/**
 * 生成验证码图片
 */
@RequestMapping(value = "/captcha")
public String getCaptchaImage() {
    VerificationCodePlace vcPlace = VerificationCodeAdapter.getRandomVerificationCodePlace(this.getClass().getResource("/static/captcha/after/").getPath(),
            this.getClass().getResource("/static/captcha/image/").getPath());
    try {
        String token = UUID.randomUUID().toString();
        RedisTool.setKeyValueExpire(jedisCluster, token, vcPlace.getXLocation().toString(), 60 * 2);
        vcPlace.setXLocation(0);
        vcPlace.setCaptchtoken(token);
        return JSONObject.toJSONString(vcPlace);
    } catch (Exception e) {
        return JSONObject.toJSONString(e.getMessage());
    }
}

POJO

@Data
public class VerificationCodePlace {
    private String backName;
    private String markName;
    private Integer xLocation;
    private Integer yLocation;
    private String captchtoken;

    public VerificationCodePlace(String backName, String markName, Integer xLocation, Integer yLocation) {
        this.backName = backName;
        this.markName = markName;
        this.xLocation = xLocation;
        this.yLocation = yLocation;
    }

}
@Slf4j
public class VerificationCodeAdapter {
    /**
     * 模板图宽度
     */
    private static final int CUT_WIDTH = 50;
    /**
     * 模板图高度
     */
    private static final int CUT_HEIGHT = 50;
    /**
     * 抠图凸起圆心
     */
    private static final int circleR = 5;
    /**
     * 抠图内部矩形填充大小
     */
    private static final int RECTANGLE_PADDING = 8;
    /**
     * 抠图的边框宽度
     */
    private static final int SLIDER_IMG_OUT_PADDING = 1;

    // 生成拼图样式
    private static int[][] getBlockData() {
        int[][] data = new int[CUT_WIDTH][CUT_HEIGHT];
        Random random = new Random();
        // (x-a)²+(y-b)²=r²
        // x中心位置左右5像素随机
        double x1 = RECTANGLE_PADDING + (CUT_WIDTH - 2 * RECTANGLE_PADDING) / 2.0 - 5 + random.nextInt(10);
        // y矩形上边界半径-1像素移动
        double y1_top = RECTANGLE_PADDING - random.nextInt(3);
        double y1_bottom = CUT_HEIGHT - RECTANGLE_PADDING + random.nextInt(3);
        double y1 = random.nextInt(2) == 1 ? y1_top : y1_bottom;

        double x2_right = CUT_WIDTH - RECTANGLE_PADDING - circleR + random.nextInt(2 * circleR - 4);
        double x2_left = RECTANGLE_PADDING + circleR - 2 - random.nextInt(2 * circleR - 4);
        double x2 = random.nextInt(2) == 1 ? x2_right : x2_left;
        double y2 = RECTANGLE_PADDING + (CUT_HEIGHT - 2 * RECTANGLE_PADDING) / 2.0 - 4 + random.nextInt(10);

        double po = Math.pow(circleR, 2);
        for (int i = 0; i < CUT_WIDTH; i++) {
            for (int j = 0; j < CUT_HEIGHT; j++) {
                // 矩形区域
                boolean fill;
                if ((i >= RECTANGLE_PADDING && i < CUT_WIDTH - RECTANGLE_PADDING)
                        && (j >= RECTANGLE_PADDING && j < CUT_HEIGHT - RECTANGLE_PADDING)) {
                    data[i][j] = 1;
                    fill = true;
                } else {
                    data[i][j] = 0;
                    fill = false;
                }
                // 凸出区域
                double d3 = Math.pow(i - x1, 2) + Math.pow(j - y1, 2);
                if (d3 < po) {
                    data[i][j] = 1;
                } else {
                    if (!fill) {
                        data[i][j] = 0;
                    }
                }
                // 凹进区域
                double d4 = Math.pow(i - x2, 2) + Math.pow(j - y2, 2);
                if (d4 < po) {
                    data[i][j] = 0;
                }
            }
        }
        // 边界阴影
        for (int i = 0; i < CUT_WIDTH; i++) {
            for (int j = 0; j < CUT_HEIGHT; j++) {
                // 四个正方形边角处理
                for (int k = 1; k <= SLIDER_IMG_OUT_PADDING; k++) {
                    // 左上、右上
                    if (i >= RECTANGLE_PADDING - k && i < RECTANGLE_PADDING
                            && ((j >= RECTANGLE_PADDING - k && j < RECTANGLE_PADDING)
                            || (j >= CUT_HEIGHT - RECTANGLE_PADDING - k && j < CUT_HEIGHT - RECTANGLE_PADDING + 1))) {
                        data[i][j] = 2;
                    }
                    // 左下、右下
                    if (i >= CUT_WIDTH - RECTANGLE_PADDING + k - 1 && i < CUT_WIDTH - RECTANGLE_PADDING + 1) {
                        for (int n = 1; n <= SLIDER_IMG_OUT_PADDING; n++) {
                            if (((j >= RECTANGLE_PADDING - n && j < RECTANGLE_PADDING)
                                    || (j >= CUT_HEIGHT - RECTANGLE_PADDING - n && j <= CUT_HEIGHT - RECTANGLE_PADDING))) {
                                data[i][j] = 2;
                            }
                        }
                    }
                }
                if (data[i][j] == 1 && j - SLIDER_IMG_OUT_PADDING > 0 && data[i][j - SLIDER_IMG_OUT_PADDING] == 0) {
                    data[i][j - SLIDER_IMG_OUT_PADDING] = 2;
                }
                if (data[i][j] == 1 && j + SLIDER_IMG_OUT_PADDING > 0 && j + SLIDER_IMG_OUT_PADDING < CUT_HEIGHT && data[i][j + SLIDER_IMG_OUT_PADDING] == 0) {
                    data[i][j + SLIDER_IMG_OUT_PADDING] = 2;
                }
                if (data[i][j] == 1 && i - SLIDER_IMG_OUT_PADDING > 0 && data[i - SLIDER_IMG_OUT_PADDING][j] == 0) {
                    data[i - SLIDER_IMG_OUT_PADDING][j] = 2;
                }
                if (data[i][j] == 1 && i + SLIDER_IMG_OUT_PADDING > 0 && i + SLIDER_IMG_OUT_PADDING < CUT_WIDTH && data[i + SLIDER_IMG_OUT_PADDING][j] == 0) {
                    data[i + SLIDER_IMG_OUT_PADDING][j] = 2;
                }
            }
        }
        return data;
    }

    // 抠出拼图
    private static void cutImgByTemplate(BufferedImage oriImage, BufferedImage targetImage, int[][] blockImage, int x, int y) {
        int[][] martrix = new int[3][3];
        int[] values = new int[9];
        for (int i = 0; i < CUT_WIDTH; i++) {
            for (int j = 0; j < CUT_HEIGHT; j++) {
                int _x = x + i;
                int _y = y + j;
                int rgbFlg = blockImage[i][j];
                int rgb_ori = oriImage.getRGB(_x, _y);
                // 原图中对应位置变色处理
                if (rgbFlg == 1) {
                    // 抠图上复制对应颜色值
                    targetImage.setRGB(i, j, rgb_ori);
                    //原图对应位置颜色变化
//                    oriImage.setRGB(_x, _y, Color.LIGHT_GRAY.getRGB());

                    // 抠图区域高斯模糊
                    readPixel(oriImage, _x, _y, values);
                    fillMatrix(martrix, values);
                    oriImage.setRGB(_x, _y, avgMatrix(martrix));
                } else if (rgbFlg == 2) {
                    targetImage.setRGB(i, j, Color.WHITE.getRGB());
                    oriImage.setRGB(_x, _y, Color.GRAY.getRGB());
                } else if (rgbFlg == 0) {
                    //int alpha = 0;
//                    targetImage.setRGB(i, j, rgb_ori & 0x00ffffff);
                }
            }
        }
    }

    private static void readPixel(BufferedImage img, int x, int y, int[] pixels) {
        int xStart = x - 1;
        int yStart = y - 1;
        int current = 0;
        for (int i = xStart; i < 3 + xStart; i++) {
            for (int j = yStart; j < 3 + yStart; j++) {
                int tx = i;
                if (tx < 0) {
                    tx = -tx;

                } else if (tx >= img.getWidth()) {
                    tx = x;
                }
                int ty = j;
                if (ty < 0) {
                    ty = -ty;
                } else if (ty >= img.getHeight()) {
                    ty = y;
                }
                pixels[current++] = img.getRGB(tx, ty);
            }
        }
    }

    private static void fillMatrix(int[][] matrix, int[] values) {
        int filled = 0;
        for (int[] x : matrix) {
            for (int j = 0; j < x.length; j++) {
                x[j] = values[filled++];
            }
        }
    }

    private static int avgMatrix(int[][] matrix) {
        int r = 0;
        int g = 0;
        int b = 0;
        for (int[] x : matrix) {
            for (int j = 0; j < x.length; j++) {
                if (j == 1) {
                    continue;
                }
                Color c = new Color(x[j]);
                r += c.getRed();
                g += c.getGreen();
                b += c.getBlue();
            }
        }
        return new Color(r / 8, g / 8, b / 8).getRGB();
    }

    /**
     * 获取图片
     */
    private static BufferedImage getBufferedImage(String path) throws IOException {
        File file = new File(path);
        if (file.isFile()) {
            return ImageIO.read(file);
        }
        return null;
    }

    // 处理存放
    private static VerificationCodePlace cutAndSave(String imgName, String path, int[][] data, String headPath) throws Exception {
        VerificationCodePlace vcPlace =
                new VerificationCodePlace("sample_after.png", "sample_after_mark.png", 112, 50);

        // 进行图片处理
        BufferedImage originImage = getBufferedImage(path);
        if (originImage != null) {
            int locationX = 90 + new Random().nextInt(originImage.getWidth() - CUT_WIDTH * 3);
            int locationY = new Random().nextInt(originImage.getHeight() - CUT_HEIGHT) / 2;
            BufferedImage markImage = new BufferedImage(CUT_WIDTH, CUT_HEIGHT, BufferedImage.TYPE_4BYTE_ABGR);
            cutImgByTemplate(originImage, markImage, data, locationX, locationY);
            vcPlace = new VerificationCodePlace(getImageBASE64(originImage), getImageBASE64(markImage), locationX, locationY);
        }
        return vcPlace;
    }

    /**
     * 图片转BASE64
     */
    private static String getImageBASE64(BufferedImage image) throws IOException {
        ByteArrayOutputStream bao = new ByteArrayOutputStream();
        ImageIO.write(image, "png", bao);
        byte[] imagedata = bao.toByteArray();
        BASE64Encoder encoder = new BASE64Encoder();
        String base64Image = encoder.encodeBuffer(imagedata).trim();
        // 删除 \r\n
        base64Image = base64Image.replaceAll("\r|\n", "");
        return base64Image;
    }

    /**
     * 获取文件夹下所有文件名
     */
    private static ArrayList<String> getFileNamesFromDic(String dicPath) {
        File dic = new File(dicPath);
        ArrayList<String> imageFileNames = new ArrayList<>();
        File[] dicFileList = dic.listFiles();
        for (File f : dicFileList) {
            imageFileNames.add(f.getName());
        }
        return imageFileNames;
    }

    /**
     * 总流程,随机获取图片并处理,将拼图和对应图片存放至after_img
     * 出错则返回sample
     * headPath为存放生成图片的文件夹地址
     */
    public static VerificationCodePlace getRandomVerificationCodePlace(String headPath, String imageUrl) {
        VerificationCodePlace vcPlace = new VerificationCodePlace("sample_after.png", "sample_mark_after.png", 112, 50);

        // 从文件夹中读取所有待选择文件
        ArrayList<String> imageFileNames = getFileNamesFromDic(imageUrl);

        // 随机获取
        int r = (int) Math.round(Math.random() * (imageFileNames.size() - 1));
        String imgName = imageFileNames.get(r);
        String path = imageUrl + imgName;
        int[][] data = VerificationCodeAdapter.getBlockData();

        // 进行图片处理
        try {
            vcPlace = cutAndSave(imgName, path, data, headPath);
        } catch (Exception e) {
            log.error("getRandomVerificationCodePlace failed: " + e.getMessage());
            return vcPlace;
        }
        return vcPlace;
    }
}

前端

前端采用React框架,使用TypeScript语法。

service目录下的login.ts源码如下,指定后端接口:

interface CheckCaptchaType {
  moveLength: string;
  captchtoken: string;
}

export async function getImgInfo() {
  return request(`/api/login/captcha`);
}

export async function checkCaptcha(params: CheckCaptchaType) {
  return request('/api/login/checkcaptcha', {
    method: 'POST',
    data: params,
  });
}

models目录下的login.ts源码定义,是接口请求成功和失败的逻辑:

import {stringify} from 'querystring';
import {Effect, history, Reducer} from 'umi';
import {getImgInfo} from '@/services/login';
import {message} from 'antd';

export interface StateType {
  status?: 'ok' | 'error';
  type?: string;
  logMsg?: string;
  currentAuthority?: 'user' | 'guest' | 'admin';
  imageInfo?: any;
}

export interface LoginModelType {
  namespace: string;
  state: StateType;
  effects: {
    getImgCode: Effect;
  };
  reducers: {
    changeImageInfo: Reducer<StateType>;
  };
}

const Model: LoginModelType = {
  namespace: 'login',

  state: {
    status: undefined,
  },

  effects: {
    * getImgCode(_, {call, put}) {
      const response = yield call(getImgInfo);
      yield put({
        type: 'changeImageInfo',
        payload: response,
      });
    },

  },

  reducers: {
    changeImageInfo(state, {payload}) {
      const lx = payload.xLocation;
      const ly = payload.yLocation;
      const backImg = `/captcha/after/${payload.backName}`;
      const frontImg = `/captcha/after/${payload.markName}`;
      return {
        ...state,
        imageInfo: {lx, ly, backImg, frontImg}
      };
    },
};

export default Model;

models目录下的ImageCode.ts源码:

import {Effect, Reducer} from 'umi';
import {checkCaptcha, getImgInfo} from '@/services/login';
import {message} from 'antd';

export interface ImageInfoType {
  lx?: number;
  ly?: number;
  backImg?: string;
  frontImg?: string;
  captchtoken?: string;
}

export interface ImageCodeModelState {
  status?: 'ok' | 'error';
  imageInfo?: ImageInfoType;
  isSuccess?: true | false;
  sliderStart?: true | false;
  x?: number;
  y?: number;
  showTip?: 'none' | 'block';
  maskColor?: '1px solid #F75567' | 'none' | '1px solid #3F81EE';
  yArray?: Array<number>;
  moveX?: number;
  btnClass?: 'sliderArrowError' | 'sliderArrow';
  btnSvg?: 0 | 1;
}

export interface ImageCodeModelType {
  namespace: string;
  state: ImageCodeModelState;
  effects: {
    getImgCode: Effect;
    checkcaptcha: Effect;
  };
  reducers: {
    changeImageInfo: Reducer<ImageCodeModelState>;
    setCheckResult: Reducer<ImageCodeModelState>;
    setSliderPush: Reducer<ImageCodeModelState>;
    setSliderDrug: Reducer<ImageCodeModelState>;
    setSliderStart: Reducer<ImageCodeModelState>;
    setIsSuccess: Reducer<ImageCodeModelState>;
    reset: Reducer<ImageCodeModelState>;
    setFailed: Reducer<ImageCodeModelState>;
  };
}

const Model: ImageCodeModelType = {
  namespace: 'imagecode',

  state: {
    status: undefined,
    imageInfo: {
      lx: undefined,
      ly: 0,
      frontImg: '',
      backImg: '',
      captchtoken: '',
    },
    isSuccess: false,
    sliderStart: false,
    x: undefined,
    y: undefined,
    showTip: 'block',
    maskColor: 'none',
    yArray: [],
    moveX: 0,
    btnClass: 'sliderArrow',
    btnSvg: 1,
  },

  effects: {
    * getImgCode(_, {call, put}) {
      yield put({
        type: 'reset',
      });
      const response = yield call(getImgInfo);
      yield put({
        type: 'changeImageInfo',
        payload: response ?? {},
      });
    },
    * checkcaptcha({payload, callback}, {call, put}) {
      yield put({
        type: 'setSliderStart',
        payload: false,
      })
      const response = yield call(checkCaptcha, payload);
      if (response.status === 'success' && response.msg === '验证通过') {
        message.success('验证通过')
        yield put({
          type: 'setIsSuccess',
          payload: true,
        })
        if (callback && typeof callback === 'function') callback();
      } else {
        yield put({
          type: 'setFailed',
        })
        yield call((timeout: number) => {
          return new Promise(resolve => {
            setTimeout(resolve, timeout);
          });
        }, 500);
        yield put({
          type: 'reset',
        });
        yield put({
          type: 'getImgCode',
        });
      }
    }
  },

  reducers: {
    changeImageInfo(state, {payload}) {
      if (payload.status === 'success') {
        const result = JSON.parse(payload.msg)
        const lx = result.xLocation;
        const ly = result.yLocation;
        const backImg = `data:image/png;base64,${result.backName}`;
        const frontImg = `data:image/png;base64,${result.markName}`;
        const {captchtoken} = result;
        return {
          ...state,
          status: 'ok',
          imageInfo: {lx, ly, backImg, frontImg, captchtoken},
          isSuccess: false,
        };
      }
      return {
        ...state,
        status: 'error',
      };
    },
    setCheckResult(state, {payload}) {
      if (payload.status === 'success' && payload.msg === '验证通过') {
        return {
          ...state,
          isSuccess: true,
        }
      }
      return {...state};
    },
    setSliderPush(state, {payload}) {
      if (!state?.sliderStart && state!.yArray!.length > 0) return {...state};
      const {x, y,} = payload;
      return {
        ...state,
        sliderStart: true,
        showTip: 'none',
        maskColor: '1px solid #3F81EE',
        yArray: [],
        x, y,
      }
    },
    setSliderDrug(state, {payload}) {
      if (!state?.sliderStart) return {...state};
      const {moveY, moveX} = payload;
      const yArray = state?.yArray;
      yArray?.push(moveY);
      if (moveX < 5 || moveX + 50 > 280) {
        return {
          ...state,
          yArray,
        }
      }
      return {
        ...state,
        yArray,
        moveX,
      }
    },
    setSliderStart(state, {payload}) {
      return {
        ...state,
        sliderStart: payload
      }
    },
    setIsSuccess(state, {payload}) {
      return {
        ...state,
        isSuccess: payload,
        btnClass: 'sliderArrowSuccess',
        maskColor: '0',
      }
    },
    setFailed(state) {
      return {
        ...state,
        maskColor: '1px solid #F75567',
        btnClass: 'sliderArrowError',
        btnSvg: 0,
      }
    },
    reset(state) {
      return {
        ...state,
        btnClass: 'sliderArrow',
        maskColor: 'none',
        moveX: 0,
        showTip: 'block',
        btnSvg: 1,
        yArray: [],
        isSuccess: false,
      }
    }
  },
};

export default Model;

优化

在这里插入图片描述

参考

posted @ 2021-09-27 21:27  johnny233  阅读(29)  评论(0编辑  收藏  举报  来源