Captcha.php 11.2 KB
Newer Older
1
2
3
4
<?php

namespace Drupal\captcha\Element;

5
use Drupal\Core\Config\ConfigFactoryInterface;
6
use Drupal\Core\Form\FormStateInterface;
7
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
8
use Drupal\Core\Render\Element\FormElement;
9
use Symfony\Component\DependencyInjection\ContainerInterface;
10
use Drupal\Component\Utility\Crypt;
11
12
13
14
15
16

/**
 * Defines the CAPTCHA form element with default properties.
 *
 * @FormElement("captcha")
 */
17
18
19
class Captcha extends FormElement implements ContainerFactoryPluginInterface {

  /**
20
21
   * The config factory.
   *
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
   * @var \Drupal\Core\Config\ConfigFactoryInterface
   */
  protected $configFactory;

  /**
   * Captcha constructor.
   *
   * @param array $configuration
   *   A configuration array containing information about the plugin instance.
   * @param string $plugin_id
   *   The plugin ID for the plugin instance.
   * @param mixed $plugin_definition
   *   The plugin implementation definition.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
   *   The configuration factory.
   */
  public function __construct(array $configuration, $plugin_id, $plugin_definition, ConfigFactoryInterface $configFactory) {
    $this->configFactory = $configFactory;
    parent::__construct($configuration, $plugin_id, $plugin_definition);
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('config.factory')
    );
  }
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74

  /**
   * {@inheritdoc}
   */
  public function getInfo() {
    $captcha_element = [
      '#input' => TRUE,
      '#process' => [[static::class, 'processCaptchaElement']],
      // The type of challenge: e.g. 'default', 'captcha/Math', etc.
      '#captcha_type' => 'default',
      '#default_value' => '',
      // CAPTCHA in admin mode: presolve the CAPTCHA and always show
      // it (despite previous successful responses).
      '#captcha_admin_mode' => FALSE,
      // The default CAPTCHA validation function.
      // TODO: should this be a single string or an array of strings?
      '#captcha_validate' => 'captcha_validate_strict_equality',
    ];
    // Override the default CAPTCHA validation function for case
    // insensitive validation.
    // TODO: shouldn't this be done somewhere else, e.g. in form_alter?
75
    if (CAPTCHA_DEFAULT_VALIDATION_CASE_INSENSITIVE == $this->configFactory->get('captcha.settings')
76
      ->get('default_validation')
77
    ) {
78
79
80
81
82
83
84
85
86
      $captcha_element['#captcha_validate'] = 'captcha_validate_case_insensitive_equality';
    }
    return $captcha_element;
  }

  /**
   * Process callback for CAPTCHA form element.
   */
  public static function processCaptchaElement(&$element, FormStateInterface $form_state, &$complete_form) {
87
    // Add captcha.inc file.
88
89
    module_load_include('inc', 'captcha');

90
    // Add JavaScript for general CAPTCHA functionality.
91
92
    $element['#attached']['library'][] = 'captcha/base';

93
    if ($form_state->getTriggeringElement() && isset($form_state->getTriggeringElement()['#limit_validation_errors']) && is_array($form_state->getTriggeringElement()['#limit_validation_errors'])) {
94
95
96
97
98
99
100
      // This is a partial (ajax) submission with limited validation. Do not
      // change anything about the captcha element, assume that it will not
      // update the captcha element, do not generate anything, which keeps the
      // current token intact for the real submission.
      return $element;
    }

101
102
    // Get the form ID of the form we are currently processing (which is not
    // necessary the same form that is submitted (if any).
103
104
105
    $this_form_id = isset($complete_form['form_id']['#value']) ?
      preg_replace("/[^a-z0-9_]/", "", (string) $complete_form['form_id']['#value'])
      : NULL;
106
107
108
109
110
111
112
113
114

    // Get the CAPTCHA session ID.
    // If there is a submitted form: try to retrieve and reuse the
    // CAPTCHA session ID from the posted data.
    list($posted_form_id, $posted_captcha_sid) = _captcha_get_posted_captcha_info($element, $form_state, $this_form_id);
    if ($this_form_id == $posted_form_id && isset($posted_captcha_sid)) {
      $captcha_sid = $posted_captcha_sid;
    }
    else {
115
116
      // Generate a new CAPTCHA session if we could
      // not reuse one from a posted form.
117
      $captcha_sid = _captcha_generate_captcha_session($this_form_id, CAPTCHA_STATUS_UNSOLVED);
118
      $captcha_token = Crypt::randomBytesBase64();
119
      \Drupal::database()->update('captcha_sessions')
120
121
122
        ->fields(['token' => $captcha_token])
        ->condition('csid', $captcha_sid)
        ->execute();
123
124
125
126
127
128
129
130
131
132
133
134
    }

    // Store CAPTCHA session ID as hidden field.
    // Strictly speaking, it is not necessary to send the CAPTCHA session id
    // with the form, as the one time CAPTCHA token (see lower) is enough.
    // However, we still send it along, because it can help debugging
    // problems on live sites with only access to the markup.
    $element['captcha_sid'] = [
      '#type' => 'hidden',
      '#value' => $captcha_sid,
    ];

135
    // Store CAPTCHA token as hidden field.
136
137
138
139
140
141
142
    $captcha_token = \Drupal::database()
      ->select('captcha_sessions', 'cs')
      ->fields('cs', ['token'])
      ->condition('csid', $captcha_sid)
      ->execute()
      ->fetchField();

143
144
145
146
147
148
149
150
151
    $element['captcha_token'] = [
      '#type' => 'hidden',
      '#value' => $captcha_token,
    ];

    // Get implementing module and challenge for CAPTCHA.
    list($captcha_type_module, $captcha_type_challenge) = _captcha_parse_captcha_type($element['#captcha_type']);

    // Store CAPTCHA information for further processing in
152
    // - $form_state->get('captcha_info'), which survives
153
    //   a form rebuild (e.g. node preview),
154
    //   useful in _captcha_get_posted_captcha_info().
155
156
    // - $element['#captcha_info'], for post processing functions that do not
    //   receive a $form_state argument (e.g. the pre_render callback).
157
    // Added a new access attribute,
158
159
    // by default it will be true if access attribute
    // not defined in a custom form.
160
161
162
163
164
165
    $form_state->set('captcha_info', [
      'this_form_id' => $this_form_id,
      'posted_form_id' => $posted_form_id,
      'captcha_sid' => $captcha_sid,
      'module' => $captcha_type_module,
      'captcha_type' => $captcha_type_challenge,
166
      'access' => isset($element['#access']) ? $element['#access'] : CAPTCHA_FIELD_DEFAULT_ACCESS,
167
168
169
170
171
172
173
174
175
    ]);
    $element['#captcha_info'] = [
      'form_id' => $this_form_id,
      'captcha_sid' => $captcha_sid,
    ];

    if (_captcha_required_for_user($captcha_sid, $this_form_id) || $element['#captcha_admin_mode']) {
      // Generate a CAPTCHA and its solution
      // (note that the CAPTCHA session ID is given as third argument).
176
177
178
179
180
181
      $captcha = \Drupal::moduleHandler()
        ->invoke($captcha_type_module, 'captcha', [
          'generate',
          $captcha_type_challenge,
          $captcha_sid,
        ]);
182

183
184
      // @todo Isn't this moment a bit late to figure out
      // that we don't need CAPTCHA?
185
      if (!isset($captcha)) {
186
187
188
189
        return $element;
      }

      if (!isset($captcha['form']) || !isset($captcha['solution'])) {
190
191
        // The selected module did not return what we expected:
        // log about it and quit.
192
193
        \Drupal::logger('CAPTCHA')->error(
          'CAPTCHA problem: unexpected result from hook_captcha() of module %module when trying to retrieve challenge type %type for form %form_id.',
194
195
196
197
198
          [
            '%type' => $captcha_type_challenge,
            '%module' => $captcha_type_module,
            '%form_id' => $this_form_id,
          ]
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
        );

        return $element;
      }
      // Add form elements from challenge as children to CAPTCHA form element.
      $element['captcha_widgets'] = $captcha['form'];

      // Add a validation callback for the CAPTCHA form element
      // (when not in admin mode).
      if (!$element['#captcha_admin_mode']) {
        $element['#element_validate'] = ['captcha_validate'];
      }

      // Set a custom CAPTCHA validate function if requested.
      if (isset($captcha['captcha_validate'])) {
        $element['#captcha_validate'] = $captcha['captcha_validate'];
      }

      // Set the theme function.
      $element['#theme'] = 'captcha';

      // Add pre_render callback for additional CAPTCHA processing.
      if (!isset($element['#pre_render'])) {
        $element['#pre_render'] = [];
      }
224
      $element['#pre_render'][] = [Captcha::class, 'preRenderProcess'];
225
226
227
228

      // Store the solution in the #captcha_info array.
      $element['#captcha_info']['solution'] = $captcha['solution'];

229
230
231
232
233
234
235
236
237
238
239
240
241
      // Store if this captcha type is cacheable:
      // A cacheable captcha must not depend on the sid or solution, but be
      // independent - like for example recaptcha.
      $element['#captcha_info']['cacheable'] = !empty($captcha['cacheable']);

      if (!empty($element['#captcha_info']['cacheable'])) {
        // This is only used to avoid the re-use message.
        $element['captcha_cacheable'] = [
          '#type' => 'hidden',
          '#value' => 1,
        ];
      }

242
      // Make sure we can use a top level form value
243
      // $form_state->getValue('captcha_response'),
244
      // even if the form has #tree=true.
245
246
247
248
249
250
      $element['#tree'] = FALSE;
    }

    return $element;
  }

251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
  /**
   * Pre-render callback for additional processing of a CAPTCHA form element.
   *
   * This encompasses tasks that should happen after the general FAPI processing
   * (building, submission and validation) but before rendering
   * (e.g. storing the solution).
   *
   * @param array $element
   *   The CAPTCHA form element.
   *
   * @return array
   *   The manipulated element.
   */
  public static function preRenderProcess(array $element) {
    module_load_include('inc', 'captcha');

    // Get form and CAPTCHA information.
    $captcha_info = $element['#captcha_info'];
    $form_id = $captcha_info['form_id'];
    $captcha_sid = (int) ($captcha_info['captcha_sid']);
    // Check if CAPTCHA is still required.
    // This check is done in a first phase during the element processing
    // (@see captcha_process), but it is also done here for better support
    // of multi-page forms. Take previewing a node submission for example:
    // when the challenge is solved correctely on preview, the form is still
    // not completely submitted, but the CAPTCHA can be skipped.
    if (_captcha_required_for_user($captcha_sid, $form_id) || $element['#captcha_admin_mode']) {
      // Update captcha_sessions table: store the solution
      // of the generated CAPTCHA.
      _captcha_update_captcha_session($captcha_sid, $captcha_info['solution']);

      // Handle the response field if it is available and if it is a textfield.
      if (isset($element['captcha_widgets']['captcha_response']['#type']) && $element['captcha_widgets']['captcha_response']['#type'] == 'textfield') {
        // Before rendering: presolve an admin mode challenge or
        // empty the value of the captcha_response form item.
        $value = $element['#captcha_admin_mode'] ? $captcha_info['solution'] : '';
        $element['captcha_widgets']['captcha_response']['#value'] = $value;
      }
    }
    else {
      // Remove CAPTCHA widgets from form.
      unset($element['captcha_widgets']);
    }

    return $element;
  }

298
}