captcha.module 20.9 KB
Newer Older
1 2 3
<?php
// $Id$

wundo's avatar
wundo committed
4
/**
5 6
 * @file
 * This module enables basic CAPTCHA functionality:
7
 * administrators can add a CAPTCHA to desired forms that users without
8 9 10
 * the 'skip CAPTCHA' permission (typically anonymous visitors) have
 * to solve.
 *
wundo's avatar
wundo committed
11
 */
12

13 14 15 16 17 18 19 20
define('CAPTCHA_UNSOLVED_CHALLENGES_MAX', 20);
define('CAPTCHA_PERSISTENCE_SHOW_ALWAYS', 1);
define('CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL_PER_FORM', 2);
define('CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL', 3);

/**
 * Implementation of hook_help().
 */
21 22
function captcha_help($path, $arg) {
  switch ($path) {
23 24
    case 'admin/help#captcha':
      $output = '<p>'. t('"CAPTCHA" is an acronym for "Completely Automated Public Turing test to tell Computers and Humans Apart". It is typically a challenge-response test to determine whether the user is human. The CAPTCHA module is a tool to fight automated submission by malicious users (spamming) of for example comments forms, user registration forms, guestbook forms, etc. You can extend the desired forms with an additional challenge, which should be easy for a human to solve correctly, but hard enough to keep automated scripts and spam bots out.') .'</p>';
25
      $output .= '<p>'. t('Note that the CAPTCHA module interacts with page caching (see <a href="!performancesettings">performance settings</a>). Because the challenge should be unique for each generated form, the caching of the page it appears on is prevented. Make sure that these forms do not appear on too many pages or you will lose much caching efficiency. For example, if you put a CAPTCHA on the user login block, which typically appears on each page for anonymous visitors, caching will practically be disabled. The comment submission forms are another example. In this case you should set the "%commentlocation" to "%separatepage" in the comment settings of the relevant <a href="!contenttypes">content types</a> for better caching efficiency.' ,
26 27 28 29
        array(
          '!performancesettings' => url('admin/settings/performance'),
          '%commentlocation' => t('Location of comment submission form'),
          '%separatepage' => t('Display on separate page'),
30
          '!contenttypes' => url('admin/content/types'),
31 32 33 34 35 36 37
        )
      ) .'</p>';
      $output .= '<p>'. t('CAPTCHA is a trademark of Carnegie Mellon University.') .'</p>';
      return $output;
    case 'admin/user/captcha':
    case 'admin/user/captcha/captcha':
    case 'admin/user/captcha/captcha/settings':
38 39
      $output = '<p>'. t('A CAPTCHA can be added to virtually each Drupal form. Some default forms are already provided in the form list, but arbitrary forms can be easily added and managed when the option "%adminlinks" is enabled.',
        array('%adminlinks' => t('Add CAPTCHA administration links to forms'))) .'</p>';
40
      $output .= '<p>'. t('Users with the "%skipcaptcha" <a href="@perm">permission</a> won\'t be offered a challenge. Be sure to grant this permission to the trusted users (e.g. site administrators). If you want to test a protected form, be sure to do it as a user without the "%skipcaptcha" permission (e.g. as anonymous user).',
41
        array('%skipcaptcha' => t('skip CAPTCHA'), '@perm' => url('admin/user/permissions'))) .'</p>';
42
      return $output;
43 44 45
  }
}

wundo's avatar
wundo committed
46 47 48
/**
 * Implementation of hook_menu().
 */
49
function captcha_menu() {
wundo's avatar
wundo committed
50
  $items = array();
51 52
  // main configuration page of the basic CAPTCHA module
  $items['admin/user/captcha'] = array(
53 54
    'title' => 'CAPTCHA',
    'description' => 'Administer how and where CAPTCHAs are used.',
55
    'file' => 'captcha.admin.inc',
56 57
    'page callback' => 'drupal_get_form',
    'page arguments' => array('captcha_admin_settings'),
58
    'access arguments' => array('administer CAPTCHA settings'),
59 60 61 62 63
    'type' => MENU_NORMAL_ITEM,
  );
  // the default local task (needed when other modules want to offer
  // alternative CAPTCHA types and their own configuration page as local task)
  $items['admin/user/captcha/captcha'] = array(
64
    'title' => 'CAPTCHA',
65
    'access arguments' => array('administer CAPTCHA settings'),
66 67 68 69
    'type' => MENU_DEFAULT_LOCAL_TASK,
    'weight' => -20,
  );
  $items['admin/user/captcha/captcha/settings'] = array(
70
    'title' => 'General settings',
71
    'access arguments' => array('administer CAPTCHA settings'),
72 73 74 75
    'type' => MENU_DEFAULT_LOCAL_TASK,
    'weight' => 0,
  );
  $items['admin/user/captcha/captcha/examples'] = array(
76 77
    'title' => 'Examples',
    'description' => 'An overview of the available challenge types with examples.',
78
    'file' => 'captcha.admin.inc',
79 80
    'page callback' => 'drupal_get_form',
    'page arguments' => array('captcha_examples', 5, 6),
81
    'access arguments' => array('administer CAPTCHA settings'),
82 83 84
    'type' => MENU_LOCAL_TASK,
    'weight' => 5,
  );
85
  $items['admin/user/captcha/captcha/captcha_point'] = array(
86
    'title' => 'CAPTCHA point administration',
87 88 89
    'file' => 'captcha.admin.inc',
    'page callback' => 'captcha_point_admin',
    'page arguments' => array(5, 6),
90
    'access arguments' => array('administer CAPTCHA settings'),
91 92
    'type' => MENU_CALLBACK,
  );
wundo's avatar
wundo committed
93 94
  return $items;
}
95

96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115
/**
 * Implementation of hook_perm().
 */
function captcha_perm() {
  return array('administer CAPTCHA settings', 'skip CAPTCHA');
}

/**
 * Implementation of hook_requirements().
 */
function captcha_requirements($phase) {
  $requirements = array();
  $t = get_t();
  if ($phase == 'runtime') {
    // show the wrong response counter in the status report
    $requirements['captcha_wrong_response_counter'] = array(
      'title' => $t('CAPTCHA'),
      'value' => $t('Already @counter blocked form submissions', array('@counter' => variable_get('captcha_wrong_response_counter', 0))),
      'severity' => REQUIREMENT_INFO,
    );
116 117 118 119 120 121 122 123 124 125
    // Check if there is an entry for uid=0 in the users table, this is required
    // to have working a $_SESSION variable for anonymous users.
    if (!db_result(db_query('SELECT COUNT(*) FROM {users} WHERE uid=%d', 0))) {
      $requirements['captcha_no_sessions_for_anonymous'] = array(
        'title' => $t('CAPTCHA'),
        'value' => $t('No sessions for anonymous users.'),
        'description' => $t('There is no entry for uid 0 in the %users table of the database. This disables persistent session data for anonymous users. Because the CAPTCHA module depends on this session data, CAPTCHAs will not work for anonymous users. Add a row for uid 0 to the %users table to resolve this.', array('%users' => 'users')),
        'severity' => REQUIREMENT_ERROR,
      );
    }
126 127 128
  }
  return $requirements;
}
129

130 131 132 133 134 135 136 137 138 139
/**
 * Implementation of hook_theme().
 */
function captcha_theme() {
  return array(
    'captcha_admin_settings_captcha_points' => array(
      'arguments' => array('form' => NULL)
    )
  );
}
wundo's avatar
wundo committed
140

141 142 143 144 145
/**
 * Get the description which appears above the CAPTCHA in forms.
 * If the locale module is enabled, an optional language code can be given
 */
function _captcha_get_description($lang_code=NULL) {
146
  $default = t('This question is for testing whether you are a human visitor and to prevent automated spam submissions.');
147 148
  if (module_exists('locale')) {
    if ($lang_code == NULL) {
149 150
      global $language;
      $lang_code = $language->language;
151
    }
152
    $description = variable_get("captcha_description_$lang_code", $default);
153 154
  }
  else {
155
    $description = variable_get('captcha_description', $default);
156 157 158 159 160 161 162 163 164
  }
  return $description;
}

/**
 * Helper function for checking if the CAPTCHA for the given form_id should
 * be skipped because of CAPTCHA persistence.
 */
function _captcha_persistence_skip($form_id) {
165 166 167 168 169
  switch (variable_get('captcha_persistence', CAPTCHA_PERSISTENCE_SHOW_ALWAYS)) {
    case CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL:
      return isset($_SESSION['captcha']['success']) && ($_SESSION['captcha']['success'] === TRUE);
    case CAPTCHA_PERSISTENCE_SKIP_ONCE_SUCCESSFUL_PER_FORM:
      return isset($_SESSION['captcha'][$form_id]['success']) && ($_SESSION['captcha'][$form_id]['success'] === TRUE);
170
    default:
171
      return FALSE;
172
  }
173 174
}

wundo's avatar
wundo committed
175 176
/**
 * Implementation of hook_form_alter().
177 178
 *
 * This function adds a CAPTCHA to forms for untrusted users if needed and adds
179
 * CAPTCHA administration links for site administrators if this option is enabled.
wundo's avatar
wundo committed
180
 */
181
function captcha_form_alter(&$form, $form_state, $form_id) {
wundo's avatar
wundo committed
182

183 184
  if (!user_access('skip CAPTCHA')) {
    // Visitor does not have permission to skip the CAPTCHA
Arnab Nandi's avatar
Arnab Nandi committed
185

186 187 188 189 190 191
    // Get CAPTCHA type and module for this form. Return if no CAPTCHA was set.
    $result = db_query("SELECT module, type FROM {captcha_points} WHERE form_id = '%s'", $form_id);
    if (!$result) {
      return;
    }
    $captcha_point = db_fetch_object($result);
192
    if (!$captcha_point || !$captcha_point->type) {
193 194
      return;
    }
Arnab Nandi's avatar
Arnab Nandi committed
195

196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212
    // Prevent caching of the page with this CAPTCHA enabled form.
    // This needs to be done even if the CAPTCHA will be skipped (because of
    // persistence): other untrusted users should not get a cached page when
    // the current untrusted user can skip the current CAPTCHA.
    global $conf;
    $conf['cache'] = FALSE;

    // Do not present CAPTCHA if not CAPTCHA-persistent and user has already solved a CAPTCHA for this form
    if (_captcha_persistence_skip($form_id)) {
      return;
    }

    // Generate a CAPTCHA and its solution
    $captcha = module_invoke($captcha_point->module, 'captcha', 'generate', $captcha_point->type);
    if (!$captcha) {
      //The selected module returned nothing, maybe it is disabled or it's wrong, we should watchdog that and then quit.
      watchdog('CAPTCHA',
213 214
        'CAPTCHA problem: hook_captcha() of module %module returned nothing when trying to retrieve challenge type %type for form %form_id.',
        array('%type' => $captcha_point->type, '%module' => $captcha_point->module, '%form_id' => $form_id),
215 216 217 218 219 220 221 222 223 224 225 226
        WATCHDOG_ERROR);
      return;
    }

    // Add a CAPTCHA part to the form (depends on value of captcha_description)
    $captcha_description = _captcha_get_description();
    if ($captcha_description) {
      // $captcha_description is not empty: CAPTCHA part is a fieldset with description
      $form['captcha'] = array(
        '#type' => 'fieldset',
        '#title' => t('CAPTCHA'),
        '#description' => $captcha_description,
227
        '#attributes' => array('class' => 'captcha'),
228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264
      );
    }
    else {
      // $captcha_description is empty: CAPTCHA part is an empty markup form element
      $form['captcha'] = array(
        '#type' => 'markup',
        '#prefix' => '<div class="captcha">',
        '#suffix' => '</div>',
      );
    }

    // Add the form elements of the generated CAPTCHA to the form
    $form['captcha'] = array_merge($form['captcha'], $captcha['form']);

    // Store the solution of the generated CAPTCHA as an internal form value.
    // This will be stored later in $_SESSION during the pre_render phase.
    // It can't be saved at this point because hook_form_alter is not only run
    // before form rendering, but also before form validation (which happens
    // in a new (POST) request. Consequently the right CAPTCHA solution would be
    // overwritten just before validation. The pre_render functions are not run
    // before validation and are the right place to store the solution in $_SESSION.
    $form['captcha']['captcha_solution'] = array(
      '#type' => 'value',
      '#value' => $captcha['solution'],
    );

    // The CAPTCHA token is used to differentiate between different instances
    // of the same form. This makes it possible to request the same form a
    // couple of times before submitting them. The solution of the CAPTCHA of
    // each of these form instances will be stored at the pre_render phase in
    // $_SESSION['captcha'][$form_id][$captcha_token]
    $form['captcha']['captcha_token'] = array(
      '#type' => 'hidden',
      '#value' => md5(mt_rand()),
    );

    // other internal values needed for the validation phase
265
    $form['captcha']['captcha_info'] = array(
266 267 268 269 270 271 272 273 274
      '#type' => 'value',
      '#value' => array(
        'form_id' => $form_id,
        'preprocess' => isset($captcha['preprocess'])? $captcha['preprocess'] : FALSE,
        'module' => $captcha_point->module,
        'type' => $captcha_point->type,
      ),
    );

275
    // Add pre_render function for additional CAPTCHA processing.
276
    $form['#pre_render'][] = 'captcha_pre_render';
277 278
    // Add pre_render function for placement of CAPTCHA formt element (above submit buttons).
    $form['#pre_render'][] = 'captcha_pre_render_place_captcha';
279

280
    // Add a validation function for the CAPTCHA form element.
281
    $form['captcha']['#element_validate'] =  array('captcha_validate');
282

wundo's avatar
wundo committed
283
  }
284 285 286 287 288 289 290
  elseif (user_access('administer CAPTCHA settings') && variable_get('captcha_administration_mode', FALSE) && arg(0) != 'admin') {
    // For administrators: show CAPTCHA info and offer link to configure it
    $result = db_query("SELECT module, type FROM {captcha_points} WHERE form_id = '%s'", $form_id);
    if (!$result) {
      return;
    }
    $captcha_point = db_fetch_object($result);
291 292 293 294 295 296
    $form['captcha'] = array(
      '#type' => 'fieldset',
      '#title' => t('CAPTCHA'),
      '#collapsible' => TRUE,
      '#collapsed' => TRUE,
    );
297
    if ($captcha_point && $captcha_point->type) {
298 299 300 301
      $form['captcha']['#description'] = t('Untrusted users will see a CAPTCHA here (!settings).',
        array('!settings' => l(t('general CAPTCHA settings'), 'admin/user/captcha'))
      );
      $form['captcha']['challenge'] = array(
302
        '#type' => 'item',
303 304
        '#title' => t('Enabled challenge'),
        '#value' => t('"@type" by module "@module" (!change, !disable)', array(
305 306
          '@type' => $captcha_point->type,
          '@module' => $captcha_point->module,
307 308
          '!change' => l(t('change'), "admin/user/captcha/captcha/captcha_point/$form_id", array('query' => drupal_get_destination())),
          '!disable' => l(t('disable'), "admin/user/captcha/captcha/captcha_point/$form_id/disable", array('query' => drupal_get_destination())),
309
        )),
310 311 312
      );
    }
    else {
313
      $form['captcha']['add_captcha'] = array(
314
        '#value' => l(t('Place a CAPTCHA here for untrusted users.'), "admin/user/captcha/captcha/captcha_point/$form_id", array('query' => drupal_get_destination()))
315 316
      );
    }
317 318
    // Add pre_render function for placement of CAPTCHA formt element (above submit buttons).
    $form['#pre_render'][] = 'captcha_pre_render_place_captcha';
319
  }
320
}
321

322 323 324
/**
 * Implementation of form #validate.
 */
325
function captcha_validate($form, &$form_state) {
326 327 328 329 330 331
  // Check if there is CAPTCHA data available in $_SESSION.
  // If not, the visitor has most likely disabled cookies.
  if (!isset($_SESSION['captcha'])) {
    form_set_error('captcha_response', t('Cookies should be enabled in your browser for CAPTCHA validation.'));
    return;
  }
332
  // Get answer and preprocess if needed
333
  $captcha_response = $form_state['values']['captcha_response'];
334
  $captcha_info = $form_state['values']['captcha_info'];
335 336
  if ($captcha_info['preprocess']) {
    $captcha_response = module_invoke($captcha_info['module'], 'captcha', 'preprocess', $captcha_info['type'], $captcha_response);
337
  }
338 339 340 341
  $form_id = $captcha_info['form_id'];
  // not that we use $form_state['clicked_button']['#post']['captcha_token']
  // here instead of $form_state['values']['captcha_token'], because the latter
  // contains the captcha_token of the new form, while the former contains
342
  // the captcha token of the posted form.
343
  $captcha_token = $form_state['clicked_button']['#post']['captcha_token'];
344 345 346 347 348
  // Check if captcha_token exists
  if (!isset($_SESSION['captcha'][$form_id][$captcha_token])) {
    form_set_error('captcha_token', t('Invalid CAPTCHA token.'));
  }
  // Check answer
349
  elseif ($captcha_response === $_SESSION['captcha'][$form_id][$captcha_token]) {
350 351
    $_SESSION['captcha'][$form_id]['success'] = TRUE;
    $_SESSION['captcha']['success'] = TRUE;
wundo's avatar
wundo committed
352
  }
wundo's avatar
wundo committed
353
  else {
354 355 356 357 358 359 360
    // set form error
    form_set_error('captcha_response', t('The answer you entered for the CAPTCHA was not correct.'));
    // update wrong response counter
    variable_set('captcha_wrong_response_counter', variable_get('captcha_wrong_response_counter', 0) + 1);
    // log to watchdog if needed
    if (variable_get('captcha_log_wrong_responses', FALSE)) {
      watchdog('CAPTCHA',
361 362 363 364
        '%form_id post blocked by CAPTCHA module: challenge "%challenge" (by module "%module"), user answered "%response", but the solution was "%solution".',
        array('%form_id' => $form_id,
          '%response' => $captcha_response, '%solution' => $_SESSION['captcha'][$form_id][$captcha_token],
          '%challenge' => $captcha_info['type'], '%module' => $captcha_info['module'],
365 366 367 368 369 370 371 372 373 374 375
        ),
        WATCHDOG_NOTICE);
    }
    // If CAPTCHA was on a login form: stop validating, quit the current request
    // and forward to the current page (like a reload) to prevent loging in.
    // We do that because the log in procedure, which happens after
    // captcha_validate(), does not check error conditions of extra form
    // elements like the CAPTCHA.
    if ($form_id == 'user_login' || $form_id == 'user_login_block') {
      drupal_goto($_GET['q']);
    }
376
  }
377 378 379 380 381 382

  // Unset the solution to prevent reuse of the same CAPTCHA solution
  // by a spammer that repeats posting a form without requesting
  // (and thus rendering) a new form. Note that a new CAPTCHA solution is only
  // set at the pre_render phase.
  unset($_SESSION['captcha'][$form_id][$captcha_token]);
383
}
384

wundo's avatar
wundo committed
385
/**
wundo's avatar
wundo committed
386 387
 * Implementation of form #pre_render.
 *
388 389
 * The main purpose of this function is to store the solution of the CAPTCHA
 * in the $_SESSION variable.
390
 */
391
function captcha_pre_render($form) {
392
  $form_id = $form['captcha']['captcha_info']['#value']['form_id'];
393 394 395 396 397 398 399 400 401 402 403 404 405 406 407
  // Unset the CAPTCHA if non-CAPTCHA persistent and the CAPTCHA has
  // already been successfully solved for this form.
  // This needs to be done in this pre_render phase when previewing for example
  // nodes and comments before submission.
  // On submission of such a forms for preview, captcha_form_alter() is called
  // *before* the CAPTCHA validation function (which sets
  // $_SESSION['captcha'][$form_id]['success'] to TRUE on a correctly answered
  // CAPTCHA). After this the form_values are entered in the generated form
  // and this form is presented with the preview.
  // This means that captcha_form_alter() can't know if the CAPTCHA was
  // correctly answered and consequently adds a CAPTCHA to the form.
  // The pre_render phase happens after the validation phase and makes it
  // possible to remove the CAPTCHA from the form after all.
  if (_captcha_persistence_skip($form_id)) {
    unset($form['captcha']);
408
    return $form;
409 410
  }

411
  // count the number of unsolved CAPTCHAs and unset the oldest if too many
412
  // minus 1 is needed because 'success' is also an item of $_SESSION['captcha'][$form_id]
413
  if (isset($_SESSION['captcha'][$form_id]) && count($_SESSION['captcha'][$form_id]) - 1 > CAPTCHA_UNSOLVED_CHALLENGES_MAX) {
414 415 416 417 418 419
    foreach (array_keys($_SESSION['captcha'][$form_id]) as $captcha_token) {
      if ($captcha_token != 'success') {
        unset($_SESSION['captcha'][$form_id][$captcha_token]);
        break;
      }
    }
wundo's avatar
wundo committed
420
  }
421
  // store the current CAPTCHA solution in $_SESSION
422 423
  $captcha_token = $form['captcha']['captcha_token']['#value'];
  $_SESSION['captcha'][$form_id][$captcha_token] = $form['captcha']['captcha_solution']['#value'];
424 425
  $_SESSION['captcha'][$form_id]['success'] = FALSE;
  // empty the value of the captcha_response form item before rendering
426
  $form['captcha']['captcha_response']['#value'] = '';
427
  return $form;
428
}
429

430 431 432
/**
 * Pre_render function to place the CAPTCHA form element just above the last submit button
 */
433
function captcha_pre_render_place_captcha($form) {
434 435 436
  // search the weights of the buttons in the form
  $button_weights = array();
  foreach (element_children($form) as $key) {
437
    if ($key == 'buttons' || isset($form[$key]['#type']) && ($form[$key]['#type'] == 'submit' || $form[$key]['#type'] == 'button')) {
438 439 440 441 442 443 444 445 446 447 448
      $button_weights[] = $form[$key]['#weight'];
    }
  }
  if ($button_weights) {
    // set the weight of the CAPTCHA element a tiny bit smaller than the lightest button weight
    // (note that the default resolution of #weight values is 1/1000 (see drupal/includes/form.inc))
    $first_button_weight = min($button_weights);
    $form['captcha']['#weight'] = $first_button_weight - 0.5/1000.0;
    // make sure the form gets sorted before rendering
    unset($form['#sorted']);
  }
449
  return $form;
450
}
wundo's avatar
wundo committed
451

452
/**
wundo's avatar
wundo committed
453
 * Default implementation of hook_captcha
wundo's avatar
wundo committed
454
 */
455
function captcha_captcha($op, $captcha_type = '') {
456
  switch ($op) {
wundo's avatar
wundo committed
457
    case 'list':
458
      return array('Math');
wundo's avatar
wundo committed
459
    case 'generate':
460 461
      if ($captcha_type == 'Math') {
        $result = array();
wundo's avatar
wundo committed
462 463 464
        $answer = mt_rand(1, 20);
        $x = mt_rand(1, $answer);
        $y = $answer - $x;
465 466
        $result['solution'] = "$answer";
        $result['form']['captcha_response'] = array(
wundo's avatar
wundo committed
467
          '#type' => 'textfield',
468 469 470 471 472
          '#title' => t('Math Question'),
          '#description' => t('Solve this simple math problem and enter the result. E.g. for 1+3, enter 4.'),
          '#field_prefix' => t('@x + @y = ', array('@x' => $x, '@y' => $y)),
          '#size' => 4,
          '#maxlength' => 2,
wundo's avatar
wundo committed
473 474
          '#required' => TRUE,
        );
475
        return $result;
wundo's avatar
wundo committed
476
      }
477
  }
Arnab Nandi's avatar
Arnab Nandi committed
478
}