4 * Object encapsulating a captcha process. The captcha has two elements: it must be able
5 * to generate a frontend HTML representation of itself which can be presented to the user,
6 * which provides inputs for users to provide their interpretation of the captcha; and it
7 * must be able to retrieve that data from a subsequently-submitted request and validate
8 * whether the user got the data correct.
10 abstract class Captcha {
18 * Information about the captcha, in array form
24 * Whether this captcha exists in the storage
30 * Generate a new empty Captcha. This is guaranteed to return a Captcha object if it
31 * does not throw an exception
33 * @return Captcha subclass
35 public final static function factory() {
36 global $wgCaptchaClass;
37 $obj = new $wgCaptchaClass;
38 if ( $obj instanceof Captcha ) {
41 throw new MWException( "Invalid Captcha class $wgCaptchaClass, must extend Captcha" );
46 * Instantiate a new Captcha object for a given Id
51 public final static function newFromId( $id ){
52 $obj = self::factory();
60 * Instantiate a brand new captcha, never seen before.
64 public final static function newRandom(){
65 $obj = self::factory();
71 * Protected constructor - use only the factory methods above to instantiate captchas,
72 * or you may end up with the wrong type of object
74 protected function __construct(){}
81 public function getId(){
86 * Set the Id internally. Don't include wierd things like entities or characters that
87 * need to be HTML-escaped, you'll just be creating more work and pain for yourself...
91 protected function setId( $id ){
96 * Initialise $this->info etc with information needed to make this object a new,
97 * (ideally) never-seen-before captcha. Implementations should not save the data in
98 * the store in this function, as the captcha may not ever be used.
100 * @return Array of captcha info
103 protected abstract function generateNew();
106 * Save a generated captcha in storage somewhere where it won't be lost between
107 * requests. A random ID is used so legit users can make edits in multiple tabs
108 * or windows without being unnecessarily hobbled by a serial order requirement.
110 protected function store() {
111 // Assign random index if we're not udpating
112 if ( !isset( $this->info['index'] ) ) {
113 if( !$this->getId() ){
114 $this->setId( strval( mt_rand() ) );
116 $this->info['index'] = $this->getId();
118 CaptchaStore::get()->store( $this->info['index'], $this->info );
122 * Fetch the data for this captcha from the CaptchaStore. This requires $this->id
125 * @return Array|Bool: Array of info, or false if missing
127 protected function retrieve() {
128 if( $this->getId() === null ){
131 if( $this->info === null ){
132 $this->info = CaptchaStore::get()->retrieve( $this->getId() );
133 $this->exists = $this->info !== false;
139 * Clear the information about this captcha from the CaptchaStore, so it cannot
140 * be reused at a later date.
142 protected function delete() {
143 if( $this->getId() !== null ){
144 CaptchaStore::get()->clear( $this->getId() );
149 * Whether this captcha exists. $this->setId() must have been called from some context
153 public function exists(){
154 if( $this->exists === null ){
157 return $this->exists;
161 * Load some data from a WebRequest. Implementations must load all data they need
162 * from the request in this function, they must not use the global $wgRequest, as
163 * in the post-1.18 environment they may not necessarily be the same.
165 * @param $request WebRequest
166 * @param $field HTMLCaptchaField will be passed if the captcha is part of an HTMLForm
168 public abstract function loadFromRequest( WebRequest $request, HTMLCaptchaField $field = null );
171 * Return the data that would be needed to pass the captcha challenge through the API.
172 * Implementations must return an array with at least the following parameters:
173 * 'type' - a unique description of the type of challenge. This could be
175 * 'mime' - the MIME type of the challenge
176 * 'id' - the captcha Id produced by getId()
177 * Implementations should document how the user should use the provided data to answer
180 * Implementations may return False to indicate that it is not possible to represent
181 * the challenge via the API. API actions protected by such a captcha will be disabled.
185 public abstract function getApiParams();
188 * Return the HTML which will be placed in the 'input' table cell of an HTMLForm.
189 * Implementations must include input fields which will perpetuate the captcha Id and
190 * any special data, as well as providing a means for the user to answer the captcha.
191 * Implementations should not include any help or label text, as these will be set in
192 * the label-message and help-message attributes of the HTMLCaptchafield.
193 * Implementations should honour the options set in the HTMLFormField such as
194 * $field->mName and $field->mReadonly.
196 * @param $field HTMLCaptchaField
197 * @return String raw HTML
199 public abstract function getFormHTML( HTMLCaptchaField $field );
202 * Return the HTML which will be used in legacy forms which do not implement HTMLForm
203 * Implementations must include input fields which will perpetuate the captcha Id and
204 * any other necessary data, as well as providing a means for the user to answer the
205 * captcha, and any relevant descriptions and instructions.
207 * @return String raw HTML
209 public abstract function getFreeflowHTML();
212 * Using the parameters loaded from the web request, check the captcha, maybe delete
213 * it if that's desirable, do any other necessary cleanup, and return Bool
214 * @return Bool whether the captcha was successfully answered
216 public abstract function checkCaptcha();
219 class SimpleCaptcha {
226 function __construct() {
227 global $wgCaptchaStorageClass;
228 if( in_array( 'CaptchaStore', class_implements( $wgCaptchaStorageClass ) ) ) {
229 $this->storage = new $wgCaptchaStorageClass;
231 throw new MWException( "Invalid CaptchaStore class $wgCaptchaStorageClass" );
235 function getCaptcha() {
236 $a = mt_rand( 0, 100 );
237 $b = mt_rand( 0, 10 );
239 /* Minus sign is used in the question. UTF-8,
240 since the api uses text/plain, not text/html */
241 $op = mt_rand( 0, 1 ) ? '+' : '−';
244 $answer = ( $op == '+' ) ? ( $a + $b ) : ( $a - $b );
245 return array( 'question' => $test, 'answer' => $answer );
248 function addCaptchaAPI( &$resultArr ) {
249 $captcha = $this->getCaptcha();
250 $index = $this->storeCaptcha( $captcha );
251 $resultArr['captcha']['type'] = 'simple';
252 $resultArr['captcha']['mime'] = 'text/plain';
253 $resultArr['captcha']['id'] = $index;
254 $resultArr['captcha']['question'] = $captcha['question'];
258 * Insert a captcha prompt into the edit form.
259 * This sample implementation generates a simple arithmetic operation;
260 * it would be easy to defeat by machine.
264 * @return string HTML
267 $captcha = $this->getCaptcha();
268 $index = $this->storeCaptcha( $captcha );
270 return "<p><label for=\"wpCaptchaWord\">{$captcha['question']}</label> = " .
271 Xml::element( 'input', array(
272 'name' => 'wpCaptchaWord',
273 'id' => 'wpCaptchaWord',
274 'tabindex' => 1 ) ) . // tab in before the edit textarea
276 Xml::element( 'input', array(
278 'name' => 'wpCaptchaId',
279 'id' => 'wpCaptchaId',
280 'value' => $index ) );
284 * Insert the captcha prompt into an edit form.
285 * @param OutputPage $out
287 function editCallback( &$out ) {
288 $out->addWikiText( $this->getMessage( $this->action ) );
289 $out->addHTML( $this->getForm() );
293 * Show a message asking the user to enter a captcha on edit
294 * The result will be treated as wiki text
296 * @param $action Action being performed
299 function getMessage( $action ) {
300 $name = 'captcha-' . $action;
301 $text = wfMsg( $name );
302 # Obtain a more tailored message, if possible, otherwise, fall back to
303 # the default for edits
304 return wfEmptyMsg( $name, $text ) ? wfMsg( 'captcha-edit' ) : $text;
309 * @fixme if multiple thingies insert a header, could break
310 * @param $form HTMLForm
311 * @return bool true to keep running callbacks
313 function injectEmailUser( &$form ) {
314 global $wgCaptchaTriggers, $wgOut, $wgUser;
315 if ( $wgCaptchaTriggers['sendemail'] ) {
316 if ( $wgUser->isAllowed( 'skipcaptcha' ) ) {
317 wfDebug( "ConfirmEdit: user group allows skipping captcha on email sending\n" );
320 $form->addFooterText(
321 "<div class='captcha'>" .
322 $wgOut->parse( $this->getMessage( 'sendemail' ) ) .
331 * @fixme if multiple thingies insert a header, could break
332 * @param QuickTemplate $template
333 * @return bool true to keep running callbacks
335 function injectUserCreate( &$template ) {
336 global $wgCaptchaTriggers, $wgOut, $wgUser;
337 if ( $wgCaptchaTriggers['createaccount'] ) {
338 if ( $wgUser->isAllowed( 'skipcaptcha' ) ) {
339 wfDebug( "ConfirmEdit: user group allows skipping captcha on account creation\n" );
342 $template->set( 'header',
343 "<div class='captcha'>" .
344 $wgOut->parse( $this->getMessage( 'createaccount' ) ) .
352 * Inject a captcha into the user login form after a failed
353 * password attempt as a speedbump for mass attacks.
354 * @fixme if multiple thingies insert a header, could break
355 * @param $template QuickTemplate
356 * @return bool true to keep running callbacks
358 function injectUserLogin( &$template ) {
359 if ( $this->isBadLoginTriggered() ) {
361 $template->set( 'header',
362 "<div class='captcha'>" .
363 $wgOut->parse( $this->getMessage( 'badlogin' ) ) .
371 * When a bad login attempt is made, increment an expiring counter
372 * in the memcache cloud. Later checks for this may trigger a
373 * captcha display to prevent too many hits from the same place.
375 * @param string $password
376 * @param int $retval authentication return value
377 * @return bool true to keep running callbacks
379 function triggerUserLogin( $user, $password, $retval ) {
380 global $wgCaptchaTriggers, $wgCaptchaBadLoginExpiration, $wgMemc;
381 if ( $retval == LoginForm::WRONG_PASS && $wgCaptchaTriggers['badlogin'] ) {
382 $key = $this->badLoginKey();
383 $count = $wgMemc->get( $key );
385 $wgMemc->add( $key, 0, $wgCaptchaBadLoginExpiration );
387 $count = $wgMemc->incr( $key );
393 * Check if a bad login has already been registered for this
394 * IP address. If so, require a captcha.
398 function isBadLoginTriggered() {
399 global $wgMemc, $wgCaptchaBadLoginAttempts;
400 return intval( $wgMemc->get( $this->badLoginKey() ) ) >= $wgCaptchaBadLoginAttempts;
404 * Check if the IP is allowed to skip captchas
406 function isIPWhitelisted() {
407 global $wgCaptchaWhitelistIP;
408 if ( $wgCaptchaWhitelistIP ) {
410 foreach ( $wgCaptchaWhitelistIP as $range ) {
411 if ( IP::isInRange( $ip, $range ) ) {
420 * Internal cache key for badlogin checks.
424 function badLoginKey() {
425 return wfMemcKey( 'captcha', 'badlogin', 'ip', wfGetIP() );
429 * Check if the submitted form matches the captcha session data provided
430 * by the plugin when the form was generated.
434 * @param string $answer
438 function keyMatch( $answer, $info ) {
439 return $answer == $info['answer'];
442 // ----------------------------------
445 * @param EditPage $editPage
446 * @param string $action (edit/create/addurl...)
447 * @return bool true if action triggers captcha on editPage's namespace
449 function captchaTriggers( &$editPage, $action ) {
450 global $wgCaptchaTriggers, $wgCaptchaTriggersOnNamespace;
451 // Special config for this NS?
452 if ( isset( $wgCaptchaTriggersOnNamespace[$editPage->mTitle->getNamespace()][$action] ) )
453 return $wgCaptchaTriggersOnNamespace[$editPage->mTitle->getNamespace()][$action];
455 return ( !empty( $wgCaptchaTriggers[$action] ) ); // Default
459 * @param EditPage $editPage
460 * @param string $newtext
461 * @param string $section
462 * @return bool true if the captcha should run
464 function shouldCheck( &$editPage, $newtext, $section, $merged = false ) {
466 $title = $editPage->mArticle->getTitle();
469 if ( $wgUser->isAllowed( 'skipcaptcha' ) ) {
470 wfDebug( "ConfirmEdit: user group allows skipping captcha\n" );
473 if ( $this->isIPWhitelisted() )
477 global $wgEmailAuthentication, $ceAllowConfirmedEmail;
478 if ( $wgEmailAuthentication && $ceAllowConfirmedEmail &&
479 $wgUser->isEmailConfirmed() ) {
480 wfDebug( "ConfirmEdit: user has confirmed mail, skipping captcha\n" );
484 if ( $this->captchaTriggers( $editPage, 'edit' ) ) {
485 // Check on all edits
487 $this->trigger = sprintf( "edit trigger by '%s' at [[%s]]",
489 $title->getPrefixedText() );
490 $this->action = 'edit';
491 wfDebug( "ConfirmEdit: checking all edits...\n" );
495 if ( $this->captchaTriggers( $editPage, 'create' ) && !$editPage->mTitle->exists() ) {
496 // Check if creating a page
498 $this->trigger = sprintf( "Create trigger by '%s' at [[%s]]",
500 $title->getPrefixedText() );
501 $this->action = 'create';
502 wfDebug( "ConfirmEdit: checking on page creation...\n" );
506 if ( $this->captchaTriggers( $editPage, 'addurl' ) ) {
507 // Only check edits that add URLs
509 // Get links from the database
510 $oldLinks = $this->getLinksFromTracker( $title );
511 // Share a parse operation with Article::doEdit()
512 $editInfo = $editPage->mArticle->prepareTextForEdit( $newtext );
513 $newLinks = array_keys( $editInfo->output->getExternalLinks() );
515 // Get link changes in the slowest way known to man
516 $oldtext = $this->loadText( $editPage, $section );
517 $oldLinks = $this->findLinks( $editPage, $oldtext );
518 $newLinks = $this->findLinks( $editPage, $newtext );
521 $unknownLinks = array_filter( $newLinks, array( &$this, 'filterLink' ) );
522 $addedLinks = array_diff( $unknownLinks, $oldLinks );
523 $numLinks = count( $addedLinks );
525 if ( $numLinks > 0 ) {
527 $this->trigger = sprintf( "%dx url trigger by '%s' at [[%s]]: %s",
530 $title->getPrefixedText(),
531 implode( ", ", $addedLinks ) );
532 $this->action = 'addurl';
537 global $wgCaptchaRegexes;
538 if ( $wgCaptchaRegexes ) {
539 // Custom regex checks
540 $oldtext = $this->loadText( $editPage, $section );
542 foreach ( $wgCaptchaRegexes as $regex ) {
543 $newMatches = array();
544 if ( preg_match_all( $regex, $newtext, $newMatches ) ) {
545 $oldMatches = array();
546 preg_match_all( $regex, $oldtext, $oldMatches );
548 $addedMatches = array_diff( $newMatches[0], $oldMatches[0] );
550 $numHits = count( $addedMatches );
551 if ( $numHits > 0 ) {
553 $this->trigger = sprintf( "%dx %s at [[%s]]: %s",
557 $title->getPrefixedText(),
558 implode( ", ", $addedMatches ) );
559 $this->action = 'edit';
570 * Filter callback function for URL whitelisting
571 * @param string url to check
572 * @return bool true if unknown, false if whitelisted
575 function filterLink( $url ) {
576 global $wgCaptchaWhitelist;
577 $source = wfMsgForContent( 'captcha-addurl-whitelist' );
579 $whitelist = wfEmptyMsg( 'captcha-addurl-whitelist', $source )
581 : $this->buildRegexes( explode( "\n", $source ) );
583 $cwl = $wgCaptchaWhitelist !== false ? preg_match( $wgCaptchaWhitelist, $url ) : false;
584 $wl = $whitelist !== false ? preg_match( $whitelist, $url ) : false;
586 return !( $cwl || $wl );
590 * Build regex from whitelist
591 * @param string lines from [[MediaWiki:Captcha-addurl-whitelist]]
592 * @return string Regex or bool false if whitelist is empty
595 function buildRegexes( $lines ) {
596 # Code duplicated from the SpamBlacklist extension (r19197)
598 # Strip comments and whitespace, then remove blanks
599 $lines = array_filter( array_map( 'trim', preg_replace( '/#.*$/', '', $lines ) ) );
601 # No lines, don't make a regex which will match everything
602 if ( count( $lines ) == 0 ) {
603 wfDebug( "No lines\n" );
607 # It's faster using the S modifier even though it will usually only be run once
608 // $regex = 'http://+[a-z0-9_\-.]*(' . implode( '|', $lines ) . ')';
609 // return '/' . str_replace( '/', '\/', preg_replace('|\\\*/|', '/', $regex) ) . '/Si';
611 $regexStart = '/^https?:\/\/+[a-z0-9_\-.]*(';
615 foreach ( $lines as $line ) {
616 // FIXME: not very robust size check, but should work. :)
617 if ( $build === false ) {
619 } elseif ( strlen( $build ) + strlen( $line ) > $regexMax ) {
620 $regexes .= $regexStart .
621 str_replace( '/', '\/', preg_replace( '|\\\*/|', '/', $build ) ) .
625 $build .= '|' . $line;
628 if ( $build !== false ) {
629 $regexes .= $regexStart .
630 str_replace( '/', '\/', preg_replace( '|\\\*/|', '/', $build ) ) .
638 * Load external links from the externallinks table
639 * @param $title Title
642 function getLinksFromTracker( $title ) {
643 $dbr = wfGetDB( DB_SLAVE );
644 $id = $title->getArticleId(); // should be zero queries
645 $res = $dbr->select( 'externallinks', array( 'el_to' ),
646 array( 'el_from' => $id ), __METHOD__ );
648 foreach ( $res as $row ) {
649 $links[] = $row->el_to;
655 * Backend function for confirmEdit() and confirmEditAPI()
656 * @return bool false if the CAPTCHA is rejected, true otherwise
658 private function doConfirmEdit( $editPage, $newtext, $section, $merged = false ) {
659 if ( $this->shouldCheck( $editPage, $newtext, $section, $merged ) ) {
660 if ( $this->passCaptcha() ) {
666 wfDebug( "ConfirmEdit: no need to show captcha.\n" );
672 * The main callback run on edit attempts.
673 * @param EditPage $editPage
674 * @param string $newtext
675 * @param string $section
676 * @param bool $merged
677 * @return bool true to continue saving, false to abort and show a captcha form
679 function confirmEdit( $editPage, $newtext, $section, $merged = false ) {
680 if ( defined( 'MW_API' ) ) {
682 # The CAPTCHA was already checked and approved
685 if ( !$this->doConfirmEdit( $editPage, $newtext, $section, $merged ) ) {
686 $editPage->showEditForm( array( &$this, 'editCallback' ) );
693 * A more efficient edit filter callback based on the text after section merging
694 * @param EditPage $editPage
695 * @param string $newtext
697 function confirmEditMerged( $editPage, $newtext ) {
698 return $this->confirmEdit( $editPage, $newtext, false, true );
702 function confirmEditAPI( $editPage, $newtext, &$resultArr ) {
703 if ( !$this->doConfirmEdit( $editPage, $newtext, false, false ) ) {
704 $this->addCaptchaAPI( $resultArr );
711 * Hook for user creation form submissions.
713 * @param string $message
714 * @return bool true to continue, false to abort user creation
716 function confirmUserCreate( $u, &$message ) {
717 global $wgCaptchaTriggers, $wgUser;
718 if ( $wgCaptchaTriggers['createaccount'] ) {
719 if ( $wgUser->isAllowed( 'skipcaptcha' ) ) {
720 wfDebug( "ConfirmEdit: user group allows skipping captcha on account creation\n" );
723 if ( $this->isIPWhitelisted() )
726 $this->trigger = "new account '" . $u->getName() . "'";
727 if ( !$this->passCaptcha() ) {
728 $message = wfMsg( 'captcha-createaccount-fail' );
736 * Hook for user login form submissions.
738 * @param string $message
739 * @return bool true to continue, false to abort user creation
741 function confirmUserLogin( $u, $pass, &$retval ) {
742 if ( $this->isBadLoginTriggered() ) {
743 if ( $this->isIPWhitelisted() )
746 $this->trigger = "post-badlogin login '" . $u->getName() . "'";
747 if ( !$this->passCaptcha() ) {
748 // Emulate a bad-password return to confuse the shit out of attackers
749 $retval = LoginForm::WRONG_PASS;
757 * Check the captcha on Special:EmailUser
758 * @param $from MailAddress
759 * @param $to MailAddress
760 * @param $subject String
761 * @param $text String
762 * @param $error String reference
763 * @return Bool true to continue saving, false to abort and show a captcha form
765 function confirmEmailUser( $from, $to, $subject, $text, &$error ) {
766 global $wgCaptchaTriggers, $wgUser;
767 if ( $wgCaptchaTriggers['sendemail'] ) {
768 if ( $wgUser->isAllowed( 'skipcaptcha' ) ) {
769 wfDebug( "ConfirmEdit: user group allows skipping captcha on email sending\n" );
772 if ( $this->isIPWhitelisted() )
775 if ( defined( 'MW_API' ) ) {
777 # Asking for captchas in the API is really silly
778 $error = wfMsg( 'captcha-disabledinapi' );
781 $this->trigger = "{$wgUser->getName()} sending email";
782 if ( !$this->passCaptcha() ) {
783 $error = wfMsg( 'captcha-sendemail-fail' );
791 * Given a required captcha run, test form input for correct
792 * input on the open session.
793 * @return bool if passed, false if failed or new session
795 function passCaptcha() {
796 $info = $this->retrieveCaptcha();
799 if ( $this->keyMatch( $wgRequest->getVal( 'wpCaptchaWord' ), $info ) ) {
800 $this->log( "passed" );
801 $this->clearCaptcha( $info );
804 $this->clearCaptcha( $info );
805 $this->log( "bad form input" );
809 $this->log( "new captcha session" );
815 * Log the status and any triggering info for debugging or statistics
816 * @param string $message
818 function log( $message ) {
819 wfDebugLog( 'captcha', 'ConfirmEdit: ' . $message . '; ' . $this->trigger );
823 * Generate a captcha session ID and save the info in PHP's session storage.
824 * (Requires the user to have cookies enabled to get through the captcha.)
826 * A random ID is used so legit users can make edits in multiple tabs or
827 * windows without being unnecessarily hobbled by a serial order requirement.
828 * Pass the returned id value into the edit form as wpCaptchaId.
830 * @param array $info data to store
831 * @return string captcha ID key
833 function storeCaptcha( $info ) {
834 if ( !isset( $info['index'] ) ) {
835 // Assign random index if we're not udpating
836 $info['index'] = strval( mt_rand() );
838 $this->storage->store( $info['index'], $info );
839 return $info['index'];
843 * Fetch this session's captcha info.
844 * @return mixed array of info, or false if missing
846 function retrieveCaptcha() {
848 $index = $wgRequest->getVal( 'wpCaptchaId' );
849 return $this->storage->retrieve( $index );
853 * Clear out existing captcha info from the session, to ensure
854 * it can't be reused.
856 function clearCaptcha( $info ) {
857 $this->storage->clear( $info['index'] );
861 * Retrieve the current version of the page or section being edited...
862 * @param EditPage $editPage
863 * @param string $section
867 function loadText( $editPage, $section ) {
868 $rev = Revision::newFromTitle( $editPage->mTitle );
869 if ( is_null( $rev ) ) {
872 $text = $rev->getText();
873 if ( $section != '' ) {
875 return $wgParser->getSection( $text, $section );
883 * Extract a list of all recognized HTTP links in the text.
884 * @param string $text
885 * @return array of strings
887 function findLinks( &$editpage, $text ) {
888 global $wgParser, $wgUser;
890 $options = new ParserOptions();
891 $text = $wgParser->preSaveTransform( $text, $editpage->mTitle, $wgUser, $options );
892 $out = $wgParser->parse( $text, $editpage->mTitle, $options );
894 return array_keys( $out->getExternalLinks() );
898 * Show a page explaining what this wacky thing is.
900 function showHelp() {
902 $wgOut->setPageTitle( wfMsg( 'captchahelp-title' ) );
903 $wgOut->addWikiText( wfMsg( 'captchahelp-text' ) );
904 if ( $this->storage->cookiesNeeded() ) {
905 $wgOut->addWikiText( wfMsg( 'captchahelp-cookies-needed' ) );