Passed
Pull Request — 4 (#10276)
by Guy
06:03
created

Security::database_is_ready()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 25
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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