#!/usr/bin/perl # # DW::Captcha # # This module handles CAPTCHA throughout the site # # Authors: # Afuna # # Copyright (c) 2010-2014 by Dreamwidth Studios, LLC. # # This program is free software; you may redistribute it and/or modify it under # the same terms as Perl itself. For a copy of the license, please reference # 'perldoc perlartistic' or 'perldoc perlgpl'. # =head1 NAME DW::Captcha - This module handles CAPTCHA throughout the site =head1 SYNOPSIS Here's the simplest method: # print out the captcha form fields on a particular page my $captcha = DW::Captcha->new( $page ); if ( $captcha->enabled ) { $captcha->print; } # elsewhere, process the post if ( $r->did_post ) { my $captcha = DW::Captcha->new( $page, %{$r->post_args} ); my $captcha_error; push @errors, $captcha_error unless $captcha->validate( err_ref => \$captcha_error ); } When using in conjunction with LJ::Widget subclasses, you can just specify the form field names and let the widget handle it: LJ::Widget->use_specific_form_fields( post => \%POST, widget => "...", fields => [ DW::Captcha->form_fields ] ) if DW::Captcha->enabled( 'create' ); In a controller+template pair, do this to generate the captcha HTML: $vars->{print_captcha} = sub { return DW::Captcha->new( $_[0] )->print; } # ... other vars go here ... return DW::Template->render_template( 'path/to/template.tt', $vars ); then, in template path/to/template.tt: [% print_captcha( 'page name' ) %] or [% print_captcha() %] =cut package DW::Captcha; use strict; use v5.10; use Log::Log4perl; my $log = Log::Log4perl->get_logger(__PACKAGE__); use LJ::MemCache; use LJ::ModuleLoader; use LJ::UniqCookie; my @CLASSES = LJ::ModuleLoader->module_subclasses("DW::Captcha"); my %impl2class; foreach my $class (@CLASSES) { eval "use $class"; die "Error loading class '$class': $@" if $@; $impl2class{ lc $class->name } = $class; } # class methods =head1 API =head2 C<< DW::Captcha->new( $page, %opts ) >> Arguments: =over =item page - the page we're going to display this CAPTCHA on =item a hash of additional options, including the request/response from a form post =back =cut sub new { my ( $class, $page, %opts ) = @_; # yes, I really do want to do this rather than $impl{...||$LJ::DEFAULT_CAPTCHA...} # we want to make certain that someone can't force all captchas off # by asking for an invalid captcha type my $impl = $LJ::CAPTCHA_TYPES{ delete $opts{want} || "" } || ""; my $subclass = $impl2class{$impl}; $subclass = $impl2class{ $LJ::CAPTCHA_TYPES{$LJ::DEFAULT_CAPTCHA_TYPE} } unless $subclass && $subclass->site_enabled; my $self = bless { page => $page, }, $subclass; $self->_init_opts(%opts); return $self; } # must be implemented by subclasses =head2 C<< $class->name >> The name used to refer to this CAPTCHA implementation. =cut sub name { return ""; } # object methods =head2 C<< $captcha->form_fields >> Returns a list of the form fields expected by the CAPTCHA implementation. =head2 C<< $captcha->site_enabled >> Whether CAPTCHA is enabled site-wide. (Specific pages may have CAPTCHA disabled) =head2 C<< $captcha->print >> Print the CAPTCHA form fields. =head2 C<< $captcha->validate( %opts ) >> Return whether the response for this CAPTCHA was valid. Arguments: =over =item opts - a hash of additional options, including the request/response from a form post and an error reference (err_ref) which may contain additional information in case the validation failed =back =head2 C<< $captcha->enabled( $page ) >> Whether this CAPTCHA implementation is enabled on this particular page (or sitewide if this captcha instance isn't tied to a specific page) Arguments: =over =item page - Optional. A specific page to check =back =head2 C<< $captcha->page >> Return the page that this CAPTCHA instance is going to be used with =head2 C<< $captcha->challenge >> Challenge text, provided by the CAPTCHA implementation =head2 C<< $captcha->response >> User-provided response text =head2 C<< $captcha->has_response >> Tests for existence of user-provided response text, returns true/false =cut # must be implemented by subclasses sub form_fields { qw() } sub site_enabled { return LJ::is_enabled('captcha') && $_[0]->_implementation_enabled ? 1 : 0 } # must be implemented by subclasses sub _implementation_enabled { return 1; } sub print { my $self = $_[0]; return "" unless $self->enabled; my $ret = "
"; $ret .= $self->_print; $ret .= "

" . LJ::Lang::ml( 'captcha.accessibility.contact', { email => $LJ::SUPPORT_EMAIL } ) . "

"; $ret .= "
"; return $ret; } # must be implemented by subclasses sub _print { return ""; } sub validate { my ( $self, %opts ) = @_; # if disabled, then it's always valid to allow the post to go through return 1 unless $self->enabled; $self->_init_opts(%opts); my $err_ref = $opts{err_ref}; # error catching for undefined page my $pageref = $self->page // ''; # captcha type, page captcha appeared on my $stat_tags = [ ( ref $self )->name, "page:$pageref" ]; if ( $self->challenge && $self->_validate ) { DW::Stats::increment( "dw.captcha.success", 1, $stat_tags ); return 1; } DW::Stats::increment( "dw.captcha.failure", 1, $stat_tags ); $$err_ref = LJ::Lang::ml('captcha.invalid'); return 0; } # must be implemented by subclasses sub _validate { return 0; } sub enabled { my $page; $page = $_[0]->page if ref $_[0]; $page ||= $_[1]; return $page ? $_[0]->site_enabled() && $LJ::CAPTCHA_FOR{$page} : $_[0]->site_enabled(); } # Integrated with our controller system to determine whether a user should # or shouldn't receive a captcha in this place. # # Returns whether or not we believe the user should receive a captcha at # this point based on request counting. Note, you should probably not use # this on things where you _definitely_ want a captcha, like creating # an account or something. sub should_captcha_view { my ( $class, $remote ) = @_; my $r = DW::Request->get; # Return unless we're enabled return 0 unless $LJ::CAPTCHA_HCAPTCHA_SITEKEY; # If we're completing a captcha, no captcha return 0 if $r->uri =~ m!^/captcha!; # If the user is logged in, no captcha return 0 if $remote; # If the path matches the bypass regex, no captcha if ($LJ::CAPTCHA_BYPASS_REGEX) { return 0 if $r->uri =~ $LJ::CAPTCHA_BYPASS_REGEX; } # If the user is on an automated IP range, captcha my ( $mckey, $ip ) = _captcha_mckey(); if ( my $matcher = $LJ::SHOULD_CAPTCHA_IP ) { return 0 unless $matcher->($ip); } # Unless the user is the Google bot... then allow it if ( my $matcher = $LJ::CAPTCHA_BYPASS_IP ) { return 0 if $matcher->($ip); } # Get our captcha information -- no information means that the user has not # passed a captcha. If we have information, then they *have* at some point. my $info_raw = LJ::MemCache::get($mckey); unless ($info_raw) { # Let's see if this is a repeat offender who is spamming requests at us # and hitting a bunch of 302s -- in which case, temp ban my $ip = $r->get_remote_ip; my $mckey = "cct:$ip"; my ( $last_seen_ts, $count ) = split( /:/, LJ::MemCache::get($mckey) // "0:0" ); if ( $last_seen_ts > 0 ) { # Subtract out my $intervals = int( ( time() - $last_seen_ts ) / $LJ::CAPTCHA_FRAUD_INTERVAL_SECS ); if ( $intervals > 1 ) { $count -= $LJ::CAPTCHA_FRAUD_FORGIVENESS_AMOUNT * $intervals; $count = 0 if $count < 0; } } # Set the counter $log->debug( $ip, ' has seen ', $count + 1, ' captcha requests.' ); LJ::MemCache::set( $mckey, join( ':', time(), $count + 1 ), $LJ::CAPTCHA_FRAUD_INTERVAL_SECS * $LJ::CAPTCHA_FRAUD_LIMIT ); # Now the trigger interval, if it's over, sysban this IP but just carry # on with rendering this page (simpler) if ( $count >= $LJ::CAPTCHA_FRAUD_LIMIT ) { $log->info( 'Banning ', $ip, ' for exceeding captcha fraud threshold.' ); LJ::Sysban::tempban_create( ip => $ip, $LJ::CAPTCHA_FRAUD_SYSBAN_SECS ); } return 1; } # This is a poor rate limit system but it might be good enough # for us, it's also poorly documented sorry my ( $first_req_ts, $last_req_ts, $remaining ) = map { $_ + 0 } split( /:/, $info_raw ); # If the first request is too long ago, then re-captcha if ( ( time() - $first_req_ts ) > $LJ::CAPTCHA_RETEST_INTERVAL_SECS ) { $log->info( $mckey, ' has exceeded the retest interval, issuing captcha.' ); return 1; } # First, refresh remaining according to time since last request my $delta_intervals = ( time() - $last_req_ts ) / $LJ::CAPTCHA_REFILL_INTERVAL_SECS; if ( $delta_intervals > 1 ) { # Only add more if we've waited at least an interval $remaining += int( $delta_intervals * $LJ::CAPTCHA_REFILL_AMOUNT ); $remaining = $LJ::CAPTCHA_MAX_REMAINING if $remaining > $LJ::CAPTCHA_MAX_REMAINING; $last_req_ts = time(); } # If we are out of requests, retest if ( $remaining <= 0 ) { $log->info( $mckey, ' is out of requests by usage, retesting.' ); return 1; } # Things look good, so let's allow this to continue but update remaining LJ::MemCache::set( $mckey, join( ':', $first_req_ts, $last_req_ts, $remaining - 1 ) ); return 0; } # Called when the captcha page has a successful captcha. sub record_success { # Return unless we're enabled return 0 unless $LJ::CAPTCHA_HCAPTCHA_SITEKEY; my $mckey = _captcha_mckey(); $log->debug( 'Captcha success for: ', $mckey ); LJ::MemCache::set( $mckey, join( ':', time(), time(), $LJ::CAPTCHA_INITIAL_REMAINING ) ); } # Reset the captcha counter, so the user starts getting them # again. Mostly used for debugging. sub reset_captcha { my $mckey = _captcha_mckey(); $log->debug( 'Resetting captcha for: ', $mckey ); LJ::MemCache::delete($mckey); } # Construct a redirect URL that will take us to get captchaed and # then back to whatever page we're on now sub redirect_url { my $r = DW::Request->get; my $uri = $r->uri; if ( my $qs = $r->query_string ) { $uri .= '?' . $qs; } my $host = $r->host; $uri = LJ::eurl( $host ? "https://$host$uri" : $uri ); return "$LJ::SITEROOT/captcha?returnto=$uri"; } sub _captcha_mckey { my $r = DW::Request->get; my $ip = $r->get_remote_ip; my $ip_trimmed = join( '.', ( split( /\./, $ip ) )[ 0 .. 2 ] ) . '.0'; my $uniq = LJ::UniqCookie->current_uniq; my $mckey = "$uniq:$ip_trimmed"; return wantarray ? ( $mckey, $ip ) : $mckey; } # internal method. Used to initialize the challenge and response fields # must be implemented by subclasses sub _init_opts { my ( $self, %opts ) = @_; # do something } sub page { return $_[0]->{page} } sub challenge { return $_[0]->{challenge} } sub response { return $_[0]->{response} } # return true if the captcha has a valid response # (use this instead of "if response" since correct response might be 0) sub has_response { my ($self) = @_; my $resp = $self->response; # this should only be false if the response is empty or zero characters return defined $resp && length $resp ? 1 : 0; } 1;