Security::permissionFailure()   F
last analyzed

Complexity

Conditions 19
Paths 228

Size

Total Lines 118
Code Lines 68

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 19
eloc 68
c 1
b 0
f 0
nc 228
nop 2
dl 0
loc 118
rs 3.3333

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace SilverStripe\Security;
4
5
use LogicException;
6
use Page;
0 ignored issues
show
Bug introduced by
The type Page was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
7
use ReflectionClass;
8
use SilverStripe\CMS\Controllers\ModelAsController;
0 ignored issues
show
Bug introduced by
The type SilverStripe\CMS\Controllers\ModelAsController was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
9
use SilverStripe\Control\Controller;
10
use SilverStripe\Control\Director;
11
use SilverStripe\Control\HTTPRequest;
12
use SilverStripe\Control\HTTPResponse;
13
use SilverStripe\Control\HTTPResponse_Exception;
14
use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware;
15
use SilverStripe\Control\RequestHandler;
16
use SilverStripe\Core\ClassInfo;
17
use SilverStripe\Core\Convert;
18
use SilverStripe\Core\Injector\Injector;
19
use SilverStripe\Dev\Deprecation;
20
use SilverStripe\Dev\TestOnly;
21
use SilverStripe\Forms\Form;
22
use SilverStripe\ORM\ArrayList;
23
use SilverStripe\ORM\DataObject;
24
use SilverStripe\ORM\DB;
25
use SilverStripe\ORM\FieldType\DBField;
26
use SilverStripe\ORM\FieldType\DBHTMLText;
27
use SilverStripe\ORM\ValidationResult;
28
use SilverStripe\View\ArrayData;
29
use SilverStripe\View\Requirements;
30
use SilverStripe\View\SSViewer;
31
use SilverStripe\View\TemplateGlobalProvider;
32
33
/**
34
 * Implements a basic security model
35
 */
36
class Security extends Controller implements TemplateGlobalProvider
37
{
38
39
    private static $allowed_actions = array(
0 ignored issues
show
introduced by
The private property $allowed_actions is not used, and could be removed.
Loading history...
40
        'basicauthlogin',
41
        'changepassword',
42
        'index',
43
        'login',
44
        'logout',
45
        'lostpassword',
46
        'passwordsent',
47
        'ping',
48
    );
49
50
    /**
51
     * If set to TRUE to prevent sharing of the session across several sites
52
     * in the domain.
53
     *
54
     * @config
55
     * @var bool
56
     */
57
    private static $strict_path_checking = false;
0 ignored issues
show
introduced by
The private property $strict_path_checking is not used, and could be removed.
Loading history...
58
59
    /**
60
     * The password encryption algorithm to use by default.
61
     * This is an arbitrary code registered through {@link PasswordEncryptor}.
62
     *
63
     * @config
64
     * @var string
65
     */
66
    private static $password_encryption_algorithm = 'blowfish';
0 ignored issues
show
introduced by
The private property $password_encryption_algorithm is not used, and could be removed.
Loading history...
67
68
    /**
69
     * Showing "Remember me"-checkbox
70
     * on loginform, and saving encrypted credentials to a cookie.
71
     *
72
     * @config
73
     * @var bool
74
     */
75
    private static $autologin_enabled = true;
0 ignored issues
show
introduced by
The private property $autologin_enabled is not used, and could be removed.
Loading history...
76
77
    /**
78
     * Determine if login username may be remembered between login sessions
79
     * If set to false this will disable auto-complete and prevent username persisting in the session
80
     *
81
     * @config
82
     * @var bool
83
     */
84
    private static $remember_username = true;
0 ignored issues
show
introduced by
The private property $remember_username is not used, and could be removed.
Loading history...
85
86
    /**
87
     * Location of word list to use for generating passwords
88
     *
89
     * @config
90
     * @var string
91
     */
92
    private static $word_list = './wordlist.txt';
0 ignored issues
show
introduced by
The private property $word_list is not used, and could be removed.
Loading history...
93
94
    /**
95
     * @config
96
     * @var string
97
     */
98
    private static $template = 'BlankPage';
0 ignored issues
show
introduced by
The private property $template is not used, and could be removed.
Loading history...
99
100
    /**
101
     * Template that is used to render the pages.
102
     *
103
     * @var string
104
     * @config
105
     */
106
    private static $template_main = 'Page';
0 ignored issues
show
introduced by
The private property $template_main is not used, and could be removed.
Loading history...
107
108
    /**
109
     * Class to use for page rendering
110
     *
111
     * @var string
112
     * @config
113
     */
114
    private static $page_class = Page::class;
0 ignored issues
show
introduced by
The private property $page_class is not used, and could be removed.
Loading history...
115
116
    /**
117
     * Default message set used in permission failures.
118
     *
119
     * @config
120
     * @var array|string
121
     */
122
    private static $default_message_set;
0 ignored issues
show
introduced by
The private property $default_message_set is not used, and could be removed.
Loading history...
123
124
    /**
125
     * The default login URL
126
     *
127
     * @config
128
     *
129
     * @var string
130
     */
131
    private static $login_url = 'Security/login';
0 ignored issues
show
introduced by
The private property $login_url is not used, and could be removed.
Loading history...
132
133
    /**
134
     * The default logout URL
135
     *
136
     * @config
137
     *
138
     * @var string
139
     */
140
    private static $logout_url = 'Security/logout';
0 ignored issues
show
introduced by
The private property $logout_url is not used, and could be removed.
Loading history...
141
142
    /**
143
     * The default lost password URL
144
     *
145
     * @config
146
     *
147
     * @var string
148
     */
149
    private static $lost_password_url = 'Security/lostpassword';
0 ignored issues
show
introduced by
The private property $lost_password_url is not used, and could be removed.
Loading history...
150
151
    /**
152
     * Value of X-Frame-Options header
153
     *
154
     * @config
155
     * @var string
156
     */
157
    private static $frame_options = 'SAMEORIGIN';
0 ignored issues
show
introduced by
The private property $frame_options is not used, and could be removed.
Loading history...
158
159
    /**
160
     * Value of the X-Robots-Tag header (for the Security section)
161
     *
162
     * @config
163
     * @var string
164
     */
165
    private static $robots_tag = 'noindex, nofollow';
0 ignored issues
show
introduced by
The private property $robots_tag is not used, and could be removed.
Loading history...
166
167
    /**
168
     * Enable or disable recording of login attempts
169
     * through the {@link LoginRecord} object.
170
     *
171
     * @config
172
     * @var boolean $login_recording
173
     */
174
    private static $login_recording = false;
0 ignored issues
show
introduced by
The private property $login_recording is not used, and could be removed.
Loading history...
175
176
    /**
177
     * @var boolean If set to TRUE or FALSE, {@link database_is_ready()}
178
     * will always return FALSE. Used for unit testing.
179
     */
180
    protected static $force_database_is_ready;
181
182
    /**
183
     * When the database has once been verified as ready, it will not do the
184
     * checks again.
185
     *
186
     * @var bool
187
     */
188
    protected static $database_is_ready = false;
189
190
    /**
191
     * @var Authenticator[] available authenticators
192
     */
193
    private $authenticators = [];
194
195
    /**
196
     * @var Member Currently logged in user (if available)
197
     */
198
    protected static $currentUser;
199
200
    /**
201
     * @return Authenticator[]
202
     */
203
    public function getAuthenticators()
204
    {
205
        return array_filter($this->authenticators);
206
    }
207
208
    /**
209
     * @param Authenticator[] $authenticators
210
     */
211
    public function setAuthenticators(array $authenticators)
212
    {
213
        $this->authenticators = $authenticators;
214
    }
215
216
    protected function init()
217
    {
218
        parent::init();
219
220
        // Prevent clickjacking, see https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options
221
        $frameOptions = static::config()->get('frame_options');
222
        if ($frameOptions) {
223
            $this->getResponse()->addHeader('X-Frame-Options', $frameOptions);
224
        }
225
226
        // Prevent search engines from indexing the login page
227
        $robotsTag = static::config()->get('robots_tag');
228
        if ($robotsTag) {
229
            $this->getResponse()->addHeader('X-Robots-Tag', $robotsTag);
230
        }
231
    }
232
233
    public function index()
234
    {
235
        $this->httpError(404); // no-op
236
    }
237
238
    /**
239
     * Get the selected authenticator for this request
240
     *
241
     * @param string $name The identifier of the authenticator in your config
242
     * @return Authenticator Class name of Authenticator
243
     * @throws LogicException
244
     */
245
    protected function getAuthenticator($name = 'default')
246
    {
247
        $authenticators = $this->getAuthenticators();
248
249
        if (isset($authenticators[$name])) {
250
            return $authenticators[$name];
251
        }
252
253
        throw new LogicException('No valid authenticator found');
254
    }
255
256
    /**
257
     * Get all registered authenticators
258
     *
259
     * @param int $service The type of service that is requested
260
     * @return Authenticator[] Return an array of Authenticator objects
261
     */
262
    public function getApplicableAuthenticators($service = Authenticator::LOGIN)
263
    {
264
        $authenticators = $this->getAuthenticators();
265
266
        /** @var Authenticator $authenticator */
267
        foreach ($authenticators as $name => $authenticator) {
268
            if (!($authenticator->supportedServices() & $service)) {
269
                unset($authenticators[$name]);
270
            }
271
        }
272
273
        if (empty($authenticators)) {
274
            throw new LogicException('No applicable authenticators found');
275
        }
276
277
        return $authenticators;
278
    }
279
280
    /**
281
     * Check if a given authenticator is registered
282
     *
283
     * @param string $authenticator The configured identifier of the authenicator
284
     * @return bool Returns TRUE if the authenticator is registered, FALSE
285
     *              otherwise.
286
     */
287
    public function hasAuthenticator($authenticator)
288
    {
289
        $authenticators = $this->getAuthenticators();
290
291
        return !empty($authenticators[$authenticator]);
292
    }
293
294
    /**
295
     * Register that we've had a permission failure trying to view the given page
296
     *
297
     * This will redirect to a login page.
298
     * If you don't provide a messageSet, a default will be used.
299
     *
300
     * @param Controller $controller The controller that you were on to cause the permission
301
     *                               failure.
302
     * @param string|array $messageSet The message to show to the user. This
303
     *                                 can be a string, or a map of different
304
     *                                 messages for different contexts.
305
     *                                 If you pass an array, you can use the
306
     *                                 following keys:
307
     *                                   - default: The default message
308
     *                                   - alreadyLoggedIn: The message to
309
     *                                                      show if the user
310
     *                                                      is already logged
311
     *                                                      in and lacks the
312
     *                                                      permission to
313
     *                                                      access the item.
314
     *
315
     * The alreadyLoggedIn value can contain a '%s' placeholder that will be replaced with a link
316
     * to log in.
317
     * @return HTTPResponse
318
     */
319
    public static function permissionFailure($controller = null, $messageSet = null)
320
    {
321
        self::set_ignore_disallowed_actions(true);
322
323
        // Parse raw message / escape type
324
        $parseMessage = function ($message) {
325
            if ($message instanceof DBField) {
326
                return [
327
                    $message->getValue(),
328
                    $message->config()->get('escape_type') === 'raw'
329
                        ? ValidationResult::CAST_TEXT
330
                        : ValidationResult::CAST_HTML,
331
                ];
332
            }
333
334
            // Default to escaped value
335
            return [
336
                $message,
337
                ValidationResult::CAST_TEXT,
338
            ];
339
        };
340
341
        if (!$controller && Controller::has_curr()) {
342
            $controller = Controller::curr();
343
        }
344
345
        if (Director::is_ajax()) {
346
            $response = ($controller) ? $controller->getResponse() : new HTTPResponse();
347
            $response->setStatusCode(403);
348
            if (!static::getCurrentUser()) {
349
                $response->setBody(
350
                    _t('SilverStripe\\CMS\\Controllers\\ContentController.NOTLOGGEDIN', 'Not logged in')
351
                );
352
                $response->setStatusDescription(
353
                    _t('SilverStripe\\CMS\\Controllers\\ContentController.NOTLOGGEDIN', 'Not logged in')
354
                );
355
                // Tell the CMS to allow re-authentication
356
                if (CMSSecurity::singleton()->enabled()) {
357
                    $response->addHeader('X-Reauthenticate', '1');
358
                }
359
            }
360
361
            return $response;
362
        }
363
364
        // Prepare the messageSet provided
365
        if (!$messageSet) {
366
            if ($configMessageSet = static::config()->get('default_message_set')) {
367
                $messageSet = $configMessageSet;
368
            } else {
369
                $messageSet = array(
370
                    'default' => _t(
371
                        __CLASS__ . '.NOTEPAGESECURED',
372
                        "That page is secured. Enter your credentials below and we will send "
373
                            . "you right along."
374
                    ),
375
                    'alreadyLoggedIn' => _t(
376
                        __CLASS__ . '.ALREADYLOGGEDIN',
377
                        "You don't have access to this page.  If you have another account that "
378
                            . "can access that page, you can log in again below."
379
                    )
380
                );
381
            }
382
        }
383
384
        if (!is_array($messageSet)) {
385
            $messageSet = array('default' => $messageSet);
386
        }
387
388
        $member = static::getCurrentUser();
389
390
        // Work out the right message to show
391
        if ($member && $member->exists()) {
392
            $response = ($controller) ? $controller->getResponse() : new HTTPResponse();
393
            $response->setStatusCode(403);
394
395
            //If 'alreadyLoggedIn' is not specified in the array, then use the default
396
            //which should have been specified in the lines above
397
            if (isset($messageSet['alreadyLoggedIn'])) {
398
                $message = $messageSet['alreadyLoggedIn'];
399
            } else {
400
                $message = $messageSet['default'];
401
            }
402
403
            list($messageText, $messageCast) = $parseMessage($message);
404
            static::singleton()->setSessionMessage($messageText, ValidationResult::TYPE_WARNING, $messageCast);
405
            $request = new HTTPRequest('GET', '/');
406
            if ($controller) {
407
                $request->setSession($controller->getRequest()->getSession());
408
            }
409
            $loginResponse = static::singleton()->login($request);
410
            if ($loginResponse instanceof HTTPResponse) {
411
                return $loginResponse;
412
            }
413
414
            $response->setBody((string)$loginResponse);
415
416
            $controller->extend('permissionDenied', $member);
417
418
            return $response;
419
        }
420
        $message = $messageSet['default'];
421
422
        $request = $controller->getRequest();
423
        if ($request->hasSession()) {
424
            list($messageText, $messageCast) = $parseMessage($message);
425
            static::singleton()->setSessionMessage($messageText, ValidationResult::TYPE_WARNING, $messageCast);
426
427
            $request->getSession()->set("BackURL", $_SERVER['REQUEST_URI']);
428
        }
429
430
        // TODO AccessLogEntry needs an extension to handle permission denied errors
431
        // Audit logging hook
432
        $controller->extend('permissionDenied', $member);
433
434
        return $controller->redirect(Controller::join_links(
435
            Security::config()->uninherited('login_url'),
436
            "?BackURL=" . urlencode($_SERVER['REQUEST_URI'])
437
        ));
438
    }
439
440
    /**
441
     * @param null|Member $currentUser
442
     */
443
    public static function setCurrentUser($currentUser = null)
444
    {
445
        self::$currentUser = $currentUser;
446
    }
447
448
    /**
449
     * @return null|Member
450
     */
451
    public static function getCurrentUser()
452
    {
453
        return self::$currentUser;
454
    }
455
456
    /**
457
     * Get the login forms for all available authentication methods
458
     *
459
     * @deprecated 5.0.0 Now handled by {@link static::delegateToMultipleHandlers}
460
     *
461
     * @return array Returns an array of available login forms (array of Form
462
     *               objects).
463
     *
464
     */
465
    public function getLoginForms()
466
    {
467
        Deprecation::notice('5.0.0', 'Now handled by delegateToMultipleHandlers');
468
469
        return array_map(
470
            function (Authenticator $authenticator) {
471
                return [
472
                    $authenticator->getLoginHandler($this->Link())->loginForm()
473
                ];
474
            },
475
            $this->getApplicableAuthenticators()
476
        );
477
    }
478
479
480
    /**
481
     * Get a link to a security action
482
     *
483
     * @param string $action Name of the action
484
     * @return string Returns the link to the given action
485
     */
486
    public function Link($action = null)
487
    {
488
        /** @skipUpgrade */
489
        $link = Controller::join_links(Director::baseURL(), "Security", $action);
490
        $this->extend('updateLink', $link, $action);
491
        return $link;
492
    }
493
494
    /**
495
     * This action is available as a keep alive, so user
496
     * sessions don't timeout. A common use is in the admin.
497
     */
498
    public function ping()
499
    {
500
        HTTPCacheControlMiddleware::singleton()->disableCache();
501
        Requirements::clear();
502
        return 1;
503
    }
504
505
    /**
506
     * Perform pre-login checking and prepare a response if available prior to login
507
     *
508
     * @return HTTPResponse Substitute response object if the login process should be curcumvented.
509
     * Returns null if should proceed as normal.
510
     */
511
    protected function preLogin()
512
    {
513
        // Event handler for pre-login, with an option to let it break you out of the login form
514
        $eventResults = $this->extend('onBeforeSecurityLogin');
515
        // If there was a redirection, return
516
        if ($this->redirectedTo()) {
517
            return $this->getResponse();
518
        }
519
        // If there was an HTTPResponse object returned, then return that
520
        if ($eventResults) {
521
            foreach ($eventResults as $result) {
522
                if ($result instanceof HTTPResponse) {
523
                    return $result;
524
                }
525
            }
526
        }
527
528
        // If arriving on the login page already logged in, with no security error, and a ReturnURL then redirect
529
        // back. The login message check is neccesary to prevent infinite loops where BackURL links to
530
        // an action that triggers Security::permissionFailure.
531
        // This step is necessary in cases such as automatic redirection where a user is authenticated
532
        // upon landing on an SSL secured site and is automatically logged in, or some other case
533
        // where the user has permissions to continue but is not given the option.
534
        if (!$this->getSessionMessage()
535
            && ($member = static::getCurrentUser())
536
            && $member->exists()
537
            && $this->getRequest()->requestVar('BackURL')
538
        ) {
539
            return $this->redirectBack();
540
        }
541
542
        return null;
543
    }
544
545
    public function getRequest()
546
    {
547
        // Support Security::singleton() where a request isn't always injected
548
        $request = parent::getRequest();
549
        if ($request) {
0 ignored issues
show
introduced by
$request is of type SilverStripe\Control\HTTPRequest, thus it always evaluated to true.
Loading history...
550
            return $request;
551
        }
552
553
        if (Controller::has_curr() && Controller::curr() !== $this) {
554
            return Controller::curr()->getRequest();
555
        }
556
557
        return null;
558
    }
559
560
    /**
561
     * Prepare the controller for handling the response to this request
562
     *
563
     * @param string $title Title to use
564
     * @return Controller
565
     */
566
    protected function getResponseController($title)
567
    {
568
        // Use the default setting for which Page to use to render the security page
569
        $pageClass = $this->config()->get('page_class');
570
        if (!$pageClass || !class_exists($pageClass)) {
571
            return $this;
572
        }
573
574
        // Create new instance of page holder
575
        /** @var Page $holderPage */
576
        $holderPage = Injector::inst()->create($pageClass);
577
        $holderPage->Title = $title;
578
        /** @skipUpgrade */
579
        $holderPage->URLSegment = 'Security';
580
        // Disable ID-based caching  of the log-in page by making it a random number
581
        $holderPage->ID = -1 * random_int(1, 10000000);
582
583
        $controller = ModelAsController::controller_for($holderPage);
584
        $controller->setRequest($this->getRequest());
585
        $controller->doInit();
586
587
        return $controller;
588
    }
589
590
    /**
591
     * Combine the given forms into a formset with a tabbed interface
592
     *
593
     * @param array|Form[] $forms
594
     * @return string
595
     */
596
    protected function generateTabbedFormSet($forms)
597
    {
598
        if (count($forms) === 1) {
599
            return $forms;
600
        }
601
602
        $viewData = new ArrayData([
603
            'Forms' => new ArrayList($forms),
604
        ]);
605
606
        return $viewData->renderWith(
607
            $this->getTemplatesFor('MultiAuthenticatorTabbedForms')
608
        );
609
    }
610
611
    /**
612
     * Get the HTML Content for the $Content area during login
613
     *
614
     * @param string &$messageType Type of message, if available, passed back to caller
615
     * @return string Message in HTML format
616
     */
617
    protected function getSessionMessage(&$messageType = null)
618
    {
619
        $session = $this->getRequest()->getSession();
620
        $message = $session->get('Security.Message.message');
621
        $messageType = null;
622
        if (empty($message)) {
623
            return null;
624
        }
625
626
        $messageType = $session->get('Security.Message.type');
627
        $messageCast = $session->get('Security.Message.cast');
628
        if ($messageCast !== ValidationResult::CAST_HTML) {
629
            $message = Convert::raw2xml($message);
630
        }
631
632
        return sprintf('<p class="message %s">%s</p>', Convert::raw2att($messageType), $message);
0 ignored issues
show
Bug introduced by
It seems like SilverStripe\Core\Convert::raw2att($messageType) can also be of type array and array; however, parameter $args of sprintf() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

632
        return sprintf('<p class="message %s">%s</p>', /** @scrutinizer ignore-type */ Convert::raw2att($messageType), $message);
Loading history...
633
    }
634
635
    /**
636
     * Set the next message to display for the security login page. Defaults to warning
637
     *
638
     * @param string $message Message
639
     * @param string $messageType Message type. One of ValidationResult::TYPE_*
640
     * @param string $messageCast Message cast. One of ValidationResult::CAST_*
641
     */
642
    public function setSessionMessage(
643
        $message,
644
        $messageType = ValidationResult::TYPE_WARNING,
645
        $messageCast = ValidationResult::CAST_TEXT
646
    ) {
647
        Controller::curr()
648
            ->getRequest()
649
            ->getSession()
650
            ->set("Security.Message.message", $message)
651
            ->set("Security.Message.type", $messageType)
652
            ->set("Security.Message.cast", $messageCast);
653
    }
654
655
    /**
656
     * Clear login message
657
     */
658
    public static function clearSessionMessage()
659
    {
660
        Controller::curr()
661
            ->getRequest()
662
            ->getSession()
663
            ->clear("Security.Message");
664
    }
665
666
    /**
667
     * Show the "login" page
668
     *
669
     * For multiple authenticators, Security_MultiAuthenticatorLogin is used.
670
     * See getTemplatesFor and getIncludeTemplate for how to override template logic
671
     *
672
     * @param null|HTTPRequest $request
673
     * @param int $service
674
     * @return HTTPResponse|string Returns the "login" page as HTML code.
675
     * @throws HTTPResponse_Exception
676
     */
677
    public function login($request = null, $service = Authenticator::LOGIN)
678
    {
679
        if ($request) {
680
            $this->setRequest($request);
681
        } elseif ($this->getRequest()) {
682
            $request = $this->getRequest();
683
        } else {
684
            throw new HTTPResponse_Exception("No request available", 500);
685
        }
686
687
        // Check pre-login process
688
        if ($response = $this->preLogin()) {
689
            return $response;
690
        }
691
        $authName = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $authName is dead and can be removed.
Loading history...
692
693
        $handlers = $this->getServiceAuthenticatorsFromRequest($service, $request);
694
695
        $link = $this->Link('login');
696
        array_walk(
697
            $handlers,
698
            function (Authenticator &$auth, $name) use ($link) {
699
                $auth = $auth->getLoginHandler(Controller::join_links($link, $name));
700
            }
701
        );
702
703
        return $this->delegateToMultipleHandlers(
704
            $handlers,
705
            _t(__CLASS__ . '.LOGIN', 'Log in'),
706
            $this->getTemplatesFor('login'),
707
            [$this, 'aggregateTabbedForms']
708
        );
709
    }
710
711
    /**
712
     * Log the currently logged in user out
713
     *
714
     * Logging out without ID-parameter in the URL, will log the user out of all applicable Authenticators.
715
     *
716
     * Adding an ID will only log the user out of that Authentication method.
717
     *
718
     * @param null|HTTPRequest $request
719
     * @param int $service
720
     * @return HTTPResponse|string
721
     */
722
    public function logout($request = null, $service = Authenticator::LOGOUT)
723
    {
724
        $authName = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $authName is dead and can be removed.
Loading history...
725
726
        if (!$request) {
727
            $request = $this->getRequest();
728
        }
729
730
        $handlers = $this->getServiceAuthenticatorsFromRequest($service, $request);
731
732
        $link = $this->Link('logout');
733
        array_walk(
734
            $handlers,
735
            function (Authenticator &$auth, $name) use ($link) {
736
                $auth = $auth->getLogoutHandler(Controller::join_links($link, $name));
737
            }
738
        );
739
740
        return $this->delegateToMultipleHandlers(
741
            $handlers,
742
            _t(__CLASS__ . '.LOGOUT', 'Log out'),
743
            $this->getTemplatesFor('logout'),
744
            [$this, 'aggregateAuthenticatorResponses']
745
        );
746
    }
747
748
    /**
749
     * Get authenticators for the given service, optionally filtered by the ID parameter
750
     * of the current request
751
     *
752
     * @param int $service
753
     * @param HTTPRequest $request
754
     * @return array|Authenticator[]
755
     * @throws HTTPResponse_Exception
756
     */
757
    protected function getServiceAuthenticatorsFromRequest($service, HTTPRequest $request)
758
    {
759
        $authName = null;
760
761
        if ($request->param('ID')) {
762
            $authName = $request->param('ID');
763
        }
764
765
        // Delegate to a single named handler - e.g. Security/login/<authname>/
766
        if ($authName && $this->hasAuthenticator($authName)) {
767
            if ($request) {
0 ignored issues
show
introduced by
$request is of type SilverStripe\Control\HTTPRequest, thus it always evaluated to true.
Loading history...
768
                $request->shift();
769
            }
770
771
            $authenticator = $this->getAuthenticator($authName);
772
773
            if (!$authenticator->supportedServices() & $service) {
774
                // Try to be helpful and show the service constant name, e.g. Authenticator::LOGIN
775
                $constants = array_flip((new ReflectionClass(Authenticator::class))->getConstants());
776
777
                $message = 'Invalid Authenticator "' . $authName . '" for ';
778
                if (array_key_exists($service, $constants)) {
779
                    $message .= 'service: Authenticator::' . $constants[$service];
780
                } else {
781
                    $message .= 'unknown authenticator service';
782
                }
783
784
                throw new HTTPResponse_Exception($message, 400);
785
            }
786
787
            $handlers = [$authName => $authenticator];
788
        } else {
789
            // Delegate to all of them, building a tabbed view - e.g. Security/login/
790
            $handlers = $this->getApplicableAuthenticators($service);
791
        }
792
793
        return $handlers;
794
    }
795
796
    /**
797
     * Aggregate tabbed forms from each handler to fragments ready to be rendered.
798
     *
799
     * @skipUpgrade
800
     * @param array $results
801
     * @return array
802
     */
803
    protected function aggregateTabbedForms(array $results)
804
    {
805
        $forms = [];
806
        foreach ($results as $authName => $singleResult) {
807
            // The result *must* be an array with a Form key
808
            if (!is_array($singleResult) || !isset($singleResult['Form'])) {
809
                user_error('Authenticator "' . $authName . '" doesn\'t support tabbed forms', E_USER_WARNING);
810
                continue;
811
            }
812
813
            $forms[] = $singleResult['Form'];
814
        }
815
816
        if (!$forms) {
817
            throw new \LogicException('No authenticators found compatible with tabbed forms');
818
        }
819
820
        return [
821
            'Forms' => ArrayList::create($forms),
822
            'Form' => $this->generateTabbedFormSet($forms)
823
        ];
824
    }
825
826
    /**
827
     * We have three possible scenarios.
828
     * We get back Content (e.g. Password Reset)
829
     * We get back a Form (no token set for logout)
830
     * We get back a HTTPResponse, telling us to redirect.
831
     * Return the first one, which is the default response, as that covers all required scenarios
832
     *
833
     * @param array $results
834
     * @return array|HTTPResponse
835
     */
836
    protected function aggregateAuthenticatorResponses($results)
837
    {
838
        $error = false;
839
        $result = null;
840
        foreach ($results as $authName => $singleResult) {
841
            if (($singleResult instanceof HTTPResponse) ||
842
                (is_array($singleResult) &&
843
                    (isset($singleResult['Content']) || isset($singleResult['Form'])))
844
            ) {
845
                // return the first successful response
846
                return $singleResult;
847
            } else {
848
                // Not a valid response
849
                $error = true;
850
            }
851
        }
852
853
        if ($error) {
854
            throw new \LogicException('No authenticators found compatible with logout operation');
855
        }
856
857
        return $result;
858
    }
859
860
    /**
861
     * Delegate to a number of handlers and aggregate the results. This is used, for example, to
862
     * build the log-in page where there are multiple authenticators active.
863
     *
864
     * If a single handler is passed, delegateToHandler() will be called instead
865
     *
866
     * @param array|RequestHandler[] $handlers
867
     * @param string $title The title of the form
868
     * @param array $templates
869
     * @param callable $aggregator
870
     * @return array|HTTPResponse|RequestHandler|DBHTMLText|string
871
     */
872
    protected function delegateToMultipleHandlers(array $handlers, $title, array $templates, callable $aggregator)
873
    {
874
875
        // Simpler case for a single authenticator
876
        if (count($handlers) === 1) {
877
            return $this->delegateToHandler(array_values($handlers)[0], $title, $templates);
878
        }
879
880
        // Process each of the handlers
881
        $results = array_map(
882
            function (RequestHandler $handler) {
883
                return $handler->handleRequest($this->getRequest());
884
            },
885
            $handlers
886
        );
887
888
        $response = call_user_func_array($aggregator, [$results]);
889
        // The return could be a HTTPResponse, in which we don't want to call the render
890
        if (is_array($response)) {
891
            return $this->renderWrappedController($title, $response, $templates);
892
        }
893
894
        return $response;
895
    }
896
897
    /**
898
     * Delegate to another RequestHandler, rendering any fragment arrays into an appropriate.
899
     * controller.
900
     *
901
     * @param RequestHandler $handler
902
     * @param string $title The title of the form
903
     * @param array $templates
904
     * @return array|HTTPResponse|RequestHandler|DBHTMLText|string
905
     */
906
    protected function delegateToHandler(RequestHandler $handler, $title, array $templates = [])
907
    {
908
        $result = $handler->handleRequest($this->getRequest());
909
910
        // Return the customised controller - may be used to render a Form (e.g. login form)
911
        if (is_array($result)) {
912
            $result = $this->renderWrappedController($title, $result, $templates);
913
        }
914
915
        return $result;
916
    }
917
918
    /**
919
     * Render the given fragments into a security page controller with the given title.
920
     *
921
     * @param string $title string The title to give the security page
922
     * @param array $fragments A map of objects to render into the page, e.g. "Form"
923
     * @param array $templates An array of templates to use for the render
924
     * @return HTTPResponse|DBHTMLText
925
     */
926
    protected function renderWrappedController($title, array $fragments, array $templates)
927
    {
928
        $controller = $this->getResponseController($title);
929
930
        // if the controller calls Director::redirect(), this will break early
931
        if (($response = $controller->getResponse()) && $response->isFinished()) {
932
            return $response;
933
        }
934
935
        // Handle any form messages from validation, etc.
936
        $messageType = '';
937
        $message = $this->getSessionMessage($messageType);
938
939
        // We've displayed the message in the form output, so reset it for the next run.
940
        static::clearSessionMessage();
941
942
        if ($message) {
943
            $messageResult = [
944
                'Content'     => DBField::create_field('HTMLFragment', $message),
945
                'Message'     => DBField::create_field('HTMLFragment', $message),
946
                'MessageType' => $messageType
947
            ];
948
            $fragments = array_merge($fragments, $messageResult);
949
        }
950
951
        return $controller->customise($fragments)->renderWith($templates);
952
    }
953
954
    public function basicauthlogin()
955
    {
956
        $member = BasicAuth::requireLogin($this->getRequest(), 'SilverStripe login', 'ADMIN');
957
        static::setCurrentUser($member);
0 ignored issues
show
Bug introduced by
It seems like $member can also be of type boolean; however, parameter $currentUser of SilverStripe\Security\Security::setCurrentUser() does only seem to accept SilverStripe\Security\Member|null, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

957
        static::setCurrentUser(/** @scrutinizer ignore-type */ $member);
Loading history...
958
    }
959
960
    /**
961
     * Show the "lost password" page
962
     *
963
     * @return string Returns the "lost password" page as HTML code.
964
     */
965
    public function lostpassword()
966
    {
967
        $handlers = [];
968
        $authenticators = $this->getApplicableAuthenticators(Authenticator::RESET_PASSWORD);
969
        /** @var Authenticator $authenticator */
970
        foreach ($authenticators as $authenticator) {
971
            $handlers[] = $authenticator->getLostPasswordHandler(
972
                Controller::join_links($this->Link(), 'lostpassword')
973
            );
974
        }
975
976
        return $this->delegateToMultipleHandlers(
977
            $handlers,
978
            _t('SilverStripe\\Security\\Security.LOSTPASSWORDHEADER', 'Lost Password'),
979
            $this->getTemplatesFor('lostpassword'),
980
            [$this, 'aggregateAuthenticatorResponses']
981
        );
982
    }
983
984
    /**
985
     * Show the "change password" page.
986
     * This page can either be called directly by logged-in users
987
     * (in which case they need to provide their old password),
988
     * or through a link emailed through {@link lostpassword()}.
989
     * In this case no old password is required, authentication is ensured
990
     * through the Member.AutoLoginHash property.
991
     *
992
     * @see ChangePasswordForm
993
     *
994
     * @return string|HTTPRequest Returns the "change password" page as HTML code, or a redirect response
995
     */
996
    public function changepassword()
997
    {
998
        /** @var array|Authenticator[] $authenticators */
999
        $authenticators = $this->getApplicableAuthenticators(Authenticator::CHANGE_PASSWORD);
1000
        $handlers = [];
1001
        foreach ($authenticators as $authenticator) {
1002
            $handlers[] = $authenticator->getChangePasswordHandler($this->Link('changepassword'));
1003
        }
1004
1005
        return $this->delegateToMultipleHandlers(
1006
            $handlers,
1007
            _t('SilverStripe\\Security\\Security.CHANGEPASSWORDHEADER', 'Change your password'),
1008
            $this->getTemplatesFor('changepassword'),
1009
            [$this, 'aggregateAuthenticatorResponses']
1010
        );
1011
    }
1012
1013
    /**
1014
     * Create a link to the password reset form.
1015
     *
1016
     * GET parameters used:
1017
     * - m: member ID
1018
     * - t: plaintext token
1019
     *
1020
     * @param Member $member Member object associated with this link.
1021
     * @param string $autologinToken The auto login token.
1022
     * @return string
1023
     */
1024
    public static function getPasswordResetLink($member, $autologinToken)
1025
    {
1026
        $autologinToken = urldecode($autologinToken);
1027
1028
        return static::singleton()->Link('changepassword') . "?m={$member->ID}&t=$autologinToken";
1029
    }
1030
1031
    /**
1032
     * Determine the list of templates to use for rendering the given action.
1033
     *
1034
     * @skipUpgrade
1035
     * @param string $action
1036
     * @return array Template list
1037
     */
1038
    public function getTemplatesFor($action)
1039
    {
1040
        $templates = SSViewer::get_templates_by_class(static::class, "_{$action}", __CLASS__);
1041
1042
        return array_merge(
1043
            $templates,
1044
            [
1045
                "Security_{$action}",
1046
                "Security",
1047
                $this->config()->get("template_main"),
1048
                "BlankPage"
1049
            ]
1050
        );
1051
    }
1052
1053
    /**
1054
     * Return an existing member with administrator privileges, or create one of necessary.
1055
     *
1056
     * Will create a default 'Administrators' group if no group is found
1057
     * with an ADMIN permission. Will create a new 'Admin' member with administrative permissions
1058
     * if no existing Member with these permissions is found.
1059
     *
1060
     * Important: Any newly created administrator accounts will NOT have valid
1061
     * login credentials (Email/Password properties), which means they can't be used for login
1062
     * purposes outside of any default credentials set through {@link Security::setDefaultAdmin()}.
1063
     *
1064
     * @return Member
1065
     *
1066
     * @deprecated 4.0.0:5.0.0 Please use DefaultAdminService::findOrCreateDefaultAdmin()
1067
     */
1068
    public static function findAnAdministrator()
1069
    {
1070
        Deprecation::notice('5.0.0', 'Please use DefaultAdminService::findOrCreateDefaultAdmin()');
1071
1072
        $service = DefaultAdminService::singleton();
1073
        return $service->findOrCreateDefaultAdmin();
1074
    }
1075
1076
    /**
1077
     * Flush the default admin credentials
1078
     *
1079
     * @deprecated 4.0.0:5.0.0 Please use DefaultAdminService::clearDefaultAdmin()
1080
     */
1081
    public static function clear_default_admin()
1082
    {
1083
        Deprecation::notice('5.0.0', 'Please use DefaultAdminService::clearDefaultAdmin()');
1084
1085
        DefaultAdminService::clearDefaultAdmin();
1086
    }
1087
1088
    /**
1089
     * Set a default admin in dev-mode
1090
     *
1091
     * This will set a static default-admin which is not existing
1092
     * as a database-record. By this workaround we can test pages in dev-mode
1093
     * with a unified login. Submitted login-credentials are first checked
1094
     * against this static information in {@link Security::authenticate()}.
1095
     *
1096
     * @param string $username The user name
1097
     * @param string $password The password (in cleartext)
1098
     * @return bool True if successfully set
1099
     *
1100
     * @deprecated 4.0.0:5.0.0 Please use DefaultAdminService::setDefaultAdmin($username, $password)
1101
     */
1102
    public static function setDefaultAdmin($username, $password)
1103
    {
1104
        Deprecation::notice('5.0.0', 'Please use DefaultAdminService::setDefaultAdmin($username, $password)');
1105
1106
        DefaultAdminService::setDefaultAdmin($username, $password);
1107
        return true;
1108
    }
1109
1110
    /**
1111
     * Checks if the passed credentials are matching the default-admin.
1112
     * Compares cleartext-password set through Security::setDefaultAdmin().
1113
     *
1114
     * @param string $username
1115
     * @param string $password
1116
     * @return bool
1117
     *
1118
     * @deprecated 4.0.0:5.0.0 Use DefaultAdminService::isDefaultAdminCredentials() instead
1119
     */
1120
    public static function check_default_admin($username, $password)
1121
    {
1122
        Deprecation::notice('5.0.0', 'Please use DefaultAdminService::isDefaultAdminCredentials($username, $password)');
1123
1124
        /** @var DefaultAdminService $service */
1125
        return DefaultAdminService::isDefaultAdminCredentials($username, $password);
1126
    }
1127
1128
    /**
1129
     * Check that the default admin account has been set.
1130
     *
1131
     * @deprecated 4.0.0:5.0.0 Use DefaultAdminService::hasDefaultAdmin() instead
1132
     */
1133
    public static function has_default_admin()
1134
    {
1135
        Deprecation::notice('5.0.0', 'Please use DefaultAdminService::hasDefaultAdmin()');
1136
1137
        return DefaultAdminService::hasDefaultAdmin();
1138
    }
1139
1140
    /**
1141
     * Get default admin username
1142
     *
1143
     * @deprecated 4.0.0:5.0.0 Use DefaultAdminService::getDefaultAdminUsername()
1144
     * @return string
1145
     */
1146
    public static function default_admin_username()
1147
    {
1148
        Deprecation::notice('5.0.0', 'Please use DefaultAdminService::getDefaultAdminUsername()');
1149
1150
        return DefaultAdminService::getDefaultAdminUsername();
1151
    }
1152
1153
    /**
1154
     * Get default admin password
1155
     *
1156
     * @deprecated 4.0.0:5.0.0 Use DefaultAdminService::getDefaultAdminPassword()
1157
     * @return string
1158
     */
1159
    public static function default_admin_password()
1160
    {
1161
        Deprecation::notice('5.0.0', 'Please use DefaultAdminService::getDefaultAdminPassword()');
1162
1163
        return DefaultAdminService::getDefaultAdminPassword();
1164
    }
1165
1166
    /**
1167
     * Encrypt a password according to the current password encryption settings.
1168
     * If the settings are so that passwords shouldn't be encrypted, the
1169
     * result is simple the clear text password with an empty salt except when
1170
     * a custom algorithm ($algorithm parameter) was passed.
1171
     *
1172
     * @param string $password The password to encrypt
1173
     * @param string $salt Optional: The salt to use. If it is not passed, but
1174
     *  needed, the method will automatically create a
1175
     *  random salt that will then be returned as return value.
1176
     * @param string $algorithm Optional: Use another algorithm to encrypt the
1177
     *  password (so that the encryption algorithm can be changed over the time).
1178
     * @param Member $member Optional
1179
     * @return mixed Returns an associative array containing the encrypted
1180
     *  password and the used salt in the form:
1181
     * <code>
1182
     *  array(
1183
     *  'password' => string,
1184
     *  'salt' => string,
1185
     *  'algorithm' => string,
1186
     *  'encryptor' => PasswordEncryptor instance
1187
     *  )
1188
     * </code>
1189
     * If the passed algorithm is invalid, FALSE will be returned.
1190
     *
1191
     * @throws PasswordEncryptor_NotFoundException
1192
     * @see encrypt_passwords()
1193
     */
1194
    public static function encrypt_password($password, $salt = null, $algorithm = null, $member = null)
1195
    {
1196
        // Fall back to the default encryption algorithm
1197
        if (!$algorithm) {
1198
            $algorithm = self::config()->get('password_encryption_algorithm');
1199
        }
1200
1201
        $encryptor = PasswordEncryptor::create_for_algorithm($algorithm);
1202
1203
        // New salts will only need to be generated if the password is hashed for the first time
1204
        $salt = ($salt) ? $salt : $encryptor->salt($password);
1205
1206
        return [
1207
            'password'  => $encryptor->encrypt($password, $salt, $member),
1208
            'salt' => $salt,
1209
            'algorithm' => $algorithm,
1210
            'encryptor' => $encryptor
1211
        ];
1212
    }
1213
1214
    /**
1215
     * Checks the database is in a state to perform security checks.
1216
     * See {@link DatabaseAdmin->init()} for more information.
1217
     *
1218
     * @return bool
1219
     */
1220
    public static function database_is_ready()
1221
    {
1222
        // Used for unit tests
1223
        if (self::$force_database_is_ready !== null) {
0 ignored issues
show
introduced by
The condition self::force_database_is_ready !== null is always true.
Loading history...
1224
            return self::$force_database_is_ready;
1225
        }
1226
1227
        if (self::$database_is_ready) {
1228
            return self::$database_is_ready;
1229
        }
1230
1231
        $requiredClasses = ClassInfo::dataClassesFor(Member::class);
1232
        $requiredClasses[] = Group::class;
1233
        $requiredClasses[] = Permission::class;
1234
        $schema = DataObject::getSchema();
1235
        foreach ($requiredClasses as $class) {
1236
            // Skip test classes, as not all test classes are scaffolded at once
1237
            if (is_a($class, TestOnly::class, true)) {
1238
                continue;
1239
            }
1240
1241
            // if any of the tables aren't created in the database
1242
            $table = $schema->tableName($class);
1243
            if (!ClassInfo::hasTable($table)) {
1244
                return false;
1245
            }
1246
1247
            // HACK: DataExtensions aren't applied until a class is instantiated for
1248
            // the first time, so create an instance here.
1249
            singleton($class);
1250
1251
            // if any of the tables don't have all fields mapped as table columns
1252
            $dbFields = DB::field_list($table);
1253
            if (!$dbFields) {
1254
                return false;
1255
            }
1256
1257
            $objFields = $schema->databaseFields($class, false);
1258
            $missingFields = array_diff_key($objFields, $dbFields);
1259
1260
            if ($missingFields) {
1261
                return false;
1262
            }
1263
        }
1264
        self::$database_is_ready = true;
1265
1266
        return true;
1267
    }
1268
1269
    /**
1270
     * Resets the database_is_ready cache
1271
     */
1272
    public static function clear_database_is_ready()
1273
    {
1274
        self::$database_is_ready = null;
1275
        self::$force_database_is_ready = null;
1276
    }
1277
1278
    /**
1279
     * For the database_is_ready call to return a certain value - used for testing
1280
     *
1281
     * @param bool $isReady
1282
     */
1283
    public static function force_database_is_ready($isReady)
1284
    {
1285
        self::$force_database_is_ready = $isReady;
1286
    }
1287
1288
    /**
1289
     * @config
1290
     * @var string Set the default login dest
1291
     * This is the URL that users will be redirected to after they log in,
1292
     * if they haven't logged in en route to access a secured page.
1293
     * By default, this is set to the homepage.
1294
     */
1295
    private static $default_login_dest = "";
0 ignored issues
show
introduced by
The private property $default_login_dest is not used, and could be removed.
Loading history...
1296
1297
    /**
1298
     * @config
1299
     * @var string Set the default reset password destination
1300
     * This is the URL that users will be redirected to after they change their password,
1301
     * By default, it's redirecting to {@link $login}.
1302
     */
1303
    private static $default_reset_password_dest;
0 ignored issues
show
introduced by
The private property $default_reset_password_dest is not used, and could be removed.
Loading history...
1304
1305
    protected static $ignore_disallowed_actions = false;
1306
1307
    /**
1308
     * Set to true to ignore access to disallowed actions, rather than returning permission failure
1309
     * Note that this is just a flag that other code needs to check with Security::ignore_disallowed_actions()
1310
     * @param bool $flag True or false
1311
     */
1312
    public static function set_ignore_disallowed_actions($flag)
1313
    {
1314
        self::$ignore_disallowed_actions = $flag;
1315
    }
1316
1317
    public static function ignore_disallowed_actions()
1318
    {
1319
        return self::$ignore_disallowed_actions;
1320
    }
1321
1322
    /**
1323
     * Get the URL of the log-in page.
1324
     *
1325
     * To update the login url use the "Security.login_url" config setting.
1326
     *
1327
     * @return string
1328
     */
1329
    public static function login_url()
1330
    {
1331
        return Controller::join_links(Director::baseURL(), self::config()->get('login_url'));
1332
    }
1333
1334
1335
    /**
1336
     * Get the URL of the logout page.
1337
     *
1338
     * To update the logout url use the "Security.logout_url" config setting.
1339
     *
1340
     * @return string
1341
     */
1342
    public static function logout_url()
1343
    {
1344
        $logoutUrl = Controller::join_links(Director::baseURL(), self::config()->get('logout_url'));
1345
        return SecurityToken::inst()->addToUrl($logoutUrl);
1346
    }
1347
1348
    /**
1349
     * Get the URL of the logout page.
1350
     *
1351
     * To update the logout url use the "Security.logout_url" config setting.
1352
     *
1353
     * @return string
1354
     */
1355
    public static function lost_password_url()
1356
    {
1357
        return Controller::join_links(Director::baseURL(), self::config()->get('lost_password_url'));
1358
    }
1359
1360
    /**
1361
     * Defines global accessible templates variables.
1362
     *
1363
     * @return array
1364
     */
1365
    public static function get_template_global_variables()
1366
    {
1367
        return [
1368
            "LoginURL" => "login_url",
1369
            "LogoutURL" => "logout_url",
1370
            "LostPasswordURL" => "lost_password_url",
1371
            "CurrentMember"   => "getCurrentUser",
1372
            "currentUser"     => "getCurrentUser"
1373
        ];
1374
    }
1375
}
1376