Completed
Push — master ( 66fe64...b57115 )
by rugk
02:43
created

resetProviderOptionsForTrigger()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 13
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 13
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 10
nc 1
nop 2
1
<?php
2
/**
3
 * Two factor authentication provider for Threema Gateway which sends a
4
 * confirmation message.
5
 *
6
 * @package ThreemaGateway
7
 * @author rugk
8
 * @copyright Copyright (c) 2015-2016 rugk
9
 * @license MIT
10
 */
11
12
/**
13
 * TFA where the user acknowledges a message sent my the server.
14
 */
15
class ThreemaGateway_Tfa_Fast extends ThreemaGateway_Tfa_AbstractProvider
16
{
17
    /**
18
     * Called when verifying displaying the choose 2FA mode.
19
     *
20
     * @return bool
21
     */
22 View Code Duplication
    public function canEnable()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
23
    {
24
        if (!parent::canEnable()) {
25
            return false;
26
        }
27
28
        // check whether it is activated in the settings
29
        /** @var XenForo_Options $options */
30
        $options = XenForo_Application::getOptions();
31
        if (!$options->threema_gateway_tfa_fast) {
32
            return false;
33
        }
34
35
        // this 2FA mode requires end-to-end encryption
36
        if (!$this->gatewaySettings->isEndToEnd()) {
37
            return false;
38
        }
39
40
        // check specific permissions
41
        if (!$this->gatewayPermissions->hasPermission('send') ||
42
            !$this->gatewayPermissions->hasPermission('receive') ||
43
            !$this->gatewayPermissions->hasPermission('fetch')
44
        ) {
45
            return false;
46
        }
47
48
        return true;
49
    }
50
51
    /**
52
     * Called when trying to verify user. Creates secret and registers callback
53
     * request.
54
     *
55
     * @param  string $context
56
     * @param  array  $user
57
     * @param  string $ip
58
     * @param  array  $providerData
59
     * @return array
60
     */
61
    public function triggerVerification($context, array $user, $ip, array &$providerData)
62
    {
63
        parent::triggerVerification($context, $user, $ip, $providerData);
64
65
        // this 2FA mode requires end-to-end encryption
66
        if (!$this->gatewaySettings->isEndToEnd()) {
67
            throw new XenForo_Exception(new XenForo_Phrase('threema_this_action_required_e2e'));
68
        }
69
70
        /** @var XenForo_Options $options */
71
        $options = XenForo_Application::getOptions();
72
73
        //message is only valid for some time
74 View Code Duplication
        if ($context == 'setup') {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
75
            $providerData['validationTime'] = $options->threema_gateway_tfa_fast_validation_setup * 60; //default: 10 minutes
0 ignored issues
show
Unused Code Comprehensibility introduced by
50% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
76
        } else {
77
            $providerData['validationTime'] = $options->threema_gateway_tfa_fast_validation * 60; //default: 3 minutes
0 ignored issues
show
Unused Code Comprehensibility introduced by
50% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
78
        }
79
80
        // temporarily save IP, which triggered this
81
        $providerData['triggerIp'] = $ip;
82
83
        // send message
84
        /** @var string $phrase name of XenForo phrase to use */
85
        $phrase = 'tfa_threemagw_fast_message';
86
        if ($context == 'setup') {
87
            $phrase = 'tfa_threemagw_fast_setup_message';
88
        }
89
90
        /** @var bool $isBlocked true when the user is blocked */
91
        $isBlocked = false;
92
        // whether the login is still blocked right now
93
        if ($this->userIsBlocked($providerData, true)) {
94
            $isBlocked = true;
95
            if (
96
                $this->gatewayPermissions->hasPermission('blockedNotification') &&
97
                !$providerData['blockedNotification']
98
            ) {
99
                // skip message sending
100
                // This is not recommend as it makes the whole request faster,
101
                // which means timing attacks can be used to detect that the
102
                // user is blocked
103
                return [];
104
            }
105
106
            // silently send a block message noticing the user of the
107
            // blocking
108
            $phrase .= '_blocked';
109
        }
110
111
        if (!$isBlocked && $providerData['useShortMessage']) {
112
            $phrase .= '_short';
113
        }
114
115
        /** @var XenForo_Phrase $message */
116
        $message = new XenForo_Phrase($phrase, [
117
            'user' => $user['username'],
118
            'ip' => $ip,
119
            'validationTime' => $this->parseTime($providerData['validationTime']),
120
            'board' => $options->boardTitle,
121
            'board_url' => $options->boardUrl
122
        ]);
123
124
        /** @var int $messageId */
125
        $messageId = $this->sendMessage($providerData['threemaid'], $message);
126
127
        // save message ID as secret here
128
        $providerData['secret']          = $messageId;
129
        $providerData['secretGenerated'] = XenForo_Application::$time;
130
131
        // register message request for Threema callback
132
        $this->registerPendingConfirmationMessage(
133
            $providerData,
134
            ThreemaGateway_Model_TfaPendingMessagesConfirmation::PENDING_REQUEST_DELIVERY_RECEIPT,
135
            $user,
136
            $messageId
137
        );
138
139
        return [];
140
    }
141
142
    /**
143
     * Called when trying to verify user. Shows only the request to confirm
144
     * the message.
145
     *
146
     * @param  XenForo_View $view
147
     * @param  string       $context
148
     * @param  array        $user
149
     * @param  array        $providerData
150
     * @param  array        $triggerData
151
     * @return string       HTML code
152
     */
153
    public function renderVerification(XenForo_View $view, $context, array $user,
154
                                        array $providerData, array $triggerData)
155
    {
156
        parent::renderVerification($view, $context, $user, $providerData, $triggerData);
157
158
        /** @var XenForo_Options $xenOptions */
159
        $xenOptions = XenForo_Application::getOptions();
160
161
        $params = [
162
            'data' => $providerData,
163
            'trigger' => $triggerData,
164
            'context' => $context,
165
            'validationTime' => $this->parseTime($providerData['validationTime']),
166
            'gatewayid' => $this->gatewaySettings->getId(),
167
            'autoTrigger' => $xenOptions->threema_gateway_tfa_fast_auto_trigger
168
        ];
169
        return $view->createTemplateObject('two_step_threemagw_fast', $params)->render();
170
    }
171
172
    /**
173
     * Called when trying to verify user. Checks whether the delivery receipt was received
174
     * from the Threema Gateway callback and acknowledges the message.
175
     *
176
     * @param string $context
177
     * @param array  $input
178
     * @param array  $user
179
     * @param array  $providerData
180
     *
181
     * @return bool
182
     */
183
    public function verifyFromInput($context, XenForo_Input $input, array $user, array &$providerData)
184
    {
185
        $result = parent::verifyFromInput($context, $input, $user, $providerData);
186
187
        // let errors pass through
188
        if (!$result) {
189
            return $result;
190
        }
191
192
193
        // assure that secret has not expired yet
194
        if (!$this->verifySecretIsInTime($providerData)) {
195
            return false;
196
        }
197
198
        // assure that secret has been received at all
199
        if (!isset($providerData['receivedSecret'])) {
200
            return false;
201
        }
202
        if (!isset($providerData['receivedDeliveryReceipt'])) {
203
            return false;
204
        }
205
206
        // prevent replay attacks
207
        if (!$this->verifyNoReplayAttack($providerData, $providerData['receivedSecret'])) {
208
            return false;
209
        }
210
211
        // assure that the secret is the same as required
212
        if (!$this->stringCompare($providerData['secret'], $providerData['receivedSecret'])) {
213
            return false;
214
        }
215
216
        // verify block state
217
        // we do this right now to prevent timing attacks to detect whether the
218
        // user is blocked
219
        if ($this->userIsBlocked($providerData)) {
220
            return false;
221
        }
222
223
        // assure that the current delivery receipt is *not* a decline message
224
        if ($providerData['receivedDeliveryReceipt'] === 4) {
225
            // take more drastic steps if it the user explicitly disallowed access
226
            $this->handleMessageDecline($providerData, $user);
227
            if ($context == 'login') {
228
                // manually need to save provider data as when verification fails this is not done by default
229
                $tfaModel = XenForo_Model::create('XenForo_Model_Tfa');
230
                $tfaModel->updateUserProvider($user['user_id'], $this->_providerId, $providerData, true);
231
                // usually this part of the code should never be reached as the callback/receiver
232
                // triggers this check and saves the resulting provider data in any case
233
            }
234
            return false; // and fail silently
235
        }
236
237
        // assure that the receipt message is a confirmation/acknowledge receipt
238
        // or has at least been a confirmation receipe before
239
        if ($providerData['receivedDeliveryReceipt'] !== 3 &&
240
            $providerData['receivedDeliveryReceiptLargest'] !== 3
241
        ) {
242
            return false;
243
        }
244
245
        $this->updateReplayCheckData($providerData, $providerData['receivedSecret']);
246
247
        // unregister confirmation
248
        $this->unregisterPendingConfirmationMessage(
249
            $providerData,
250
            ThreemaGateway_Model_TfaPendingMessagesConfirmation::PENDING_REQUEST_DELIVERY_RECEIPT
251
        );
252
253
        // unset data
254
        //
255
        // IMPORTANT: This is very important here as some data cannot be replay-
256
        // checked and would therefore result in a vulnerability.
257
        // Especially 'receivedDeliveryReceiptLargest' would lead to problems
258
        // this case when it is not reset!
259
        $this->resetProviderOptionsForTrigger($context, $providerData);
260
261
        return true;
262
    }
263
264
    /**
265
     * Verifies the Treema ID formally after it was entered/changed.
266
     *
267
     * @param XenForo_Input $input
268
     * @param array         $user
269
     * @param array         $error
270
     *
271
     * @return array
272
     */
273
    public function verifySetupFromInput(XenForo_Input $input, array $user, &$error)
274
    {
275
        /** @var array $providerData */
276
        $providerData = parent::verifySetupFromInput($input, $user, $error);
277
278
        // let errors pass through
279
        if (!$providerData) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $providerData of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
280
            return $providerData;
281
        }
282
283
        //add other options to provider data
284
        $providerData['useShortMessage'] = $input->filterSingle('useShortMessage', XenForo_Input::BOOLEAN);
285
286
        // default to false (if not passed/set/allowed as permissions)
287
        $providerData['blockedNotification'] = false;
288
        $providerData['blockTfaMode'] = false;
289
        $providerData['blockUser'] = false;
290
        $providerData['blockIp'] = false;
291
292
        // decline options
293
        if ($this->gatewayPermissions->hasPermission('blockedNotification')) {
294
            $providerData['blockedNotification'] = $input->filterSingle('blockedNotification', XenForo_Input::BOOLEAN);
295
        }
296
        if ($this->gatewayPermissions->hasPermission('blockTfaMode')) {
297
            $providerData['blockTfaMode'] = $input->filterSingle('blockTfaMode', XenForo_Input::BOOLEAN);
298
        }
299
        if ($this->gatewayPermissions->hasPermission('blockUser')) {
300
            $providerData['blockUser'] = $input->filterSingle('blockUser', XenForo_Input::BOOLEAN);
301
        }
302
        if ($this->gatewayPermissions->hasPermission('blockIp')) {
303
            $providerData['blockIp'] = $input->filterSingle('blockIp', XenForo_Input::BOOLEAN);
304
        }
305
306
        return $providerData;
307
    }
308
309
    /**
310
     * Called before the setup verification is shown.
311
     *
312
     * @param array $providerData
313
     * @param array $triggerData
314
     *
315
     * @return bool
316
     */
317
    protected function initiateSetupData(array &$providerData, array &$triggerData)
318
    {
319
        return true;
320
    }
321
322
    /**
323
     * Generates the default provider options at setup time before it is
324
     * displayed to the user.
325
     *
326
     * @return array
327
     */
328
    protected function generateDefaultData()
329
    {
330
        return [
331
            'useShortMessage' => false,
332
            'blockedNotification' => true,
333
            'blockTfaMode' => true,
334
            'blockUser' => false,
335
            'blockIp' => false
336
        ];
337
    }
338
339
    /**
340
    * Adjust the view params for managing the 2FA mode, e.g. add special
341
    * params needed by your template.
342
     *
343
     * @param array  $viewParams
344
     * @param string $context
345
     * @param array  $user
346
     *
347
     * @return array
348
     */
349
    protected function adjustViewParams(array $viewParams, $context, array $user)
350
    {
351
        /** @var XenForo_Options $xenOptions */
352
        $xenOptions = XenForo_Application::getOptions();
353
354
        /** @var array $declinePermissions all permissions when declining a message */
355
        $declinePermissions = [
356
            'blockedNotification' => $this->gatewayPermissions->hasPermission('blockedNotification'),
357
            'blockTfaMode' => $this->gatewayPermissions->hasPermission('blockTfaMode'),
358
            'blockUser' => $this->gatewayPermissions->hasPermission('blockUser'),
359
            'blockIp' => $this->gatewayPermissions->hasPermission('blockIp'),
360
        ];
361
362
        // if user is admin/mod we unfortunately cannot ban them as it is not supported by XenForo
363
        if ($user['is_moderator'] || $user['is_admin']) {
364
            $declinePermissions['blockUser'] = false;
365
        }
366
367
        $viewParams += [
368
            'https' => XenForo_Application::$secure,
369
            'showqrcode' => $xenOptions->threema_gateway_tfa_fast_show_qr_code,
370
            'gatewayid' => $this->gatewaySettings->getId(),
371
            'blockTime' => $this->parseTime($xenOptions->threema_gateway_tfa_blocking_time * 60),
372
            'blockTimeDayRounded' => $this->parseTime(ThreemaGateway_Helper_General::roundToDayRelative($xenOptions->threema_gateway_tfa_blocking_time * 60, true)),
373
            // permissions for decline options
374
            'declinePermissionsSet' => in_array(true, $declinePermissions, true),
375
            'declinePermissions' => $declinePermissions,
376
        ];
377
378
        return $viewParams;
379
    }
380
381
    /**
382
     * Resets the provider options to make sure the current 2FA verification
383
     * does not affect the next one.
384
     *
385
     * @param string $context
386
     * @param array $providerData
387
     */
388
    protected function resetProviderOptionsForTrigger($context, array &$providerData)
389
    {
390
        parent::resetProviderOptionsForTrigger($context, $providerData);
391
392
        unset($providerData['receivedSecret']);
393
        unset($providerData['receivedDeliveryReceipt']);
394
        unset($providerData['receivedDeliveryReceiptLargest']);
395
        unset($providerData['triggerIp']);
396
        unset($providerData['blocked']);
397
        unset($providerData['blockedUntil']);
398
        unset($providerData['blockedBy']);
399
        unset($providerData['messageDeclineHandeled']);
400
    }
401
402
    /**
403
     * Checks whether a user is blocked.
404
     *
405
     * @param array $providerData
406
     * @param bool $messageYetToSent Set to true to specify that the message
407
     *                              still needs to be sent
408
     *
409
     * @return bool
410
     */
411
    private function userIsBlocked(array $providerData, $messageYetToSent = false)
412
    {
413
        // not blocked when not marked as beeing blocked
414
        if (empty($providerData['blocked'])) {
415
            return false;
416
        }
417
418
        // assure that block is not expired yet
419
        if (XenForo_Application::$time > $providerData['blockedUntil']) {
420
            return false;
421
        }
422
423
        // as message ID is evaluated below, we need to assure that it is
424
        // correct/already set
425
        if ($messageYetToSent) {
426
            // if the message ID has not been sent yet, we know the user is blocked
427
            return true;
428
        }
429
430
        // exception: ignore blocking if the message ID is the same and the
431
        // delivery receipt is an accept message (which overwrites decline
432
        // messages in this case)
433
        if ($this->stringCompare($providerData['blockedBy'], $providerData['secret']) &&
434
            $providerData['receivedDeliveryReceipt'] == 3
435
        ) {
436
            // this makes it possible to 'correct' a possible wrong tap on 'decline'
437
            return false;
438
        }
439
440
        return true;
441
    }
442
443
    /**
444
     * Handles the actions when a user declines a received message.
445
     *
446
     * It can block the login for some time, ban the user temporarily or even
447
     * ban the IP permanently.
448
     *
449
     * @param array $providerData
450
     * @param array $user aray of user data
451
     * @param string|null $ip the current IP address
452
     */
453
    private function handleMessageDecline(array &$providerData, array $user, $ip = null)
454
    {
455
        /** @var XenForo_Options $xenOptions */
456
        $xenOptions = XenForo_Application::getOptions();
457
        /** @var int $blockingTime seconds how long users should be blocked */
458
        $blockingTime = $xenOptions->threema_gateway_tfa_blocking_time * 60;
459
460
        if (!$ip) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $ip of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
461
            $ip = $providerData['triggerIp'];
462
        }
463
        // cancel, if already handeled
464
        if (!empty($providerData['messageDeclineHandeled'])) {
465
            return;
466
        }
467
468
        /** @var string $blockActions description of actions taken */
469
        $blockActions = '';
470
471
        // silently ban 2FA login
472
        if ($this->gatewayPermissions->hasPermission('blockTfaMode') &&
473
            $providerData['blockTfaMode']
474
        ) {
475
            // ban this 2FA method
476
            $providerData['blocked'] = true;
477
            $providerData['blockedBy'] = $providerData['secret'];
478
            $providerData['blockedUntil'] = XenForo_Application::$time + $blockingTime;
479
480
            // append to action list
481
            $blockActions .= ' ' . (new XenForo_Phrase('tfa_threemagw_message_blocked_login', [
482
                'blockTime' => $this->parseTime($blockingTime),
483
            ]))->render();
484
        }
485
486
        // ban user
487
        // Note that the user is not blocked from logging in, in this case;
488
        // they are just shown a blocking message after logging in.
489
        if ($this->gatewayPermissions->hasPermission('blockUser') &&
490
            $providerData['blockUser']
491
        ) {
492
            /** @var XenForo_DataWriter_UserBan $userBanDw */
493
            $userBanDw = XenForo_DataWriter::create('XenForo_DataWriter_UserBan', XenForo_DataWriter::ERROR_SILENT);
494
            $userBanDw->set('user_id', $user['user_id']);
495
            $userBanDw->set('ban_user_id', $user['user_id']);
496
            $userBanDw->set('user_reason', new XenForo_Phrase('threemagw_tfa_user_banned'));
497
            // as the ban is only lifted daily we need to set a useful day time
498
            $userBanDw->set('end_date',
499
                // round unix time to day (00:00)
500
                ThreemaGateway_Helper_General::roundToDay(
501
                    XenForo_Application::$time + $blockingTime,
502
                    true // round up to next full day
503
                )
504
            );
505
            $userBanDw->set('triggered', 1);
506
            $userBanDw->save();
507
508
            // append to action list
509
            $blockActions .= ' ' . (new XenForo_Phrase('tfa_threemagw_message_blocked_user', [
510
                'blockTime' => $this->parseTime(ThreemaGateway_Helper_General::roundToDayRelative($blockingTime, true)),
511
            ]))->render();
512
        }
513
514
        // ban ip
515
        if ($this->gatewayPermissions->hasPermission('blockIp') &&
516
            $providerData['blockIp']
517
        ) {
518
            /** @var XenForo_Model_Banning $ipBanModel */
519
            $ipBanModel = XenForo_Model::create('XenForo_Model_Banning');
520
            $ipBanModel->banIp($ip);
521
522
            // append to action list
523
            $blockActions .= ' ' . (new XenForo_Phrase('tfa_threemagw_message_blocked_ip'))->render();
524
        }
525
526
        // send notification message
527
        if ($this->gatewayPermissions->hasPermission('blockedNotification') &&
528
            $providerData['blockedNotification']
529
        ) {
530
            // remove unneccessary whitespace
531
            $blockActions = trim($blockActions);
532
533
            // add line breaks if actions were executed
534
            if ($blockActions) {
535
                $blockActions = PHP_EOL . $blockActions;
536
            } else {
0 ignored issues
show
Unused Code introduced by
This else statement is empty and can be removed.

This check looks for the else branches of if statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These else branches can be removed.

if (rand(1, 6) > 3) {
print "Check failed";
} else {
    //print "Check succeeded";
}

could be turned into

if (rand(1, 6) > 3) {
    print "Check failed";
}

This is much more concise to read.

Loading history...
537
                // theoretically we could explicitly state that nothing has
538
                // been done, but this is not particularly useful:
539
                // $blockActions = (new XenForo_Phrase('tfa_threemagw_message_blocked_nothing'))->render();
0 ignored issues
show
Unused Code Comprehensibility introduced by
62% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
540
            }
541
542
            // add information that this is reversible if no permanent block actions
543
            // have been executed
544
            if (!$providerData['blockIp'] &&
545
                !$providerData['blockUser']
546
            ) {
547
                $blockActions = PHP_EOL . PHP_EOL . (new XenForo_Phrase('tfa_threemagw_message_blocked_canreverse'))->render();
548
            }
549
550
            /** @var XenForo_Options $options */
551
            $options = XenForo_Application::getOptions();
552
553
            /** @var XenForo_Phrase $message */
554
            $message = new XenForo_Phrase('tfa_threemagw_message_blocked_general', [
555
                'user' => $user['username'],
556
                'ip' => $ip,
557
                'blockActions' => $blockActions,
558
                'board' => $options->boardTitle,
559
                'board_url' => $options->boardUrl
560
            ]);
561
562
            $this->sendMessage($providerData['threemaid'], $message);
563
        }
564
565
        // set value to prevent duplicate handling by this method
566
        // This is needed as otherwise this method is executed again and again
567
        // if the user has not activated blockTfaMode.
568
        $providerData['messageDeclineHandeled'] = true;
569
    }
570
}
571