=encoding UTF-8

=head1 NAME

OpenResty::Spec::Captcha_cn - Captcha 图片生成和验证

=head1 AUTHOR

chaoslawful (王晓哲) <chaoslawful@gmail.com>

=head1 VERSION

    CREATED:       Apr 11, 2007
    LAST MODIFIED: Apr 11, 2007
    VERSION:       0.01

=head1 DESCRIPTION

本文描述了 OpenResty 系统中 Captcha 图片生成和验证过程及相关算法，以便在 Captcha 模块重构过程中作为依据。

=head1 THE OLD WAY

=head2 WORKFLOWS

OpenResty 系统中原有的 Captcha 图片生成基于 C<GD::SecurityImage> 模块，可以做出英文或中文 Captcha 图片 (中文字体使用文泉驿 TTF 字体)。

当前端请求生成一个 Captcha id 时， C<Handler::Captcha> 模块生成一个 UUID 记录在缓存系统中，设定过期时间为 2 小时，然后将其作为 Captcha 图片唯一标识返回；在前端用 Captcha id 请求图片内容时， C<Handler::Captcha> 模块会检查该 id 是否存在于缓存系统中。若 id 已存在，则表示该 Captcha 请求是在 2 小时时间窗口内生成的有效请求，模块将根据请求参数中设定的语言类型随机生成英文 (默认) 或中文的验证字并输出对应的图片内容，验证字随后在缓存系统中同该 id 关联起来，以备验证之用；若 id 不存在，则该 Captcha 请求可能是过期或根本没有生成过的请求，模块将拒绝继续服务。

当前端给出一个 id 和用户给出的对应解，请求验证 Captcha 时， C<Handler::Login> 模块会从缓存系统中查找该 id 对应的内容，若该 id 不存在、不对应有效验证字或给出的解同保存的验证字不相符，则认为用户没有通过 Captcha 验证，拒绝继续服务；否则就认为用户通过了 Captcha 验证，将该 id 对应的信息从系统缓存中移除后继续进行其他验证过程。

=head2 DRAWBACKS

=over

=item *

在 2 小时内生成的每个 Captcha id 都必须记录在系统缓存中。当访问量较大，系统缓存不足以容纳这一时间窗口内所有的 Captcha 信息时，基于 LRU 的缓存策略可能使某些较早生成却还没来得及进行验证的 Captcha id 被换出缓存丢弃，这样使用该 id 对应图片的用户就无法通过验证或更换 Captcha 图片，只有重新开始登录过程，影响了用户体验。系统缓存越小，这一问题表现越明显；

=item *

有极小的概率 (1/2**128 ，即 UUID 的冲突概率) 可能出现两个独立的 Captcha 请求获得的却是相同的 id ，这样会产生不可预料的 Captcha 验证结果，迫使用户重新开始登录过程，影响了用户体验。

=back

=head1 THE RECONSTRUCTED WAY

重构后的 Captcha 图片生成部分维持不变，将 Captcha 验证接口也集成在 C<Handler::Captcha> 模块里，以便将 Captcha 功能相关接口维持在同一个地方。

=head2 WORKFLOWS

当前端请求生成一个 Captcha 时，将真实的 Captcha 验证字、最短有效时刻、最长有效时刻和随机数拼接起来，使用对称加密算法加密后作为 Captcha id；在前端用 Captcha id 请求图片内容时， C<Handler::Captcha> 模块使用系统密钥尝试解密 Captcha id，若解密后的内容同拼接时的格式相同，且当前时间早于最长有效时刻，则根据解出的 Catpcha 验证字生成对应的图片内容；若解密后的内容格式有误，或格式正确但当前时间晚于最长有效时刻，则表示 Captcha id 无效，模块将拒绝继续服务。

当前端给出一个 Captcha id 和用户给出的对应解，请求验证 Captcha 时， C<Handler::Login> 模块会调用 C<Handler::Captcha> 模块中的验证例程进行验证。该例程首先检查 Captcha id 是否已记录在系统缓存中，若缓存里没有记录则使用系统密钥尝试解密 Captcha id，若解密后的内容同拼接时的格式相同，且当前时间晚于最短有效时刻（Captcha 若被过快解答，则很可能是程序在进行破解）而早于最长有效时刻，则将解出的真实 Captcha 验证字同用户解进行不区分大小写的比较，比较通过则表示 Captcha 验证通过，此时再将 Captcha id 以一定的过期时间（由对应的最长有效时刻计算得出）记录在系统缓存里，避免恶意用户使用相同的 id 和验证字进行反复验证；若以上条件未得到满足，则 Captcha 验证失败，拒绝继续服务。

=head2 SPECIFICATIONS

=head3 CAPTCHA ID PLAINTEXT FORMAT

Captcha id 明文格式为：

	<rand> : <lang> : <solution> : <min_ts> : <max_ts> : <rand>

其中各个部分的含义如下：

=over

=item <lang>

验证字语言标识字符串，目前为 C<en>（表示使用英文 Captcha）或 C<cn>（表示使用中文 Captcha）。

=item <solution>

验证字明文字符串，非英文字符使用 UTF-8 编码，例如“hello”或“一本正经”。

=item <min_ts>

Captcha 最短有效时刻，以 UNIX 时戳形式表示，例如“1208357712”。Captcha 的验证时刻不能早于该值，否则一定失败。

=item <max_ts>

Captcha 最长有效时刻，以 UNIX 时戳形式表示，例如“1208361326”。Captcha 的验证时刻不能晚于该值，否则一定失败，且对应的 Captcha 图片也不会被生成。

=item <rand>

随机正整数，用来扰码，以避免连续产生的相同内容字串在加密后产生相同的密文。

=back

=head3 CAPTCHA ID ENCRYPTION/DECRYPTION

最终的 Captcha id 是原始的 Captcha id 明文串经过 AES 加密（通过 C<Crypt::Rijndael> 和 C<Crypt::CBC> 模块）后再 Base64 编码、最后再将 [+/] 分别替换为 [._] （以便在 URL 中引用）、去掉末尾填充用的 = 后的结果，同时在前面附加了经过相同 Base64 编码步骤的对明文串的 MD5 摘要。例如，若 Captcha id 明文串为：

	15768:cn:测试一下:1208357712:1208361326:15768

若选择密钥为 16 个 a（128 bits 密钥），则最终生成的 Captcha id 将为：

	x4MHdt6WW_yjP8Ip6hm1mQAHui6sX6dTuKSUHNjl9TUDDKHWlLfi5mOGZ11Hu01_HR_zmc4x8_V4fqqvnIfBZUmmibdmCSBYT.DEMCI6oRmg

加密的示例代码如下：

	use Crypt::CBC;
	use MIME::Base64;
	use Digest::MD5 'md5_base64';
	my $rand=15768;
	my $plain=join(":",$rand,'cn','测试一下','1208357712','1208361326',$rand);
	my $key='a' x 16;
	my $algo=Crypt::CBC->new(
			-key=>$key,
			-header=>'none',
			-iv=>$key,
			-cipher=>'Rijndael',
			);
	my $cipher=$algo->encrypt($plain);
	my $digest=md5_base64($plain);
	my $result=$digest.encode_base64($cipher,"");
	$result=~s/=//g;
	$result=~y!+/!._!;
	print "Captcha ID: $result\n";

解密的示例代码如下：

	use Crypt::CBC;
	use MIME::Base64;
	use Digest::MD5 'md5_base64';
	my $result='x4MHdt6WW_yjP8Ip6hm1mQAHui6sX6dTuKSUHNjl9TUDDKHWlLfi5mOGZ11Hu01_HR_zmc4x8_V4fqqvnIfBZUmmibdmCSBYT.DEMCI6oRmg';
	my $key='a' x 16;
	my $algo=Crypt::CBC->new(
			-key=>$key,
			-header=>'none',
			-iv=>$key,
			-cipher=>'Rijndael',
			);
	(my $base64=$result)=~y!._!+/!;
	my ($digest,$cipher)=unpack("a22a*",$base64);
	$cipher=decode_base64($cipher);
	my $plain=$algo->decrypt($cipher);
	my ($rand1,$lang,$solution,$min_ts,$max_ts,$rand2)=split(":",$plain);
	if($digest ne md5_base64($plain)) {
		print "Invalid captcha ID!\n";
		exit;
	}
	print "$rand1,$lang,$solution,$min_ts,$max_ts,$rand2\n";

另外，解密后的结果为字节串，在同 Perl-Native 字符串比较前一定要注意转换编码！

