当前位置: 首页 → 爱前端 → 

JavaScript

图片验证码在Node.js中的实现

 

博客开放评论后,常会有一些垃圾评论充斥后台。对于此类“机器人留言”,要在夹杂着正常评论的数千条数据当中进行整理、删除,着实是不小的工作量。

解决之道有以下几种:一是设置后台审核功能,或关闭匿名评论,审核通过后才予以公开;二则设置关键词,含有敏感词的评论一律屏蔽;三是设置黑名单(IP、User-Agent等),屏蔽访问;四是设置验证码,屏蔽机器人用户。其他的还包括:实名、积分/信用体系等。诸此种种,难说任何一种能够单独防御所有非法行为。结合成本、用户体验、可行性等综合考虑,多半是数种措施并举,尤其是此类小型站点。验证码便是广为使用的有效措施之一。

要在Node.js中使用验证码,似乎并没有太多成熟的实践。在npm中搜索captcha,大部分似乎都是demo性质的项目,或者并不满足自身的需求。受到一些社区文章的启示,大致清楚了验证码的整个过程及生成的原理:无非生成一串随机字符,并随机显示在固定尺寸的小图片上,并加上噪点;而后保存字符串在session中,与用户输入的内容进行匹配和拦截。

于是,尝试着自己实现一个方案。考虑到先前在实现图片上传加水印时,引入了gm库,结合网上gm+其他语言的实现,决定同样采用gm来实现验证码。

原理如下:

  1. 随机生成4位字符(字母、数字);
  2. 然后gm设置image的背景、填充色、字体大小等;
  3. 将文本绘制到图片上
  4. 通过正弦曲线进行扭曲
  5. 绘制噪声曲线/直线/点

其中,为增加随机性,字体大小、文本位置、正弦曲线的振幅、波长、噪声直线的起讫点均为一定范围内的随机值。

最后,通过req.session.captcha保存验证码字符,通过gm.write输出图片,前端通过请求该图片地址显示在页面上。

但,此方案存在一个问题,以图片形式生成的验证码,需要保证唯一性,并且和会话关联,在重新生成或者会话结束后还需要将过期的验证码销毁。考虑到前端在做工程化时经常会将小图片转为data格式,因此考虑是否也可将验证码从图片转为data格式,避免生成文件。

恰好,gm提供输出Buffer格式,而Buffer支持格式化为base64编码。因此,gm完美支持生成data格式的图片。返回到前端后,经测试,完美解决此问题,且,对于验证码刷新的支持几乎没有任何额外的工作。

完整代码如下(见:fork on GitHub):

function getRandomText() {
    const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
    const charsLen = chars.length;
    let text = '';
    for (let i = 0; i < 4; i += 1) {
        text += chars[Math.floor(Math.random() * charsLen)];
    }

    return text;
}

function createCaptcha(req, res, next) {
    const imgHeight = 32;
    const imgWidth = 80;
    const fontSizeRange = [16, 22];
    const blankHeightRange = [16, 24];
    const blankHeight = Math.round(Math.random() * (blankHeightRange[1] - blankHeightRange[0]) * 10) / 10 + blankHeightRange[0];
    const lineYRange = [2, 28];
    const marginLeftRange = [0, 15];
    const waveAmplitude = (imgHeight - blankHeight) / 2;
    const waveLengthRange = [36, 60];
    const captchaText = getRandomText();
    const textGravity = ['West', 'East'];
    let gmImg = gm(imgWidth, blankHeight, '#ddd');

    gmImg.background('#ddd')
        .fill('#000')
        .fontSize(Math.round(Math.random() * (fontSizeRange[1] - fontSizeRange[0]) * 10) / 10 + fontSizeRange[0])
        .gravity('Center')
        .drawText(Math.round(Math.random() * (marginLeftRange[1] - marginLeftRange[0]) * 10) / 10 + marginLeftRange[0], 0, captchaText, textGravity[Math.round(Math.random())])
        .wave(waveAmplitude, Math.round(Math.random() * (waveLengthRange[1] - waveLengthRange[0]) * 10) / 10 + waveLengthRange[0])
        .drawLine(0, Math.random() * (lineYRange[1] - lineYRange[0]) + lineYRange[0], imgWidth, Math.random() * (lineYRange[1] - lineYRange[0]) + lineYRange[0])
        .drawLine(0, Math.random() * (lineYRange[1] - lineYRange[0]) + lineYRange[0], imgWidth, Math.random() * (lineYRange[1] - lineYRange[0]) + lineYRange[0])
        .drawLine(0, Math.random() * (lineYRange[1] - lineYRange[0]) + lineYRange[0], imgWidth, Math.random() * (lineYRange[1] - lineYRange[0]) + lineYRange[0])
        .toBuffer('.png', (err, buffers) => {
            if (err) {
                err.code = 500;
                return next(err);
            }
            req.session.captcha = captchaText;
            res.send('data:image/png;base64,' + buffers.toString('base64'));
        });
}

 

🔚
 

*

*

*

*