Passed
Pull Request — 4 (#10041)
by Guy
07:40
created

Security::database_is_ready()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 8
nc 3
nop 0
dl 0
loc 14
rs 10
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\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 = [
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 LoginAttempt} 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
     * @deprecated 5.0 use {@link DB::$databaseReadyClasses} instead
178
     *
179
     * @var boolean If set to TRUE or FALSE, {@link database_is_ready()}
180
     * will always return FALSE. Used for unit testing.
181
     */
182
    protected static $force_database_is_ready;
183
184
    /**
185
     * @deprecated 5.0 use {@link DB::$databaseReadyClasses} instead
186
     *
187
     * When the database has once been verified as ready, it will not do the
188
     * checks again.
189
     *
190
     * @var bool
191
     */
192
    protected static $database_is_ready = false;
193
194
    /**
195
     * @var Authenticator[] available authenticators
196
     */
197
    private $authenticators = [];
198
199
    /**
200
     * @var Member Currently logged in user (if available)
201
     */
202
    protected static $currentUser;
203
204
    /**
205
     * @return Authenticator[]
206
     */
207
    public function getAuthenticators()
208
    {
209
        return array_filter($this->authenticators);
210
    }
211
212
    /**
213
     * @param Authenticator[] $authenticators
214
     */
215
    public function setAuthenticators(array $authenticators)
216
    {
217
        $this->authenticators = $authenticators;
218
    }
219
220
    protected function init()
221
    {
222
        parent::init();
223
224
        // Prevent clickjacking, see https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options
225
        $frameOptions = static::config()->get('frame_options');
226
        if ($frameOptions) {
227
            $this->getResponse()->addHeader('X-Frame-Options', $frameOptions);
228
        }
229
230
        // Prevent search engines from indexing the login page
231
        $robotsTag = static::config()->get('robots_tag');
232
        if ($robotsTag) {
233
            $this->getResponse()->addHeader('X-Robots-Tag', $robotsTag);
234
        }
235
    }
236
237
    public function index()
238
    {
239
        $this->httpError(404); // no-op
240
    }
241
242
    /**
243
     * Get the selected authenticator for this request
244
     *
245
     * @param string $name The identifier of the authenticator in your config
246
     * @return Authenticator Class name of Authenticator
247
     * @throws LogicException
248
     */
249
    protected function getAuthenticator($name = 'default')
250
    {
251
        $authenticators = $this->getAuthenticators();
252
253
        if (isset($authenticators[$name])) {
254
            return $authenticators[$name];
255
        }
256
257
        throw new LogicException('No valid authenticator found');
258
    }
259
260
    /**
261
     * Get all registered authenticators
262
     *
263
     * @param int $service The type of service that is requested
264
     * @return Authenticator[] Return an array of Authenticator objects
265
     */
266
    public function getApplicableAuthenticators($service = Authenticator::LOGIN)
267
    {
268
        $authenticators = $this->getAuthenticators();
269
270
        /** @var Authenticator $authenticator */
271
        foreach ($authenticators as $name => $authenticator) {
272
            if (!($authenticator->supportedServices() & $service)) {
273
                unset($authenticators[$name]);
274
            }
275
        }
276
277
        if (empty($authenticators)) {
278
            throw new LogicException('No applicable authenticators found');
279
        }
280
281
        return $authenticators;
282
    }
283
284
    /**
285
     * Check if a given authenticator is registered
286
     *
287
     * @param string $authenticator The configured identifier of the authenticator
288
     * @return bool Returns TRUE if the authenticator is registered, FALSE
289
     *              otherwise.
290
     */
291
    public function hasAuthenticator($authenticator)
292
    {
293
        $authenticators = $this->getAuthenticators();
294
295
        return !empty($authenticators[$authenticator]);
296
    }
297
298
    /**
299
     * Register that we've had a permission failure trying to view the given page
300
     *
301
     * This will redirect to a login page.
302
     * If you don't provide a messageSet, a default will be used.
303
     *
304
     * @param Controller $controller The controller that you were on to cause the permission
305
     *                               failure.
306
     * @param string|array $messageSet The message to show to the user. This
307
     *                                 can be a string, or a map of different
308
     *                                 messages for different contexts.
309
     *                                 If you pass an array, you can use the
310
     *                                 following keys:
311
     *                                   - default: The default message
312
     *                                   - alreadyLoggedIn: The message to
313
     *                                                      show if the user
314
     *                                                      is already logged
315
     *                                                      in and lacks the
316
     *                                                      permission to
317
     *                                                      access the item.
318
     *
319
     * The alreadyLoggedIn value can contain a '%s' placeholder that will be replaced with a link
320
     * to log in.
321
     * @return HTTPResponse
322
     */
323
    public static function permissionFailure($controller = null, $messageSet = null)
324
    {
325
        self::set_ignore_disallowed_actions(true);
326
327
        // Parse raw message / escape type
328
        $parseMessage = function ($message) {
329
            if ($message instanceof DBField) {
330
                return [
331
                    $message->getValue(),
332
                    $message->config()->get('escape_type') === 'raw'
333
                        ? ValidationResult::CAST_TEXT
334
                        : ValidationResult::CAST_HTML,
335
                ];
336
            }
337
338
            // Default to escaped value
339
            return [
340
                $message,
341
                ValidationResult::CAST_TEXT,
342
            ];
343
        };
344
345
        if (!$controller && Controller::has_curr()) {
346
            $controller = Controller::curr();
347
        }
348
349
        if (Director::is_ajax()) {
350
            $response = ($controller) ? $controller->getResponse() : new HTTPResponse();
351
            $response->setStatusCode(403);
352
            if (!static::getCurrentUser()) {
353
                $response->setBody(
354
                    _t('SilverStripe\\CMS\\Controllers\\ContentController.NOTLOGGEDIN', 'Not logged in')
355
                );
356
                $response->setStatusDescription(
357
                    _t('SilverStripe\\CMS\\Controllers\\ContentController.NOTLOGGEDIN', 'Not logged in')
358
                );
359
                // Tell the CMS to allow re-authentication
360
                if (CMSSecurity::singleton()->enabled()) {
361
                    $response->addHeader('X-Reauthenticate', '1');
362
                }
363
            }
364
365
            return $response;
366
        }
367
368
        // Prepare the messageSet provided
369
        if (!$messageSet) {
370
            if ($configMessageSet = static::config()->get('default_message_set')) {
371
                $messageSet = $configMessageSet;
372
            } else {
373
                $messageSet = [
374
                    'default' => _t(
375
                        __CLASS__ . '.NOTEPAGESECURED',
376
                        "That page is secured. Enter your credentials below and we will send "
377
                            . "you right along."
378
                    ),
379
                    'alreadyLoggedIn' => _t(
380
                        __CLASS__ . '.ALREADYLOGGEDIN',
381
                        "You don't have access to this page.  If you have another account that "
382
                            . "can access that page, you can log in again below."
383
                    )
384
                ];
385
            }
386
        }
387
388
        if (!is_array($messageSet)) {
389
            $messageSet = ['default' => $messageSet];
390
        }
391
392
        $member = static::getCurrentUser();
393
394
        // Work out the right message to show
395
        if ($member && $member->exists()) {
396
            $response = ($controller) ? $controller->getResponse() : new HTTPResponse();
397
            $response->setStatusCode(403);
398
399
            //If 'alreadyLoggedIn' is not specified in the array, then use the default
400
            //which should have been specified in the lines above
401
            if (isset($messageSet['alreadyLoggedIn'])) {
402
                $message = $messageSet['alreadyLoggedIn'];
403
            } else {
404
                $message = $messageSet['default'];
405
            }
406
407
            list($messageText, $messageCast) = $parseMessage($message);
408
            static::singleton()->setSessionMessage($messageText, ValidationResult::TYPE_WARNING, $messageCast);
409
            $request = new HTTPRequest('GET', '/');
410
            if ($controller) {
411
                $request->setSession($controller->getRequest()->getSession());
412
            }
413
            $loginResponse = static::singleton()->login($request);
414
            if ($loginResponse instanceof HTTPResponse) {
415
                return $loginResponse;
416
            }
417
418
            $response->setBody((string)$loginResponse);
419
420
            $controller->extend('permissionDenied', $member);
421
422
            return $response;
423
        }
424
        $message = $messageSet['default'];
425
426
        $request = $controller->getRequest();
427
        if ($request->hasSession()) {
428
            list($messageText, $messageCast) = $parseMessage($message);
429
            static::singleton()->setSessionMessage($messageText, ValidationResult::TYPE_WARNING, $messageCast);
430
431
            $request->getSession()->set("BackURL", $_SERVER['REQUEST_URI']);
432
        }
433
434
        // TODO AccessLogEntry needs an extension to handle permission denied errors
435
        // Audit logging hook
436
        $controller->extend('permissionDenied', $member);
437
438
        return $controller->redirect(Controller::join_links(
439
            Security::config()->uninherited('login_url'),
440
            "?BackURL=" . urlencode($_SERVER['REQUEST_URI'])
441
        ));
442
    }
443
444
    /**
445
     * The intended uses of this function is to temporarily change the current user for things such as
446
     * canView() checks or unit tests.  It is stateless and will not persist between requests.  Importantly
447
     * it also will not call any logic that may be present in the current IdentityStore logIn() or logout() methods
448
     *
449
     * If you are unit testing and calling FunctionalTest::get() or FunctionalTest::post() and you need to change
450
     * the current user, you should instead use SapphireTest::logInAs() / logOut() which itself will call
451
     * Injector::inst()->get(IdentityStore::class)->logIn($member) / logout()
452
     *
453
     * @param null|Member $currentUser
454
     */
455
    public static function setCurrentUser($currentUser = null)
456
    {
457
        self::$currentUser = $currentUser;
458
    }
459
460
    /**
461
     * @return null|Member
462
     */
463
    public static function getCurrentUser()
464
    {
465
        return self::$currentUser;
466
    }
467
468
    /**
469
     * Get the login forms for all available authentication methods
470
     *
471
     * @deprecated 5.0.0 Now handled by {@link static::delegateToMultipleHandlers}
472
     *
473
     * @return array Returns an array of available login forms (array of Form
474
     *               objects).
475
     *
476
     */
477
    public function getLoginForms()
478
    {
479
        Deprecation::notice('5.0.0', 'Now handled by delegateToMultipleHandlers');
480
481
        return array_map(
482
            function (Authenticator $authenticator) {
483
                return [
484
                    $authenticator->getLoginHandler($this->Link())->loginForm()
485
                ];
486
            },
487
            $this->getApplicableAuthenticators()
488
        );
489
    }
490
491
492
    /**
493
     * Get a link to a security action
494
     *
495
     * @param string $action Name of the action
496
     * @return string Returns the link to the given action
497
     */
498
    public function Link($action = null)
499
    {
500
        /** @skipUpgrade */
501
        $link = Controller::join_links(Director::baseURL(), "Security", $action);
502
        $this->extend('updateLink', $link, $action);
503
        return $link;
504
    }
505
506
    /**
507
     * This action is available as a keep alive, so user
508
     * sessions don't timeout. A common use is in the admin.
509
     */
510
    public function ping()
511
    {
512
        HTTPCacheControlMiddleware::singleton()->disableCache();
513
        Requirements::clear();
514
        return 1;
515
    }
516
517
    /**
518
     * Perform pre-login checking and prepare a response if available prior to login
519
     *
520
     * @return HTTPResponse Substitute response object if the login process should be circumvented.
521
     * Returns null if should proceed as normal.
522
     */
523
    protected function preLogin()
524
    {
525
        // Event handler for pre-login, with an option to let it break you out of the login form
526
        $eventResults = $this->extend('onBeforeSecurityLogin');
527
        // If there was a redirection, return
528
        if ($this->redirectedTo()) {
529
            return $this->getResponse();
530
        }
531
        // If there was an HTTPResponse object returned, then return that
532
        if ($eventResults) {
533
            foreach ($eventResults as $result) {
534
                if ($result instanceof HTTPResponse) {
535
                    return $result;
536
                }
537
            }
538
        }
539
540
        // If arriving on the login page already logged in, with no security error, and a ReturnURL then redirect
541
        // back. The login message check is necessary to prevent infinite loops where BackURL links to
542
        // an action that triggers Security::permissionFailure.
543
        // This step is necessary in cases such as automatic redirection where a user is authenticated
544
        // upon landing on an SSL secured site and is automatically logged in, or some other case
545
        // where the user has permissions to continue but is not given the option.
546
        if (!$this->getSessionMessage()
547
            && ($member = static::getCurrentUser())
548
            && $member->exists()
549
            && $this->getRequest()->requestVar('BackURL')
550
        ) {
551
            return $this->redirectBack();
552
        }
553
554
        return null;
555
    }
556
557
    public function getRequest()
558
    {
559
        // Support Security::singleton() where a request isn't always injected
560
        $request = parent::getRequest();
561
        if ($request) {
0 ignored issues
show
introduced by
$request is of type SilverStripe\Control\HTTPRequest, thus it always evaluated to true.
Loading history...
562
            return $request;
563
        }
564
565
        if (Controller::has_curr() && Controller::curr() !== $this) {
566
            return Controller::curr()->getRequest();
567
        }
568
569
        return null;
570
    }
571
572
    /**
573
     * Prepare the controller for handling the response to this request
574
     *
575
     * @param string $title Title to use
576
     * @return Controller
577
     */
578
    protected function getResponseController($title)
579
    {
580
        // Use the default setting for which Page to use to render the security page
581
        $pageClass = $this->config()->get('page_class');
582
        if (!$pageClass || !class_exists($pageClass)) {
583
            return $this;
584
        }
585
586
        // Create new instance of page holder
587
        /** @var Page $holderPage */
588
        $holderPage = Injector::inst()->create($pageClass);
589
        $holderPage->Title = $title;
590
        /** @skipUpgrade */
591
        $holderPage->URLSegment = 'Security';
592
        // Disable ID-based caching  of the log-in page by making it a random number
593
        $holderPage->ID = -1 * random_int(1, 10000000);
594
595
        $controller = ModelAsController::controller_for($holderPage);
596
        $controller->setRequest($this->getRequest());
597
        $controller->doInit();
598
599
        return $controller;
600
    }
601
602
    /**
603
     * Combine the given forms into a formset with a tabbed interface
604
     *
605
     * @param array|Form[] $forms
606
     * @return string
607
     */
608
    protected function generateTabbedFormSet($forms)
609
    {
610
        if (count($forms) === 1) {
611
            return $forms;
612
        }
613
614
        $viewData = new ArrayData([
615
            'Forms' => new ArrayList($forms),
616
        ]);
617
618
        return $viewData->renderWith(
619
            $this->getTemplatesFor('MultiAuthenticatorTabbedForms')
620
        );
621
    }
622
623
    /**
624
     * Get the HTML Content for the $Content area during login
625
     *
626
     * @param string $messageType Type of message, if available, passed back to caller (by reference)
627
     * @return string Message in HTML format
628
     */
629
    protected function getSessionMessage(&$messageType = null)
630
    {
631
        $session = $this->getRequest()->getSession();
632
        $message = $session->get('Security.Message.message');
633
        $messageType = null;
634
        if (empty($message)) {
635
            return null;
636
        }
637
638
        $messageType = $session->get('Security.Message.type');
639
        $messageCast = $session->get('Security.Message.cast');
640
        if ($messageCast !== ValidationResult::CAST_HTML) {
641
            $message = Convert::raw2xml($message);
642
        }
643
644
        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

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

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