The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.

NAME

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

AUTHOR

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

VERSION

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

DESCRIPTION

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

THE OLD WAY

WORKFLOWS

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

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

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

DRAWBACKS

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

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

THE RECONSTRUCTED WAY

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

WORKFLOWS

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

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

SPECIFICATIONS

CAPTCHA ID PLAINTEXT FORMAT

Captcha id 明文格式为:

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

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

<lang>

验证字语言标识字符串,目前为 en(表示使用英文 Captcha)或 cn(表示使用中文 Captcha)。

<solution>

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

<min_ts>

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

<max_ts>

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

<rand>

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

CAPTCHA ID ENCRYPTION/DECRYPTION

最终的 Captcha id 是原始的 Captcha id 明文串经过 AES 加密(通过 Crypt::RijndaelCrypt::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 字符串比较前一定要注意转换编码!