| OpenResty documentation | Contained in the OpenResty distribution. |
OpenResty::Handler::Captcha - The captcha handler for OpenResty
This OpenResty handler class implements the Captcha API, i.e., the /=/captcha/* stuff.
chaoslawful (王晓哲) <chaoslawful at gmail dot com>,
Agent Zhang (agentzh) <agentzh@yahoo.cn>
| OpenResty documentation | Contained in the OpenResty distribution. |
package OpenResty::Handler::Captcha; use strict; use warnings; use utf8; use File::ShareDir qw(dist_dir); use File::Spec; use Crypt::CBC; use MIME::Base64; use Digest::MD5 qw/md5/; use Encode qw( encode decode is_utf8 ); use base 'OpenResty::Handler::Base'; __PACKAGE__->register('captcha'); sub requires_acl { undef } sub level2name { qw< captcha_list captcha_column captcha_value >[$_[-1]] } my $FontPath = "$FindBin::Bin/../share/font/wqy-zenhei.ttf"; eval { if ( !-f $FontPath ) { $FontPath = File::Spec->catfile( dist_dir('OpenResty'), 'font/wqy-zenhei.ttf' ); if ( ! -f $FontPath || !-r $FontPath ) { warn "WARNING: Chinese font file $FontPath not found or not readable.\n"; } else { #warn "Found chinese font file $FontPath.\n"; } } }; if ($@) { warn $@; } my $PLAINTEXT_SEP = ":"; # separator character in plaintext str my $MIN_TIMESPAN = 1; # minimum timespan(sec) for a valid Captcha, # verification will fail before this timespan. # default to 3s. my $MAX_TIMESPAN = 3600; # maximum timespan(sec) for a valid Captcha, # Captcha will be invalid after this timespan. # default to 15m. my $Error; eval "use GD::SecurityImage;"; $Error = $@; # XXX TODO: We should put the dictionary to elsewhere... my @CnWordList = qw( 䏿¬æ£ç» ä¸å ä¸é ä¸åæç´¢ ä¸é¨ 严严å®å® ä¸¥å¯ ä¸°æ¶ ä¹é¾ ä¹±æä¸å¢ äºå¥æè³ äºå åè² äº¤é 人è¡é ä»ç± ä»ç¶ ä»å 代表 ä»°æ ä»°èµ· ä»·å¼ ä¼ æ ä¼ æ ä¼¼ä¹ ä½å¤´ ä½è´´ ä½¿å² ä¾ç¶ ä¿ç 修建 ä¿®ç åè£ å²æ ¢ å æ´ å å¾ å ¨é¨ å ³ç³» å ´å¥ å ´è¶£ å ´é«éç å°é å²å»å å³å¿ åå¤ åç¡®æ 误 åç½ åå° åè½» å ä¹ å¤å°¾ç«¹ å举 åé åå¤ åçª åé å ç´§ å¨å¬ 忢 åæ° å æ¬ åå¼ä¸å¤ åå¥ç¾æª å¡ç å±é© åå² åå åæ¾å软 å龿ç å¤è å¯å£ 坿 å°é¶ åç§åæ · åäºä¸ºä¸ 忢 åæ åå åè´µ å±ä»¬ åè¡ åè¢ åèæå¤© åé¦ åºç¶ 徿¡ å°è´¨å¦å®¶ ååº å¦å åªå åå¤´ä¸§æ° å¤§æå¤±è² 大æ¾ç¥å¨ å¤§æ¦ å¤§è ¿ å¤§è´ å¥æª å¥å 奿µä¸æ¯ å¥è· å¥½å¥ å¦å® å§¿å¿ å¨æ¦ å¨å«© 嫩绿 åéè å¦é® å®å® 宿¶ å®è´µ å®éª å®½è£ å¯å å¯å¯å±å± å¯»æ¾ å± ç¶ å±ç¤º 叿 å¹³æ¯ å¹³æ´ å¼äººæ³¨ç® å½å å½¢ç¶ å¾®çç© å¿æ å¿½ç¶ æ¼æ ææ æ åµ æ 绪 æè®¶ æ¿æ æ ¢åå æå¾ ææ´æ´ æå ææ æç¾¤ç»é æè æåº æä»¥ æå ææ æé¯ æå¹² ææ® æé æå¨ æ¢èµ° æ«ç² æ½åº æ å¿ æå¼ æå¼ ææ¶ æè®¿ æ¥æ± æ¼å½ æä¹ æç § æ¡ä½ æ¤æ¥æ¤å» æè¿·è æå£° æ¨å¨ æ¨æµ æé æå¼ ææ æ¶è æ¾å¤§é æäºº æè²å®¶ æ£æ¥ æ¬ç¤¼ æ¬é æé æ§å¤´ æ è¡ æ 论 æè« æ¾å¾®é æ¾ç¶ æ®éè¯ æç¤º æè¶£ æ¬è½ æ´ç´ æå¿ç¤¾ æé» æå æ¨æ æç¶ æ¿å æ æ¿ æ£æ¥ æ¤ç©å¦å®¶ 横跨 æ¬¢å± æ¬¢å¿« 欢蹦乱跳 æ¬£èµ æ¢å¢ æ°å³ æ°æ¯ æ±æ 油亮亮 æ²¿é æ³¨è§ æ´ç½ 浪费 æ·±è æ¸ å æ¸ é² æ¸ä¸å·¥äºº æ¸¸æ æ¹¿åº¦ æ»æ¶¦ æ¿å¨ çç ç®å£ çç çé¹ ç §ç¸æº ç¬å±± ç©äº§ä¸°å¯ çæµ ç®åº ç©å · ç©æ ç©è çä¼ ç¶å çè çå¿ çæ ç½åèè çå¼ ç¸æå¹¶è®º ç¸è· ç¼æ çå® ç¼é ç³æ ç ç©¶ 确确å®å® 磨å ç¥ç¥è¾è¾ ç¥ç¦ ç¥æ° ç§ä¹¦ ç§¦å² ç©¿æ´ çªç¶ ç«å» ç«å³ 第ä¸è¯¾ çå ç®å ç®æ¯ ç²å£® ç²®é£ ç²¾å¿ ç²¾ç¾ ç´§å¼ çºªå¿µ çº³é· çº¸è¢ ç»å¾® ç»äº ç»æ¯ ç»äº ç»§ç» ç»³å ç¾è§ èå¯ èè¤ è¥æ è¯å® èå©è è¶å· è¸è¯ èªå« èªè¨èªè¯ è头 è³ä¸½ èç è¬è³è¿·äºº è±ç£ èé èå¯ èç è¶æ¯ èå è¯æ è·å¾ è è èå è´è¶ è¡æ¶² è§å¯ è§çº¿ è®°å¿å è®°è 讲述 设计 è¯æ è¯æ¢ è¯å® 请æ è°è è°¦è è¶ å¸¸ è·¯é èº²éª è½¬å 转æ¥è½¬å» è½®æµ è¾«å è¾½é è¿å è¿äº è¿æ» è¿è¿é»å 迷失 éå® éåº é产 é迹 饿 é¥è¿ é®ç¥¨ éå¤ éé éé±¼ éé éç é¿å¤ é¿è¿ é 读 é»å éç» éç é便 éæ é¾è¿ éä¼ éå éè¦ éæ é¢å 渣 é¡¶å³° é¡ºå© é¢è² é£å°ä»ä» 飿¯ä¼ç¾ 飿¬ é£é£ææ 飿£ é£è 馿¬¡ é¦ç éªå² é«ä½ä¸å¹³ é²å«© 黿 é¼å± ); my @WordList = qw( about afraid after again against agree almost along angry another answers arms around away back ball basket become begin better boat boating books booth born both boxes boys bread brush burn buses busy cake call camping capital care careful carry cars catch centre cheaper chess china cinema city class clean clever coat cold college comb come country course cups dark days decide declare deed deliver develop dinner dirty doctor doing door down dress drive each early east eight eleven enjoy evening every exam excited excuse fall family famous fast faster father feel fever fifty film fine finish first fish fishing five floor food foot forty four friend friends from front full funny future games give glad goal good goodbye grade ground grow hair half hand hands happen hard have head healthy hear heavily help here high hill history hold hole home hour hours house hundred hungry hurry hurt idea instead into invite jump just keep kind kinds knock know ladder lake largest last late learn leave left lesson lessons letter letters life lights like listen little live long longer look lunch machine make many market match matter meals medical meeting message middle million minute model modern moment money month more morning most move much music name near nearly need news next nice night nine north number office once oneself only open other over pair papers parents parking party pass past people piano pick piece place plane plant play player plenty points post prepare primary promise pulling quarter quick quietly race rain read ready rest result return right road room roses round rules rush school scoot send seven several ship shirt shoes shoot short show shower side sides signs singing sixty skate skating slim slow slowly smile snowman some soon sorry south spare speak sports square squares stamps stand start station stay stop store story street student studies study such summer supper table take talk tall teach teacher team teeth tell test thank that them then there these thin thing things think thirty this three through ticket time times today traffic train tree trees trip trouble turn twenty twice under until very view visit voice wait wake walk wall want wash washing watch ways wear week weeks welcome well west what when window winter wish with word work world worried write wrong year your hello moon ); # Create a normal Captcha ID sub GET_captcha_column { my ( $self, $openresty, $bits ) = @_; my $col = $bits->[1]; if ( $col eq 'id' ) { # Get captcha language param my $lang = lc( $openresty->builtin_param('_lang') ) || 'en'; # Generate captcha solution in the language my $solution; if ( $lang eq 'cn' ) { $solution = $self->gen_cn_solution($openresty); } elsif ( $lang eq 'en' ) { $solution = $self->gen_en_solution($openresty); } else { die "Unsupported lang (only cn and en allowed): $lang\n"; } my $user = $openresty->{_user}; if ( $OpenResty::Config{"frontend.test_mode"} && $user && $user eq 'tester' ) { # We are in the testing mode $MIN_TIMESPAN = 1; # change min valid timespan to 1s $MAX_TIMESPAN = 3; # change max valid timespan to 3s } # Generate min and max valid timestamp my $min_valid = time() + $MIN_TIMESPAN; my $max_valid = time() + $MAX_TIMESPAN; my $id = encrypt_captcha_id( $openresty, $lang, $solution, $min_valid, $max_valid ); return $id; } else { die "Unknown captcha column: $col\n"; } } sub GET_captcha_value { my ( $self, $openresty, $bits ) = @_; my $col = $bits->[1]; my $value = $bits->[2]; if ($Error) { die "Captchas support not available on this server.\n"; } my $ext = 'gif'; if ( $value =~ s/\.(gif|jpg|png|jpeg)$//g ) { $ext = $1; if ( $ext eq 'jpg' ) { $ext = 'jpeg' } } if ( $col eq 'id' ) { my $id = $value; # Decrypt captcha id to get info about the captcha my ( $rand1, $lang, $solution, $min_valid, $max_valid, $rand2 ) = decrypt_captcha_id( $openresty, $id ); # Exit if the captcha id is in wrong format die "Invalid captcha ID: $id\n" unless defined($solution); # Exit if the max valid time of the captcha has expired die "Captcha ID has expired: $id\n" if $max_valid < time(); # Generate image according to captcha info if ( $lang eq 'cn' ) { $self->gen_cn_image( $openresty, $solution ); } elsif ( $lang eq 'en' ) { $self->gen_en_image( $openresty, $solution ); } else { die "Unsupported lang (only cn and en allowed): $lang\n"; } } else { die "Unknown captcha column: $col\n"; } } sub gen_en_solution { my ( $self, $openresty ) = @_; my $str = ''; my $list = \@WordList; my ( $i, $j ) = ( 0, 0 ); while ( $i < 2 ) { last if $j > 100; my $rand = int rand scalar(@$list); my $saved_str = $str; $str .= $list->[$rand] . " "; my $len = length($str); if ( $len >= 15 ) { $str = $saved_str; $j++; next; } $i++; } $str; # XXX debug only } sub gen_cn_solution { my ( $self, $openresty ) = @_; my $str = ''; my $list = \@CnWordList; my ( $i, $j ) = ( 0, 0 ); while ( $i < 2 ) { last if $j > 100; my $rand = int rand scalar(@$list); my $saved_str = $str; $str .= $list->[$rand]; my $len = length($str); last if $len == 3; if ( $len >= 5 ) { $str = $saved_str; $j++; next; } $i++; } $str; # XXX debug only } sub gen_cn_image { my ( $self, $openresty, $str ) = @_; my $angle = int rand 4; my $captcha = GD::SecurityImage->new( width => 100, height => 37, lines => 2 + int rand 2, font => $FontPath, #thickness => 0.5, rndmax => 3, angle => $angle, ptsize => 15, #send_ctobg => 1, #scramble => 1, ); #warn $str; $captcha->random($str); $captcha->create( ttf => 'default' ); die "Failed to load ttf font for GD: $@\n" if $captcha->gdbox_empty; $captcha->particle(300); # : 1732); my ( $image_data, $mime_type ) = $captcha->out( compress => 1 ); $openresty->{_bin_data} = $image_data; $openresty->{_type} = "image/$mime_type"; ### $mime_type } sub gen_en_image { my ( $self, $openresty, $str ) = @_; my $angle = 2 + int rand 4; my $captcha = GD::SecurityImage->new( width => 130, height => 30, lines => 1, gd_font => 'giant', #thickness => 0.5, rndmax => 3, angle => $angle, #ptsize => 80, #send_ctobg => 1, #scramble => 1, ); #warn $str; $captcha->random($str); $captcha->create( normal => 'rect' ); $captcha->particle(100); # : 1732); my ( $image_data, $mime_type ) = $captcha->out( compress => 1 ); $openresty->{_bin_data} = $image_data; $openresty->{_type} = "image/$mime_type"; ### $mime_type } sub trim_sol { my $s = $_[0]; unless ( is_utf8($s) ) { $s = decode( 'UTF-8', $s ); } $s =~ s/\W+//g; $s; } sub validate_captcha { my ( $openresty, $id, $word ) = @_; my ( $rand1, $lang, $solution, $min_valid, $max_valid, $rand2 ) = decrypt_captcha_id( $openresty, $id ); # validate failed if the captcha id is in wrong format return ( 0, "Captcha ID format is incorrect." ) unless defined($solution); # wrong format # change true solution for testing purpose if ( $OpenResty::Config{"frontend.test_mode"} && $openresty->{_user} eq 'tester' ) { if ( $lang eq 'en' ) { $solution = 'hello world '; } else { $solution = 'ä½ å¥½ä¸ç'; } } # validate failed if the captcha id has expired or not allowed to validate yet my $now = time(); return ( 0, "Answered too quickly." ) if $min_valid > $now; # ans too early return ( 0, "Captcha ID has expired." ) if $max_valid < $now; # ans too late # Construct cache key for captcha id. We cannot use captcha id directly, for the id itself # could be appending any characters without affecting its decryption. # Prepending "captcha" to prevent cache key confliction... # NOTE: the solution used here should not contains whitespaces, because it will be used as # part of the cache key! my $cache_key = join( $PLAINTEXT_SEP, "captcha", $rand1, $lang, trim_sol($solution), $min_valid, $max_valid, $rand2 ); utf8::encode($cache_key); # validate failed if the captcha id has been used my $used = $OpenResty::Cache->get($cache_key); return ( 0, "The captcha has been used." ) if $used; # ans used # validate failed if user input doesn't match the solution in captcha id #warn "Expected solution: $word\n"; #warn "Real solution: $solution\n"; return ( 0, "Solution to the captcha is incorrect." ) if trim_sol($word) ne trim_sol($solution); # wrong ans # validate succeed, remember which captcha id has been used #warn $cache_key, "\n"; $OpenResty::Cache->set( $cache_key => 1, $MAX_TIMESPAN ); return ( 1, "Verification succeeded." ); } sub decrypt_captcha_id { my ( $openresty, $id ) = @_; return () if !defined($id); $id = decode_base64_urlsafe($id); my ( $digest, $cipher ) = unpack( "a16a*", $id ); my $secret = get_captcha_secretkey($openresty); my $algo = Crypt::CBC->new( -key => $secret, -header => 'none', -iv => $secret, -cipher => 'Rijndael', ); my $plain = $algo->decrypt($cipher); return () unless $digest eq md5($plain); my ( $rand1, $lang, $solution, $min_valid, $max_valid, $rand2 ) = split( $PLAINTEXT_SEP, $plain ); return () unless defined($lang) && defined($solution) && defined($min_valid) && defined($max_valid); return () unless $min_valid > 0 && $max_valid > 0; return () unless defined($rand1) && $rand1 >= 0 && $rand1 < 10000; return () unless $rand1 == $rand2; return ( $rand1, $lang, $solution, $min_valid, $max_valid, $rand2 ); } sub encrypt_captcha_id { my ( $openresty, $lang, $solution, $min_valid, $max_valid ) = @_; my $rand = int( rand(10000) ); # Add random number before and after captcha parameters # in order to maximum obfuscate the cipher my $plain = join( $PLAINTEXT_SEP, $rand, $lang, $solution, $min_valid, $max_valid, $rand ); my $secret = get_captcha_secretkey($openresty); my $algo = Crypt::CBC->new( -key => $secret, -header => 'none', -iv => $secret, -cipher => 'Rijndael', ); my $cipher = $algo->encrypt($plain); # Generate a 16-byte MD5 signature for plaintext, to further protect cipher utf8::encode($plain); # XXX: eliminating md5() i/o error my $digest = md5($plain); return encode_base64_urlsafe( $digest . $cipher ); } sub get_captcha_secretkey { my $openresty = shift; # 128 bits secret key for encryption/decryption # Prepending "captcha:" to prevent cache key conflication... my $key = $OpenResty::Cache->get("captcha:key"); return $key if defined($key) && length($key) == 16; # Protect current user for restoring later, we don't want to break the outter context... my $cur_user = $openresty->current_user; $openresty->set_user("_global"); my $res = $openresty->select( "select captcha_key from _global._general", { use_hash => 1 } ); die "Unable to retrieve captcha secret key" unless defined($res) && @$res > 0 && exists $res->[0]{captcha_key}; $openresty->set_user($cur_user) if defined($cur_user); $key = $res->[0]{captcha_key}; die "Captcha secret key length invalid, should be exactly 16 bytes" unless length($key) == 16; # Cache the captcha secret key for 1 day # Prepending "captcha:" to prevent cache key conflication... $OpenResty::Cache->set( "captcha:key" => $key, 3600 * 24, 'trivial' ); return $key; } sub encode_base64_urlsafe { # base64 encode the given value and substitute URL-unsafe characters to URL-safe ones ( my $base64 = encode_base64( shift, "" ) ) =~ y!+/!._!; # remove the extra newline appended by encode_base64() chomp($base64); $base64 =~ s/=//g; return $base64; } sub decode_base64_urlsafe { # substitute URL-safe characters to original ones ( my $base64 = shift ) =~ y!._!+/!; my $result; { # suppress decode_base64() warnings (premature end of data, etc.) local $^W = 0; $result = decode_base64($base64); } return $result; } 1; __END__