博客开放评论后,常会有一些垃圾评论充斥后台。对于此类“机器人留言”,要在夹杂着正常评论的数千条数据当中进行整理、删除,着实是不小的工作量。
解决之道有以下几种:一是设置后台审核功能,或关闭匿名评论,审核通过后才予以公开;二则设置关键词,含有敏感词的评论一律屏蔽;三是设置黑名单(IP、User-Agent等),屏蔽访问;四是设置验证码,屏蔽机器人用户。其他的还包括:实名、积分/信用体系等。诸此种种,难说任何一种能够单独防御所有非法行为。结合成本、用户体验、可行性等综合考虑,多半是数种措施并举,尤其是此类小型站点。验证码便是广为使用的有效措施之一。
要在Node.js中使用验证码,似乎并没有太多成熟的实践。在npm中搜索captcha,大部分似乎都是demo性质的项目,或者并不满足自身的需求。受到一些社区文章的启示,大致清楚了验证码的整个过程及生成的原理:无非生成一串随机字符,并随机显示在固定尺寸的小图片上,并加上噪点;而后保存字符串在session中,与用户输入的内容进行匹配和拦截。
于是,尝试着自己实现一个方案。考虑到先前在实现图片上传加水印时,引入了gm库,结合网上gm+其他语言的实现,决定同样采用gm来实现验证码。
原理如下:
- 随机生成4位字符(字母、数字);
- 然后gm设置image的背景、填充色、字体大小等;
- 将文本绘制到图片上
- 通过正弦曲线进行扭曲
- 绘制噪声曲线/直线/点
其中,为增加随机性,字体大小、文本位置、正弦曲线的振幅、波长、噪声直线的起讫点均为一定范围内的随机值。
最后,通过req.session.captcha保存验证码字符,通过gm.write输出图片,前端通过请求该图片地址显示在页面上。
但,此方案存在一个问题,以图片形式生成的验证码,需要保证唯一性,并且和会话关联,在重新生成或者会话结束后还需要将过期的验证码销毁。考虑到前端在做工程化时经常会将小图片转为data格式,因此考虑是否也可将验证码从图片转为data格式,避免生成文件。
恰好,gm提供输出Buffer格式,而Buffer支持格式化为base64编码。因此,gm完美支持生成data格式的图片。返回到前端后,经测试,完美解决此问题,且,对于验证码刷新的支持几乎没有任何额外的工作。
完整代码如下(见:GitHub):
javascriptCopy code- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
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'));
});
}
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'));
});
}