建个站不容易啊,又得想办法提高性能,开个 CDN 吧,还得防盗刷,防 DDos,评论吧还老有国外的 IP 发广告、垃圾评论。
本文介绍一下我为了防垃圾评论都做了哪些措施,和我一样使用 Typecho 平台的可以跟着本教程试一试。
这些措施按照实现的难易程度(对不懂代码的人来说)、实用性、弊处等方面综合考虑,越靠后越不推荐。
7月16日更新 - Token 验证
今天凌晨我将 Token 以 Cookies 的形式改成了作为表单的一项提交给后端并由后端校验,这就需要改动我的 JS 文件了,看来省事还是不行啊。
这里分享一下加密和解密函数:
function encryptToken(): string
{
$key = "typecho"; // 密钥兼前缀
$timestamp = time(); // Token 生成时的时间戳
$string = $key . $timestamp; // 拼接
$cipher = "aes-256-cbc"; // 加密算法
$iv_length = openssl_cipher_iv_length($cipher);
$iv = openssl_random_pseudo_bytes($iv_length);
$encrypted_string = openssl_encrypt($string, $cipher, $key, 0, $iv); // 这三步是解密
return base64_encode($iv . $encrypted_string); // 再编码
}
function decryptAndValidateToken($encrypted_token): string
{
$key = "typecho"; // 密钥兼前缀
$cipher = "aes-256-cbc"; // 加密算法
$encrypted_token = base64_decode($encrypted_token); // 解码
$iv_length = openssl_cipher_iv_length($cipher);
$iv = substr($encrypted_token, 0, $iv_length);
$encrypted_string = substr($encrypted_token, $iv_length);
$decrypted_string = openssl_decrypt($encrypted_string, $cipher, $key, 0, $iv); // 解密字符串
$timestamp = substr($decrypted_string, strlen($key));
$current_time = time();
$validity_period = 3600; // 1 小时的有效期
return ($current_time - $timestamp) <= $validity_period; // 这几步是验证是否有效
}
将这些函数写在主题根文件夹的 function.php 中。
然后在 Typecho 的评论 php 文件,通常是 comment.php,在表单中插入一条:
<input type="hidden" name="token" id="token" value="<?php echo encryptToken()?>"/>
然后在表单提交时,把这一部分作为表单的一项提交即可。由于本站是 ajax 提交评论,则在提交请求时还需要加上这一个表单项。总之就是在哪里提交的就在哪里加。
然后修改 Typecho 源码,在 var/Widget/Feedback.php 文件中,在 comment 函数前几行加上:
$token = $_GET['token'] ? $_GET['token'] : $_POST['token'];
if (!Feedback::decryptAndValidateToken($token)) {
throw new Exception(_t('token 无效。'));
}
这里还需要一个 Feedback 的静态函数 decryptAndValidateToken,我们在 Feedback 类中加入这个函数:
private static function decryptAndValidateToken($encrypted_token): string
{
if (!$encrypted_token) {
return false;
}
$key = "typecho";
$cipher = "aes-256-cbc";
$encrypted_token = base64_decode($encrypted_token);
$iv_length = openssl_cipher_iv_length($cipher);
$iv = substr($encrypted_token, 0, $iv_length);
$encrypted_string = substr($encrypted_token, $iv_length);
$decrypted_string = openssl_decrypt($encrypted_string, $cipher, $key, 0, $iv);
// 检查解密后的 token 是否有效
if (!is_numeric($decrypted_string)) {
throw new Exception("Token 无效");
}
$timestamp = (int) $decrypted_string;
$current_time = time();
$validity_period = 1800; // 30分钟的有效期
if (($current_time - $timestamp) <= $validity_period) {
return true;
} else {
return false;
}
}
Token 验证
这种方式实现难度很高,比如我使用的是 Typecho 这类平台,需要对源码有很深的理解,因为它的评论提交没有任何验证措施,仅仅是向对应接口发送个请求(如直接访问 https://imqi1.com/shot/355.imqi1/comment?author=1&text=1&mail=1@q.com
)。所以我们需要手动为它添加验证措施,这就需要改动源码了。我觉得它的安全性是最高的,但弊端是防不住爬虫机器人。因为爬虫机器人访问前端后就可以获取到这个 Cookies,就绕过了这一机制。实现难度也很高。
首先先介绍下我的友链申请是如何实现的。
可以看到,输入框有一个 hidden
,它代表不展示给用户具体的输入框,它的值一般由其他代码控制,在表单提交时一并作为表单项一起提交。
当申请友链的请求被提交后:
可以看到,友链的请求被提交时,Token 是和其他表单项一起提交的,后端处理这个请求时,就可以先验证这个 Token,如果 Token 是正常的,就可以继续后面的处理逻辑了。
所以这里最推荐也是最有效的方法,就是加一个 Token 验证。
要实现这个,我们首先要写一个加密和解密函数,因为 Token 不能以明文方式显现出来。
加密和解密函数很好写,你可以用现成的库,或者自己写一个函数。比如我的站点名称为 ImQi1,我就可以将 “ImQi1” 作为字符串的前缀。然后为了防止 Token 被他人拿去利用,我设定 Token 的过期时间为一小时,那我将 Token 生成的初始时间戳作为起始时间,单位是秒,然后在解密和验证函数中,用代码执行时当前的时间减去生成 Token 的时间,再和 3600 秒一比较就好了。有了这个逻辑,我们就可以尝试写一下。
名词解释
Token,一个名词,用于硬件安全校验的令牌,现在也可作为软件的安全校验。
时间戳,广义上指 Unix 时间戳,是从1970年1月1日(UTC/GMT的午夜)开始所经过的秒数,不考虑闰秒。
<?php
function generateToken()
{
$prefix = "imqi1"; // 前缀
$currentTime = time(); // 当前时间戳
$unencrypted = $prefix . '_' . $currentTime; // 拼接前缀和当前时间戳
return base64_encode($unencrypted); // 返回加密后的字符串
}
function decryptToken($token)
{
return base64_decode($token); // 解密 Token
}
function testToken($token) {
$decrypted = decryptToken($token); // 解密 Token
$prefix = "imqi1"; // Token 的前缀
$prefix_length = strlen($prefix); // 前缀的长度
$current_time = time(); // 当前时间戳
// 检查解密的 Token 是否以正确的前缀开头
if (substr($decrypted, 0, $prefix_length) === $prefix) {
// 从解密的令牌中提取时间戳部分
$timestamp_str = substr($decrypted, $prefix_length + 1);
// 尝试解析提取的时间戳
$timestamp = (int)$timestamp_str;
// 检查时间戳是否有效(在合理范围内)
if ($timestamp > 0 && $timestamp <= $current_time) {
// 令牌结构和有效性检查通过
return true;
}
}
// 如果任何条件失败,则返回 false,表示令牌无效
return false;
}
这里介绍两种情况,我先介绍一下本站是如何实现 Token 验证的。由于我的评论表单是异步提交,JS 已经写死了,我不想去改,因为还要验证 + 压缩 + 混淆 + 上传 + 二次验证,比较麻烦,因而我就采用了 Cookies 的形式进行验证。
Typecho 为 Cookies 的添加封装了一个很实用的类叫 Typecho_Cookie 。它有三个方法:
Typecho_Cookie::get($cookie_name);
Typecho_Cookie::set($cookie_name, $cookie_value);
Typecho_Cookie::delete($cookie_name);
第一个方法是获取 Cookies 的值,参数是 Cookies 的名称;第二个方法是设置 Cookies 的值,第三个方法则是删除某 Cookies。
它比原生的 setCookie() 多了个安全前缀,更为安全,而且使用也极为方便。
有了以上知识之后我们就可以修改评论的逻辑了,首先就是在用户未登录时,为用户生成一个 Token。若 Token 已有且未过期,就可以不需要再申请。这部分代码需要写在评论页面,通常为 comment.php 。
if (!Typecho_Cookie::get('_typecho_comment_token') && !$this->user->hasLogin()) {
Typecho_Cookie::set('_typecho_comment_token', generateToken());
}
if (!testToken(Typecho_Cookie::get('_typecho_comment_token'))) {
Typecho_Cookie::delete('_typecho_comment_token');
}
然后就是在评论提交前验证一下 Token,这部分代码我写在了我的评论审核插件中,如果你没有此插件,就需要写在 Typecho 的源码中提交评论的那一部分,我记得应该是 var/Widget/Feedback.php 中,它里面有一个 comment 方法,貌似是提交评论时执行的方法,各位可以试一下。若用了评论审核插件,则可以插入到评论审核插件中验证评论的那一个函数中,通常叫做 filter 之类的。
$user = Typecho_Widget::widget('Widget_User');
$token = Typecho_Cookie::get("_typecho_comment_token");
if (testToken($token) && !$user->hasLogin()) {
throw new Exception("Token失效,请刷新后重新评论。");
}
到这里就大功告成了,因为 Cookies 生成之后,每次发送请求时都会一并提交给服务器,所以 Cookies 内包含 Token 的方式就不需要改动 JS 。
若你使用的是其他的博客平台,甚至是自建的,则也可以通过上述方式,只是将生成 Token 的那部分改成像我的友链中 Token 验证的那部分那样即可。
验证码
验证码的灵感是来源于喵的 MyLife 主题前几版中的评论验证,不过喵在后来的版本中把它去除了。
本站的验证码就是为评论表单多加了一个验证码的输入框,它的 placeholder 使用了一个简单的繁体字计算式,为防止外国的机器人直接使用 placeholder 的值得出正确结果从而越过该机制。
个人觉得这个验证码很有创意,并且还是自己写的,不需要引入第三方库或插件。这种方式可以屏蔽爬虫机器人访问前端页面后模拟真实用户提交的评论,但它防不住直接向后端提交请求的(因为验证码的审核是由前端实现的)。而前者则防不住爬虫机器人,因此这个可以说是前后端都设了道防线,因此有动手能力的也建议上一下。
这种实现比上一个简单多了,但还是有点难度。
要实现,首先要封装下:
- 数字转换为中文大写数字、运算符转换的函数;
- 替换 placeholder 的函数;
- 验证的函数;
- 初始化函数。
有了以上知识我们就可以开始写了。
function numberToTraditionalChinese(number) {
const traditionalChineseNumbers = ['零', '壹', '貳', '參', '肆', '伍', '陸', '柒', '捌', '玖'];
return traditionalChineseNumbers[number];
}
function replacePlaceholder() {
let num1 = Math.floor(Math.random() * 10) + 1;
let num2 = Math.floor(Math.random() * 10) + 1;
let operator = ["+", "-", "×"][Math.floor(Math.random() * 3)];
let expression = numberToTraditionalChinese(num1) + " " + operator + " " +numberToTraditionalChinese(num2);
let result = operator === "×" ? num1 * num2 : eval(num1 + operator + num2);
document.getElementById('captcha').setAttribute('placeholder', expression);
window.result = result;
}
function checkCaptcha() {
return document.getElementById('captcha').value === window.result.toString();
}
在系统加载完成以后在评论区调用替换 placeholder 的函数就可以了。
这里给出了个最简单的版本,但是需要做一些处理:
- 为了能看得懂代码,上面的正确结果是直接存储在 window.result 里的,因此通过浏览器控制台就可以获取正确结果,从而绕开这个机制,在生产环境中要存储在别处并替换变量名;
- 上述代码的函数名和变量名意图非常明显,在生产环境中要替换变量名和函数名。
然后在评论提交前,先验证一下验证码是否通过即可。
百度内容审核平台
前端后端都加上我认为比较严厉的验证机制以外,接下来就要对评论内容进行限制,因为有些广告也可以手动发。
这个的实现简单,但内容审核的尺度需要参数来调节,这些参数就需要自己根据自己的网站的实际情况调整。Typecho 实现了对百度内容审核平台的接口,可以直接应用插件并使用,还可以自定义屏蔽词汇,缺点就是参数需要自己调节,过高可能会不通过正常评论,建议所有不通过审核的都设为待审核模式。
百度内容审核平台的地址为 https://ai.baidu.com/tech/textcensoring ,插件地址为 https://github.com/sy-records/ty-baidu-textcensor ,领取试用资格的文档地址为 https://ai.baidu.com/ai-doc/ANTIPORN/Wkhu9d5iy 。
防垃圾评论插件
这个可以提供一些基础的防垃圾评论的功能,可以设置敏感邮箱、链接、昵称、评论内容、IP,设置评论是否必须包含中文等。
插件的地址我已经找不到了,但是网上有很多版本,可以自己去搜索下,名称叫 SmartSpam ,目前最新版本为 2.7.0。
唉,现在的垃圾机器人越来越多了,而且还有好多都是真人,防不胜防的~
我的还好 换了新主题之后没有一个垃圾评论
真是不容易😂
见鬼了,我只点击了一次提交评论,竟然带出来 4 条
我也不道为啥哈哈,有时候这样,我查一下看看有无解决办法
csrf token
通用框架搭的应用,机器人是构造数据调接口,前端做验证没什么用。
有些垃圾评论不是机器人发的而是真人发的,前端做验证可以防止一部分不了解中文的人恶意评论。
其实就是加一个验证就好了,wp被机器人摸透了,垃圾评论几乎都是机器人,加个验证机器人就无能为力了。
欸,啥时候发的我才看见
确实,我这个是Typecho,小众框架,没有被摸透,但评论提交也没有任何校验措施,也会被机器人钻空子,所以只能自己动刀
好高级,我只开了百度审核和必须有中文的限制,但所有评论还是需要过一遍人工审核
慢慢试验吧,还在试验阶段
我也经常收到垃圾评论,真的是很头疼哟
是哟,没办法,就得限制一下
评论测试~
豪的,能用就好
这个验证码不是前端做的吗,不需要后端验证?
不需要的,我这个验证码就是前端生成前端校验,跟后端无交互
哈哈,我记住了,有空我把这个验证码改成从后端交互,用到我的项目上 哈哈
我是前端交互呀,你要改成后端交互吗🤔
可以的,我可以把这个验证码改成从后端返回,然后把结果保存在 session 上,就能实现,当然了,安全性不能和专业搞验证码的相比
我这里还是有垃圾评论,这次我也改成了token由后端生成和后端校验,但是验证码没改,还得再试一段时间