Passed
Pull Request — 4 (#10041)
by Guy
13:30
created

Security::getPasswordResetLink()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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