前端接口防刷方法探究

暴露在前端的接口被刷是件很头疼的事,这件事情无解,根据我观察,目前主流有三种方法:
1. 通过Js注入Cookie数据,此Cookie数据会在后端进行校检,此种方式必须采用渲染Js来进行破解,提高了刷接口成本。
2. 在前端对请求参数进行加密,加密方法采用代码混淆技术,这种成本比较低,效果还不错。
3. 每次请求生成一个唯一字符串,把该字符串注入Cookie中,此串只能用一次,当访问接口时会校检该串是否有效或是否被用过,无效串直接403。
出于成本考虑我采用1和2两种方法的结合,具体流程如下:
1. 生成一个与时间戳有关系的字符串K,该字符串可采用Rc4加密算法生成,保证加密串可以被还原成时间戳。
2. 将K注入Cookie中
3. 前端拿到K,根据请求参数算出Sign
4. 前端把Sign和请求参数传递到后端,后端首先提取出K计算是否有效、是否过期了一定时间间隔,然后计算Sign是否有效

/**
     * 获取与时间戳有关的加密串
     * @Return String
     */
    Public Static Function Getencryptionkey() {
        $Timestamp = Strval(Time());
        $Rc4ret = Self::Rc4(Self::Common_secret_key, $Timestamp);
        Return Base64_encode($Rc4ret);
    }

    /**
     * 从加密串中解出时间戳
     * @Param $Key
     * @Return String
     */
    Public Static Function Decodeencryptionkey($Key) {
        Return Self::Rc4(Self::Common_secret_key, Base64_decode($Key));
    }

    /**
     * 验证加密串是否有效,考虑时间戳的失效时间。
     * @Param $Key
     * @Return Bool
     */
    Public Static Function Checkencryptionkey($Key) {
        $Nowtimestamp = Intval(Time());
        If (!Empty($Key)) {
            $Encyptiontimestamp = Intval(Self::Decodeencryptionkey($Key));
            Return (($Encyptiontimestamp + Self::Max_valid_time_interval) >= $Nowtimestamp);
        }
        Return False;
    }

谈谈抓取与反抓取

抓取是采集竞品或其它源网站数据,反抓取就是防止别人的抓取行为。目前来说,没有一家公司在反抓取方面做的比较好。举个例子,搜狗运维部门用机器学习搞反抓取策略,在业界也挺得意的,被我花了一周攻克,只用了四十个IP,一天请求上百万,两个多月了,照样好好的用着。
业界反抓取无非以下几种策略:
1. 按照请求频率封禁IP(现在只有比较low的公司会用这种,这种方法的负面伤害更大)
2. 按照IP和请求头部(agent 等信息)封禁
3. 通过执行Javascript程序注入动态cookie
4. 通过机器学习策略分析用户行为
5. 探测到用户有抓取行为,丢一些假数据给用户,比如某地图商
主流是第三种和第四种,第三种的门槛主要是是需要渲染JS才能得到正确的cookie,而普通抓取程序不具备这个功能,并且渲染JS速度太慢,在大规模抓取中不适用。但是第三种我有一套解决方案,是cookie分发机制,出于一些考虑,这套技术不做分享。(在搜狗公共号抓取中,使用cookie分发机制,四十IP可以做到10个/s 请求速度而不遭遇封禁)

第四种那就要好好伪装自己的请求了,比如请求的refer、user-agent、请求的文件类型等,需要自己去做随机请求,难度不大。

抓取解析,那就太简单了,分析DOM结构、使用正则等等,看你具体使用场景吧。

 

HTTP 协议中的 Transfer-Encoding

Transfer-Encoding,是一个 HTTP 头部字段,字面意思是「传输编码」。实际上,HTTP 协议中还有另外一个头部与编码有关:Content-Encoding(内容编码)。Content-Encoding 通常用于对实体内容进行压缩编码,目的是优化传输,例如用 gzip 压缩文本文件,能大幅减小体积。内容编码通常是选择性的,例如 jpg / png 这类文件一般不开启,因为图片格式已经是高度压缩过的,再压一遍没什么效果不说还浪费 CPU。

而 Transfer-Encoding 则是用来改变报文格式,它不但不会减少实体内容传输大小,甚至还会使传输变大,那它的作用是什么呢?本文接下来主要就是讲这个。我们先记住一点,Content-Encoding 和 Transfer-Encoding 二者是相辅相成的,对于一个 HTTP 报文,很可能同时进行了内容编码和传输编码。

Persistent Connection

暂时把 Transfer-Encoding 放一边,我们来看 HTTP 协议中另外一个重要概念:Persistent Connection(持久连接,通俗说法长连接)。我们知道 HTTP 运行在 TCP 连接之上,自然也有着跟 TCP 一样的三次握手、慢启动等特性,为了尽可能的提高 HTTP 性能,使用持久连接就显得尤为重要了。为此,HTTP 协议引入了相应的机制。

HTTP/1.0 的持久连接机制是后来才引入的,通过 Connection: keep-alive 这个头部来实现,服务端和客户端都可以使用它告诉对方在发送完数据之后不需要断开 TCP 连接,以备后用。HTTP/1.1 则规定所有连接都必须是持久的,除非显式地在头部加上 Connection: close。所以实际上,HTTP/1.1 中 Connection 这个头部字段已经没有 keep-alive 这个取值了,但由于历史原因,很多 Web Server 和浏览器,还是保留着给 HTTP/1.1 长连接发送 Connection: keep-alive 的习惯。

浏览器重用已经打开的空闲持久连接,可以避开缓慢的三次握手,还可以避免遇上 TCP 慢启动的拥塞适应阶段,听起来十分美妙。为了深入研究持久连接的特性,我决定用 Node 写一个最简单的 Web Server 用于测试,Node 提供了http 模块用于快速创建 HTTP Web Server,但我需要更多的控制,所以用 net 模块创建了一个 TCP Server:

[code]

require(‘net’).createServer(function(sock) {
sock.on(‘data’, function(data) {
sock.write(‘HTTP/1.1 200 OK\r\n’);
sock.write(‘\r\n’);
sock.write(‘hello world!’);
sock.destroy();
});
}).listen(9090, ‘127.0.0.1’);
[/code]

启动服务后,在浏览器里访问 127.0.0.1:9090,正确输出了指定内容,一切正常。去掉 sock.destroy() 这一行,让它变成持久连接,重启服务后再访问一下。这次的结果就有点奇怪了:迟迟看不到输出,通过 Network 查看请求状态,一直是 pending。

这是因为,对于非持久连接,浏览器可以通过连接是否关闭来界定请求或响应实体的边界;而对于持久连接,这种方法显然不奏效。上例中,尽管我已经发送完所有数据,但浏览器并不知道这一点,它无法得知这个打开的连接上是否还会有新数据进来,只能傻傻地等了。

Content-Length

要解决上面这个问题,最容易想到的办法就是计算实体长度,并通过头部告诉对方。这就要用到 Content-Length 了,改造一下上面的例子:

[code]

require(‘net’).createServer(function(sock) {
sock.on(‘data’, function(data) {
sock.write(‘HTTP/1.1 200 OK\r\n’);
sock.write(‘Content-Length: 12\r\n’);
sock.write(‘\r\n’);
sock.write(‘hello world!’);
});
}).listen(9090, ‘127.0.0.1’);
[/code]

可以看到,这次发送完数据并没有关闭 TCP 连接,但浏览器能正常输出内容并结束请求,因为浏览器可以通过Content-Length 的长度信息,判断出响应实体已结束。那如果 Content-Length 和实体实际长度不一致会怎样?有兴趣的同学可以自己试试,通常如果 Content-Length 比实际长度短,会造成内容被截断;如果比实体内容长,会造成 pending。

由于 Content-Length 字段必须真实反映实体长度,但实际应用中,有些时候实体长度并没那么好获得,例如实体来自于网络文件,或者由动态语言生成。这时候要想准确获取长度,只能开一个足够大的 buffer,等内容全部生成好再计算。但这样做一方面需要更大的内存开销,另一方面也会让客户端等更久。

我们在做 WEB 性能优化时,有一个重要的指标叫 TTFB(Time To First Byte),它代表的是从客户端发出请求到收到响应的第一个字节所花费的时间。大部分浏览器自带的 Network 面板都可以看到这个指标,越短的 TTFB 意味着用户可以越早看到页面内容,体验越好。可想而知,服务端为了计算响应实体长度而缓存所有内容,跟更短的 TTFB 理念背道而驰。但在 HTTP 报文中,实体一定要在头部之后,顺序不能颠倒,为此我们需要一个新的机制:不依赖头部的长度信息,也能知道实体的边界。

Transfer-Encoding: chunked

本文主角终于再次出现了,Transfer-Encoding 正是用来解决上面这个问题的。历史上 Transfer-Encoding 可以有多种取值,为此还引入了一个名为 TE 的头部用来协商采用何种传输编码。但是最新的 HTTP 规范里,只定义了一种传输编码:分块编码(chunked)。

分块编码相当简单,在头部加入 Transfer-Encoding: chunked 之后,就代表这个报文采用了分块编码。这时,报文中的实体需要改为用一系列分块来传输。每个分块包含十六进制的长度值和数据,长度值独占一行,长度不包括它结尾的 CRLF(\r\n),也不包括分块数据结尾的 CRLF。最后一个分块长度值必须为 0,对应的分块数据没有内容,表示实体结束。按照这个格式改造下之前的代码:

[code]

require(‘net’).createServer(function(sock) {
sock.on(‘data’, function(data) {
sock.write(‘HTTP/1.1 200 OK\r\n’);
sock.write(‘Transfer-Encoding: chunked\r\n’);
sock.write(‘\r\n’);

sock.write(‘b\r\n’);
sock.write(‘01234567890\r\n’);

sock.write(‘5\r\n’);
sock.write(‘12345\r\n’);

sock.write(‘0\r\n’);
sock.write(‘\r\n’);
});
}).listen(9090, ‘127.0.0.1’);
[/code]

上面这个例子中,我在响应头中表明接下来的实体会采用分块编码,然后输出了 11 字节的分块,接着又输出了 5 字节的分块,最后用一个 0 长度的分块表明数据已经传完了。用浏览器访问这个服务,可以得到正确结果。可以看到,通过这种简单的分块策略,很好的解决了前面提出的问题。

前面说过 Content-Encoding 和 Transfer-Encoding 二者经常会结合来用,其实就是针对 Transfer-Encoding 的分块再进行 Content-Encoding。下面是我用 telnet 请求测试页面得到的响应,就对分块内容进行了 gzip 编码:

[code]
telnet www.lvxinwei.com 80

GET /index.php HTTP/1.1
Host: www.lvxinwei.com
Accept-Encoding: gzip

HTTP/1.1 200 OK
Server: nginx
Date: Sun, 03 May 2015 17:25:23 GMT
Content-Type: text/html
Transfer-Encoding: chunked
Connection: keep-alive
Content-Encoding: gzip
[/code]

用 HTTP 抓包神器 Fiddler 也可以看到类似结果,有兴趣的同学可以自己试一下。

PHP网站速度优化

刚开始这个博客响应时间平均在200ms,不太理想,对此进行了几个优化,目前响应时间在80ms左右。

1.静态资源缓存

在Nginx上配置

[code]

location ~.*\.(jpg|png|jpeg)$ {
expires 30d;
root /var/www/wordpress;
}
location ~.*\.(js|css)?$ {
expires 1d;
root /var/www/wordpress;
}

[/code]

2.使用PHP7 开启opcache缓存,并使用内存缓存opcache字节码

[code]
zend_extension=opcache.so
opcache.enable=1
opcache.enable_cli=1
opcache.file_cache=/dev/shm
[/code]

3.合并图片,减少请求数,等HTTP2实现了,就不用单独搞这个了

HTTPS配置

基于Nginx HTTPS配置

我把lvxinwei.com域名下的所有站点弄成了HTTPS访问,配置挺简单的,简要的说下:

1.生成 csr 和private key

[code]
openssl req -new -newkey rsa:2048 -nodes -out lvxinwei.com.csr -keyout lvxinwei.com.key
[/code]

然后 在startssl 中填入csr生成 crt 最后在nginx配置:

[code]
listen 443 ssl;
server_name blog.lvxinwei.com;
ssl_certificate /root/cert/lvxinwei.com.crt
ssl_certificate_key /root/cert/lvxinwei.com.key
ssl_session_cache shared:SSL:1m;
ssl_session_timeout 5m;
[/code]

每次启动会让输入密码 所以对private key 解密

[code]
openssl rsa -in lvxinwei.com -out lvxinwei.com.unsecure
[/code]

生成解密文件重新配置Nginx即可。

Nginx与PHP7编译

首先安装pcre 然后下载Nginx源码编译
[code]
yum -y install gcc gcc-c++ autoconf automake libtool make cmake
yum -y install zlib zlib-devel openssl openssl-devel pcre-devel
groupadd web
useradd -g web -M web
[/code]

[code]
./configure –prefix=/usr/local/nginx \
–pid-path=/usr/local/nginx/run/nginx.pid \
–with-http_ssl_module \
–user=web \
–group=web \
–with-pcre \
–without-mail_pop3_module \
–without-mail_imap_module \
–without-mail_smtp_module
make -j4
make install
[/code]

中间可能报错,根据错误搜索下解决即可,挺容易的

PHP7编译

下载源代码 安装相关包

[code]
yum -y install libxml2 libxml2-devel openssl openssl-devel curl-devel libjpeg-devel libpng-devel freetype-devel libmcrypt-devel
[/code]

然后进入源码目录编译

[code]
./configure –prefix=/usr/local/php7 \
–with-config-file-path=/usr/local/php7/etc \
–with-config-file-scan-dir=/usr/local/php7/etc/php.d \
–with-mcrypt=/usr/include \
–enable-mysqlnd \
–with-mysqli \
–with-pdo-mysql \
–enable-fpm \
–with-fpm-user=web \
–with-fpm-group=web \
–with-gd \
–with-iconv \
–with-zlib \
–enable-xml \
–enable-shmop \
–enable-sysvsem \
–enable-inline-optimization \
–enable-mbregex \
–enable-mbstring \
–enable-ftp \
–enable-gd-native-ttf \
–with-openssl \
–enable-pcntl \
–enable-sockets \
–with-xmlrpc \
–enable-zip \
–enable-soap \
–without-pear \
–with-gettext \
–enable-session \
–with-curl \
–with-jpeg-dir \
–with-freetype-dir \
–enable-opcache
[/code]

然后就是make make install 了 最后把配置文件搞一下,设置开机启动

[code]
cp php.ini-production /usr/local/php7/etc/php.ini
cd /usr/local/php7/etc
mv php-fpm.conf.default php-fpm.conf
mv php-fpm.d/www.conf.default php-fpm.d/www.conf
cd /usr/src/php-7.0.0/sapi/fpm
cp init.d.php-fpm /etc/init.d/php-fpm
chmod +x /etc/init.d/php-fpm
chkconfig –add php-fpm
chkconfig php-fpm on
[/code]

preg_match 函数获取链接或许碰到的致命错误

这个错误可以把人雷死,耽误了我两个多小时,我获取的一个链接是 “http://demo.com/?id=1&and=3”,但是每次请求这个网址都显示不存在,然后我把这个网址直接echo出来,和通过preg_match函数得到的进行比较,发现一模一样,但是就是显示无法访问,我没办法,用strlen函数进行比较长度,意外的发现长度不一样,我这时候纳闷了,明明一样的链接为什么不同方式获取的长度不一样,然后又trim下又urldecode urlencode 最后还是没发现原理,没办法了,我就查看输出的源代码,发现有一点点不一样,用preg_match获取的链接竟然带实体符号!!!!大爷的,最后通过htmlspecialchars_decode 函数解决了!!!

无限级分类的动态显示

用到的知识Ajax +php

我以商品销售区域的动态选择为例

数据库设计:

[sql]
CREATE TABLE `think_area` (
`id` int(20) NOT NULL AUTO_INCREMENT,
`pid` int(20) NOT NULL DEFAULT ‘0’,
`area_name` char(100) NOT NULL,
`area_path` char(50) NOT NULL,
`ban_id` int(20) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8;</pre>
[/sql]

其中pid是父级分类的ID值,area_name 是当前区域名称呢 area_path是父级area_path名加“-”+父级ID,例如“0-19-22”,o是顶级区域ID,19是父级的父级区域ID,22是父级区域ID。

ban_id是板块ID,不同的板块销售不同的商品可能送货区域不同,所以绑定下板块ID

实验数据

[sql]
<pre>INSERT INTO `think_area` VALUES (‘2’, ‘0’, ‘测试1’, ‘0’, ”, ‘2’);
INSERT INTO `think_area` VALUES (‘3’, ‘2’, ‘测试1-1’, ‘0-2’, ”, ‘2’);
INSERT INTO `think_area` VALUES (‘4’, ‘0’, ‘南京’, ‘0’, ”, ‘1’);
INSERT INTO `think_area` VALUES (‘5’, ‘4’, ‘江宁’, ‘0-4’, ”, ‘1’);
INSERT INTO `think_area` VALUES (‘6’, ‘5’, ‘河海大学’, ‘0-4-5’, ”, ‘1’);
INSERT INTO `think_area` VALUES (‘8’, ‘4’, ‘仙林’, ‘0-4’, ”, ‘1’);
INSERT INTO `think_area` VALUES (‘9’, ‘8’, ‘南京大学’, ‘0-4-8’, ”, ‘1’);
INSERT INTO `think_area` VALUES (’10’, ‘4’, ‘鼓楼’, ‘0-4’, ”, ‘1’);
[/sql]

选择原理如下:

1.如果用户曾经没选择过任何区域,取得该板块的所有顶级分类传过去供选择,如果曾经选择了某个区域则不管是不是选择到了最底下,都调用函数显示已经选择过的区域并加上下一级的区域供选择(如果有下一级的话)

2.你可以把选择ID传递在cookie或querystring中,这问题不大,我选择的是在这两个中都传递、

3.监听select的值变化,变化了触发刚才的显示函数

HTML:

[html]
<div id="select1"rel="{$Think.get.banid}" aid="{$Think.get.aid}" ></div>
<select class="span2 area " onchange="area_select(this.value,true)"id="select_start">
<option value="0" >选择区域</option>
<volist name="area_data" id="li">
<option value="{$li.id}">{$li.area_name}</option>
</volist>
</select>
<span id="area_append">
</span>
</div>
[/html]
很明显我把板块id $banid 区域ID $aid隐藏在HTML中,供JS读取数据,选择框数值变化激发area_select()函数
需要jquery.js和jquery.cookies.2.2.0.min.js 两个文件
[js]
$("document").ready(function(){
var ban_id=$("#select1").attr("rel");
aid=$.cookies.get("area_id"+ban_id);
if(aid){
area_select(aid);

}
})
function area_select(area_id,r){
var ban_id=$("#select1").attr("rel");
if(area_id==0)
exit;
url=ROOT+"Ajax/select_area?area_id="+area_id;//ROOT="http://localhost/index.php/";

$.get(url,function(data){

$("#select_start").remove();
$("#area_append").html(data)

})
$.cookies.setOptions(cookieOptions);
$.cookies.set("area_id"+ban_id,area_id);
if(r){
n_url=ROOT+"Shop/index?banid="+ban_id+"&aid="+area_id;
window.location.href=n_url;
}

}
[/js]
上面的自己分析
下面是PHP代码,用的thinkphp框架,简化了查询操作、、、不懂的查手册,三分钟就能看明白什么回事
[php]
function select_area() {
$Area = D("Area");
$id = (int) $_GET["area_id"];
$data = $Area->find($id);
$area_path = $data["area_path"];
$ban_id = $data["ban_id"];
$level_num = count($level_data = explode("-", $area_path)); //计算层级
$result = "";
for ($i = 0; $i < $level_num; $i++) {
$area_path = "0";
for ($j = 1; $j < $i + 1; $j++) {
$area_path.="-" . $level_data[$j];
}
$row = $Area->where("area_path=" . "’$area_path’ and ban_id=" . $ban_id)->select();

$str = ‘<select class="span2 area" onchange="area_select(this.value,true)" id="select’ . $i . ‘">’;
foreach ($row as $key => $value) {
$selected = "";//注意为什么要引入这个值!!是为了记住刚才选择过的项。
if ($value["id"] == $level_data[$i + 1] || $value["id"] == $id)
$selected = "selected";
$str.="<option value=" . $value[‘id’] . " " . $selected . " >" . $value["area_name"] . "</option>";
}
$str.="</select>";
$result.=$str;
}
/* * ******
* 下面是判断有无子级元素并取出来
*/
$data = $Area->where("pid=" . $id)->select();
if ($data) {
$str = ‘<select class="span2 area" onchange="area_select(this.value,true)" id="select’ . $level_num . ‘">’;
foreach ($data as $key => $value) {
$str.="<option value=" . $value[‘id’] . " " . $selected . " >" . $value["area_name"] . "</option>";
}
$str.="</select>";
$result.=$str;
}
echo $result;
}
[/php]

PHP使用GD库实现截屏功能

PHP5.2.2以上版本的GD库实现了两个截屏函数 imagegrabscreen 和 imagegrabwindow
分别用于截取整个屏幕和截取某个窗口(同ALT+PrintScreen)的屏幕。

1. 截取整个屏幕 Screenshot
[php]
<?php
$im = imagegrabscreen();
imagepng($im, "myscreenshot.png");
?>
[/php]

2. 截取一个窗口 Capture a window (IE for example)

[php]
<?
$browser = new COM("InternetExplorer.Application");
$handle = $browser->HWND;
$browser->Visible = true;
$im = imagegrabwindow($handle);
$browser->Quit();
imagepng($im, "iesnap.png");
$im = imagegrabscreen();
?>
[/php]

3. 截取IE内容 Capture a window (IE for example) but with its content!

[php]
<?php
$browser = new COM("InternetExplorer.Application");
$handle = $browser->HWND;
$browser->Visible = true;
$browser->Navigate("http://www.abcd9.com/");

/* Still working? */
while ($browser->Busy) {
com_message_pump(4000);
}
$im = imagegrabwindow($handle, 0);
$browser->Quit();
imagepng($im, "iesnap.png");
?>
[/php]

4. 截取IE的全屏模式 IE in fullscreen mode
[php]
<?php
$browser = new COM("InternetExplorer.Application");
$handle = $browser->HWND;

$browser->Visible = true;
$browser->FullScreen = true;
$browser->Navigate("http://www.abcd9.com/");

/* Is it completely loaded? (be aware of frames!)*/
while ($browser->Busy) {
com_message_pump(4000);
}
$im = imagegrabwindow($handle, 0);
$browser->Quit();
imagepng($im, "iesnap.png");
?>
[/php]

附加说明:
1、imagegrabscreen和imagegrabwindow只能在windows环境下工作
2、截图黑屏解决办法:web 服务器(iis或apache)做为windows服务时,必须打开”允许与桌面交互”的选项(点击服务属性->登录->勾选”允许与桌面交互”,设置后重启服务生效)。