Completed
Push — authenticator-refactor ( bb04bd...4b728f )
by Simon
18:29
created

Security::getAuthenticators()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 0
dl 0
loc 4
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Security;
4
5
use Page;
6
use LogicException;
7
use SilverStripe\CMS\Controllers\ContentController;
8
use SilverStripe\Control\Controller;
9
use SilverStripe\Control\Director;
10
use SilverStripe\Control\HTTPRequest;
11
use SilverStripe\Control\HTTPResponse;
12
use SilverStripe\Control\HTTPResponse_Exception;
13
use SilverStripe\Control\Session;
14
use SilverStripe\Control\RequestHandler;
15
use SilverStripe\Core\ClassInfo;
16
use SilverStripe\Core\Convert;
17
use SilverStripe\Dev\Deprecation;
18
use SilverStripe\Dev\TestOnly;
19
use SilverStripe\ORM\ArrayList;
20
use SilverStripe\ORM\DataModel;
21
use SilverStripe\ORM\DB;
22
use SilverStripe\ORM\DataObject;
23
use SilverStripe\ORM\FieldType\DBField;
24
use SilverStripe\ORM\FieldType\DBHTMLText;
25
use SilverStripe\ORM\ValidationResult;
26
use SilverStripe\View\ArrayData;
27
use SilverStripe\View\SSViewer;
28
use SilverStripe\View\TemplateGlobalProvider;
29
use Subsite;
30
31
/**
32
 * Implements a basic security model
33
 */
34
class Security extends Controller implements TemplateGlobalProvider
35
{
36
37
    private static $allowed_actions = array(
0 ignored issues
show
Comprehensibility introduced by
Consider using a different property name as you override a private property of the parent class.
Loading history...
38
        'index',
39
        'login',
40
        'logout',
41
        'basicauthlogin',
42
        'lostpassword',
43
        'passwordsent',
44
        'changepassword',
45
        'ping',
46
    );
47
48
    /**
49
     * Default user name. {@link setDefaultAdmin()}
50
     *
51
     * @var string
52
     * @see setDefaultAdmin()
53
     */
54
    protected static $default_username;
55
56
    /**
57
     * Default password. {@link setDefaultAdmin()}
58
     *
59
     * @var string
60
     * @see setDefaultAdmin()
61
     */
62
    protected static $default_password;
63
64
    /**
65
     * If set to TRUE to prevent sharing of the session across several sites
66
     * in the domain.
67
     *
68
     * @config
69
     * @var bool
70
     */
71
    private static $strict_path_checking = false;
72
73
    /**
74
     * The password encryption algorithm to use by default.
75
     * This is an arbitrary code registered through {@link PasswordEncryptor}.
76
     *
77
     * @config
78
     * @var string
79
     */
80
    private static $password_encryption_algorithm = 'blowfish';
81
82
    /**
83
     * Showing "Remember me"-checkbox
84
     * on loginform, and saving encrypted credentials to a cookie.
85
     *
86
     * @config
87
     * @var bool
88
     */
89
    private static $autologin_enabled = true;
90
91
    /**
92
     * Determine if login username may be remembered between login sessions
93
     * If set to false this will disable autocomplete and prevent username persisting in the session
94
     *
95
     * @config
96
     * @var bool
97
     */
98
    private static $remember_username = true;
99
100
    /**
101
     * Location of word list to use for generating passwords
102
     *
103
     * @config
104
     * @var string
105
     */
106
    private static $word_list = './wordlist.txt';
107
108
    /**
109
     * @config
110
     * @var string
111
     */
112
    private static $template = 'BlankPage';
113
114
    /**
115
     * Template thats used to render the pages.
116
     *
117
     * @var string
118
     * @config
119
     */
120
    private static $template_main = 'Page';
121
122
    /**
123
     * Class to use for page rendering
124
     *
125
     * @var string
126
     * @config
127
     */
128
    private static $page_class = Page::class;
129
130
    /**
131
     * Default message set used in permission failures.
132
     *
133
     * @config
134
     * @var array|string
135
     */
136
    private static $default_message_set;
137
138
    /**
139
     * Random secure token, can be used as a crypto key internally.
140
     * Generate one through 'sake dev/generatesecuretoken'.
141
     *
142
     * @config
143
     * @var String
144
     */
145
    private static $token;
146
147
    /**
148
     * The default login URL
149
     *
150
     * @config
151
     *
152
     * @var string
153
     */
154
    private static $login_url = "Security/login";
155
156
    /**
157
     * The default logout URL
158
     *
159
     * @config
160
     *
161
     * @var string
162
     */
163
    private static $logout_url = "Security/logout";
164
165
    /**
166
     * The default lost password URL
167
     *
168
     * @config
169
     *
170
     * @var string
171
     */
172
    private static $lost_password_url = "Security/lostpassword";
173
174
    /**
175
     * Value of X-Frame-Options header
176
     *
177
     * @config
178
     * @var string
179
     */
180
    private static $frame_options = 'SAMEORIGIN';
181
182
    /**
183
     * Value of the X-Robots-Tag header (for the Security section)
184
     *
185
     * @config
186
     * @var string
187
     */
188
    private static $robots_tag = 'noindex, nofollow';
189
190
    /**
191
     * Enable or disable recording of login attempts
192
     * through the {@link LoginRecord} object.
193
     *
194
     * @config
195
     * @var boolean $login_recording
196
     */
197
    private static $login_recording = false;
198
199
    /**
200
     * @var boolean If set to TRUE or FALSE, {@link database_is_ready()}
201
     * will always return FALSE. Used for unit testing.
202
     */
203
    protected static $force_database_is_ready = null;
204
205
    /**
206
     * When the database has once been verified as ready, it will not do the
207
     * checks again.
208
     *
209
     * @var bool
210
     */
211
    protected static $database_is_ready = false;
212
213
    /**
214
     * @var string Default authenticator to use when none is given
215
     */
216
    protected static $default_authenticator = 'default';
217
218
    /**
219
     * @var Authenticator[] available authenticators
220
     */
221
    protected static $authenticators = [];
222
223
    /**
224
     * @var Member Currently logged in user (if available)
225
     */
226
    protected static $currentUser;
227
228
    /**
229
     * @return array
230
     */
231
    public static function getAuthenticators()
232
    {
233
        return self::$authenticators;
234
    }
235
236
    /**
237
     * @param array|Authenticator $authenticators
238
     */
239
    public static function setAuthenticators(array $authenticators)
240
    {
241
        self::$authenticators = $authenticators;
242
    }
243
244
    /**
245
     * @inheritdoc
246
     */
247
    protected function init()
248
    {
249
        parent::init();
250
251
        // Prevent clickjacking, see https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options
252
        $frameOptions = static::config()->get('frame_options');
253
        if ($frameOptions) {
254
            $this->getResponse()->addHeader('X-Frame-Options', $frameOptions);
255
        }
256
257
        // Prevent search engines from indexing the login page
258
        $robotsTag = static::config()->get('robots_tag');
259
        if ($robotsTag) {
260
            $this->getResponse()->addHeader('X-Robots-Tag', $robotsTag);
261
        }
262
    }
263
264
    /**
265
     * @inheritdoc
266
     */
267
    public function index()
268
    {
269
        return $this->httpError(404); // no-op
270
    }
271
272
    /**
273
     * Get the selected authenticator for this request
274
     *
275
     * @param $name string The identifier of the authenticator in your config
276
     * @return Authenticator Class name of Authenticator
277
     * @throws LogicException
278
     */
279
    protected function getAuthenticator($name = 'default')
280
    {
281
        $authenticators = static::$authenticators;
282
283
        if (isset($authenticators[$name])) {
284
            return $authenticators[$name];
285
        }
286
287
        throw new LogicException('No valid authenticator found');
288
    }
289
290
    /**
291
     * Get all registered authenticators
292
     *
293
     * @param int $service The type of service that is requested
294
     * @return array Return an array of Authenticator objects
295
     */
296
    public function getApplicableAuthenticators($service = Authenticator::LOGIN)
297
    {
298
        $authenticators = static::$authenticators;
299
300
        /** @var Authenticator $class */
301
        foreach ($authenticators as $name => $class) {
302
            if (!($class->supportedServices() & $service)) {
303
                unset($authenticators[$name]);
304
            }
305
        }
306
307
        return $authenticators;
308
    }
309
310
    /**
311
     * Check if a given authenticator is registered
312
     *
313
     * @param string $authenticator The configured identifier of the authenicator
314
     * @return bool Returns TRUE if the authenticator is registered, FALSE
315
     *              otherwise.
316
     */
317
    public function hasAuthenticator($authenticator)
318
    {
319
        $authenticators = static::$authenticators;
320
321
        return !empty($authenticators[$authenticator]);
322
    }
323
324
    /**
325
     * Register that we've had a permission failure trying to view the given page
326
     *
327
     * This will redirect to a login page.
328
     * If you don't provide a messageSet, a default will be used.
329
     *
330
     * @param Controller $controller The controller that you were on to cause the permission
331
     *                               failure.
332
     * @param string|array $messageSet The message to show to the user. This
333
     *                                 can be a string, or a map of different
334
     *                                 messages for different contexts.
335
     *                                 If you pass an array, you can use the
336
     *                                 following keys:
337
     *                                   - default: The default message
338
     *                                   - alreadyLoggedIn: The message to
339
     *                                                      show if the user
340
     *                                                      is already logged
341
     *                                                      in and lacks the
342
     *                                                      permission to
343
     *                                                      access the item.
344
     *
345
     * The alreadyLoggedIn value can contain a '%s' placeholder that will be replaced with a link
346
     * to log in.
347
     * @return HTTPResponse
348
     */
349
    public static function permissionFailure($controller = null, $messageSet = null)
350
    {
351
        self::set_ignore_disallowed_actions(true);
352
353
        if (!$controller) {
354
            $controller = Controller::curr();
355
        }
356
357
        if (Director::is_ajax()) {
358
            $response = ($controller) ? $controller->getResponse() : new HTTPResponse();
359
            $response->setStatusCode(403);
360
            if (!static::getCurrentUser()) {
361
                $response->setBody(
362
                    _t('SilverStripe\\CMS\\Controllers\\ContentController.NOTLOGGEDIN', 'Not logged in')
363
                );
364
                $response->setStatusDescription(
365
                    _t('SilverStripe\\CMS\\Controllers\\ContentController.NOTLOGGEDIN', 'Not logged in')
366
                );
367
                // Tell the CMS to allow re-authentication
368
                if (CMSSecurity::enabled()) {
369
                    $response->addHeader('X-Reauthenticate', '1');
370
                }
371
            }
372
373
            return $response;
374
        }
375
376
        // Prepare the messageSet provided
377
        if (!$messageSet) {
378
            if ($configMessageSet = static::config()->get('default_message_set')) {
379
                $messageSet = $configMessageSet;
380
            } else {
381
                $messageSet = array(
382
                    'default'         => _t(
383
                        'SilverStripe\\Security\\Security.NOTEPAGESECURED',
384
                        "That page is secured. Enter your credentials below and we will send "
385
                        . "you right along."
386
                    ),
387
                    'alreadyLoggedIn' => _t(
388
                        'SilverStripe\\Security\\Security.ALREADYLOGGEDIN',
389
                        "You don't have access to this page.  If you have another account that "
390
                        . "can access that page, you can log in again below.",
391
                        "%s will be replaced with a link to log in."
392
                    )
393
                );
394
            }
395
        }
396
397
        if (!is_array($messageSet)) {
398
            $messageSet = array('default' => $messageSet);
399
        }
400
401
        $member = static::getCurrentUser();
402
403
        // Work out the right message to show
404
        if ($member && $member->exists()) {
405
            $response = ($controller) ? $controller->getResponse() : new HTTPResponse();
406
            $response->setStatusCode(403);
407
408
            //If 'alreadyLoggedIn' is not specified in the array, then use the default
409
            //which should have been specified in the lines above
410
            if (isset($messageSet['alreadyLoggedIn'])) {
411
                $message = $messageSet['alreadyLoggedIn'];
412
            } else {
413
                $message = $messageSet['default'];
414
            }
415
416
            static::singleton()->setLoginMessage($message, ValidationResult::TYPE_WARNING);
417
            $loginResponse = static::singleton()->login();
418
            if ($loginResponse instanceof HTTPResponse) {
419
                return $loginResponse;
420
            }
421
422
            $response->setBody((string)$loginResponse);
423
424
            $controller->extend('permissionDenied', $member);
425
426
            return $response;
427
        } else {
428
            $message = $messageSet['default'];
429
        }
430
431
        static::singleton()->setLoginMessage($message, ValidationResult::TYPE_WARNING);
432
433
        Session::set("BackURL", $_SERVER['REQUEST_URI']);
434
435
        // TODO AccessLogEntry needs an extension to handle permission denied errors
436
        // Audit logging hook
437
        $controller->extend('permissionDenied', $member);
438
439
        return $controller->redirect(Controller::join_links(
440
            Security::config()->uninherited('login_url'),
441
            "?BackURL=" . urlencode($_SERVER['REQUEST_URI'])
442
        ));
443
    }
444
445
    /**
446
     * @param null|Member $currentUser
447
     */
448
    public static function setCurrentUser($currentUser = null)
449
    {
450
        self::$currentUser = $currentUser;
451
    }
452
453
    /**
454
     * @return null|Member
455
     */
456
    public static function getCurrentUser()
457
    {
458
        return self::$currentUser;
459
    }
460
461
    /**
462
     * Get the login forms for all available authentication methods
463
     *
464
     * @deprecated 5.0.0 Now handled by {@link static::delegateToMultipleHandlers}
465
     *
466
     * @return array Returns an array of available login forms (array of Form
467
     *               objects).
468
     *
469
     */
470
    public function getLoginForms()
471
    {
472
        Deprecation::notice('5.0.0', 'Now handled by delegateToMultipleHandlers');
473
474
        return array_map(
475
            function ($authenticator) {
476
                return [$authenticator->getLoginHandler($this->Link())->loginForm()];
477
            },
478
            $this->getApplicableAuthenticators()
479
        );
480
    }
481
482
483
    /**
484
     * Get a link to a security action
485
     *
486
     * @param string $action Name of the action
487
     * @return string Returns the link to the given action
488
     */
489
    public function Link($action = null)
490
    {
491
        /** @skipUpgrade */
492
        return Controller::join_links(Director::baseURL(), "Security", $action);
493
    }
494
495
    /**
496
     * This action is available as a keep alive, so user
497
     * sessions don't timeout. A common use is in the admin.
498
     */
499
    public function ping()
500
    {
501
        return 1;
502
    }
503
504
    /**
505
     * Log the currently logged in user out
506
     *
507
     * Logging out without ID-parameter in the URL, will log the user out of all applicable Authenticators.
508
     *
509
     * Adding an ID will only log the user out of that Authentication method.
510
     *
511
     * Logging out of Default will <i>always</i> completely log out the user.
512
     *
513
     * @param bool $redirect Redirect the user back to where they came.
514
     *                       - If it's false, the code calling logout() is
515
     *                         responsible for sending the user where-ever
516
     *                         they should go.
517
     * @return HTTPResponse|null
518
     */
519
    public function logout($redirect = true)
520
    {
521
        $this->extend('beforeMemberLoggedOut');
522
        $member = static::getCurrentUser();
523
        $result = false;
524
525
        if ($member) { // If we don't have a member, there's not much to log out.
526
            /** @var Authenticator[] $authenticator */
527
            $authenticators = $this->getApplicableAuthenticators(Authenticator::LOGOUT);
528
            foreach ($authenticators as $name => $authenticator) {
529
                $handler = $authenticator->getLogOutHandler(Controller::join_links($this->Link(), 'logout'));
530
                $result = $this->delegateToHandler($handler, $name);
531
            }
532
            if ($result !== true) {
533
                $this->extend('failureMemberLoggedOut', $authenticator);
534
535
                return $this->redirectBack();
536
            }
537
            $this->extend('successMemberLoggedOut', $authenticator);
538
            // Member is successfully logged out. Write possible changes to the database.
539
            $member->write();
540
        }
541
        $this->extend('afterMemberLoggedOut');
542
543
        if ($redirect && (!$this->getResponse()->isFinished())) {
544
            return $this->redirectBack();
545
        }
546
547
        return null;
548
    }
549
550
    /**
551
     * Perform pre-login checking and prepare a response if available prior to login
552
     *
553
     * @return HTTPResponse Substitute response object if the login process should be curcumvented.
554
     * Returns null if should proceed as normal.
555
     */
556
    protected function preLogin()
557
    {
558
        // Event handler for pre-login, with an option to let it break you out of the login form
559
        $eventResults = $this->extend('onBeforeSecurityLogin');
560
        // If there was a redirection, return
561
        if ($this->redirectedTo()) {
562
            return $this->getResponse();
563
        }
564
        // If there was an HTTPResponse object returned, then return that
565
        if ($eventResults) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $eventResults of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

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

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

Loading history...
566
            foreach ($eventResults as $result) {
567
                if ($result instanceof HTTPResponse) {
568
                    return $result;
569
                }
570
            }
571
        }
572
573
        // If arriving on the login page already logged in, with no security error, and a ReturnURL then redirect
574
        // back. The login message check is neccesary to prevent infinite loops where BackURL links to
575
        // an action that triggers Security::permissionFailure.
576
        // This step is necessary in cases such as automatic redirection where a user is authenticated
577
        // upon landing on an SSL secured site and is automatically logged in, or some other case
578
        // where the user has permissions to continue but is not given the option.
579
        if ($this->getRequest()->requestVar('BackURL')
580
            && !$this->getLoginMessage()
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->getLoginMessage() of type null|string is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

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

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

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

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
581
            && ($member = static::getCurrentUser())
582
            && $member->exists()
583
        ) {
584
            return $this->redirectBack();
585
        }
586
587
        return null;
588
    }
589
590
    /**
591
     * Prepare the controller for handling the response to this request
592
     *
593
     * @param string $title Title to use
594
     * @return Controller
595
     */
596
    protected function getResponseController($title)
597
    {
598
        // Use the default setting for which Page to use to render the security page
599
        $pageClass = $this->stat('page_class');
600
        if (!$pageClass || !class_exists($pageClass)) {
601
            return $this;
602
        }
603
604
        // Create new instance of page holder
605
        /** @var Page $holderPage */
606
        $holderPage = new $pageClass;
607
        $holderPage->Title = $title;
608
        /** @skipUpgrade */
609
        $holderPage->URLSegment = 'Security';
610
        // Disable ID-based caching  of the log-in page by making it a random number
611
        $holderPage->ID = -1 * random_int(1, 10000000);
612
613
        $controllerClass = $holderPage->getControllerName();
614
        /** @var ContentController $controller */
615
        $controller = $controllerClass::create($holderPage);
616
        $controller->setDataModel($this->model);
617
        $controller->doInit();
618
619
        return $controller;
620
    }
621
622
    /**
623
     * Combine the given forms into a formset with a tabbed interface
624
     *
625
     * @param $forms
626
     * @return string
627
     */
628
    protected function generateLoginFormSet($forms)
629
    {
630
        $viewData = new ArrayData(array(
631
            'Forms' => new ArrayList($forms),
632
        ));
633
634
        return $viewData->renderWith(
635
            $this->getTemplatesFor('MultiAuthenticatorLogin')
636
        );
637
    }
638
639
    /**
640
     * Get the HTML Content for the $Content area during login
641
     *
642
     * @param string &$messageType Type of message, if available, passed back to caller
643
     * @return string Message in HTML format
644
     */
645
    protected function getLoginMessage(&$messageType = null)
646
    {
647
        $message = Session::get('Security.Message.message');
648
        $messageType = null;
649
        if (empty($message)) {
650
            return null;
651
        }
652
653
        $messageType = Session::get('Security.Message.type');
654
        $messageCast = Session::get('Security.Message.cast');
655
        if ($messageCast !== ValidationResult::CAST_HTML) {
656
            $message = Convert::raw2xml($message);
657
        }
658
659
        return sprintf('<p class="message %s">%s</p>', Convert::raw2att($messageType), $message);
660
    }
661
662
    /**
663
     * Set the next message to display for the security login page. Defaults to warning
664
     *
665
     * @param string $message Message
666
     * @param string $messageType Message type. One of ValidationResult::TYPE_*
667
     * @param string $messageCast Message cast. One of ValidationResult::CAST_*
668
     */
669
    public function setLoginMessage(
670
        $message,
671
        $messageType = ValidationResult::TYPE_WARNING,
672
        $messageCast = ValidationResult::CAST_TEXT
673
    ) {
674
        Session::set("Security.Message.message", $message);
675
        Session::set("Security.Message.type", $messageType);
676
        Session::set("Security.Message.cast", $messageCast);
677
    }
678
679
    /**
680
     * Clear login message
681
     */
682
    public static function clearLoginMessage()
683
    {
684
        Session::clear("Security.Message");
685
    }
686
687
688
    /**
689
     * Show the "login" page
690
     *
691
     * For multiple authenticators, Security_MultiAuthenticatorLogin is used.
692
     * See getTemplatesFor and getIncludeTemplate for how to override template logic
693
     *
694
     * @param $request
695
     * @param int $service
696
     * @return HTTPResponse|string Returns the "login" page as HTML code.
697
     * @throws HTTPResponse_Exception
698
     */
699
    public function login($request = null, $service = Authenticator::LOGIN)
700
    {
701
        // Check pre-login process
702
        if ($response = $this->preLogin()) {
703
            return $response;
704
        }
705
        if (!$request) {
706
            $request = $this->getRequest();
707
        }
708
        $authName = ($request && $request->param('ID')) ? $request->param('ID') : static::$default_authenticator;
709
710
        $link = $this->Link('login');
711
712
        // Delegate to a single handler - Security/login/<authname>/...
713
        if ($authName && $this->hasAuthenticator($authName)) {
714
            if ($request) {
715
                $request->shift();
716
            }
717
718
            $authenticator = $this->getAuthenticator($authName);
719
            // @todo handle different Authenticator situations
720
            if (!$authenticator->supportedServices() & $service) {
721
                throw new HTTPResponse_Exception('Invalid Authenticator "' . $authName . '" for login action', 418);
722
            }
723
724
            $handlers = [$authName => $authenticator];
725
        } else {
726
            // Delegate to all of them, building a tabbed view - Security/login
727
            $handlers = $this->getApplicableAuthenticators($service);
728
        }
729
730
        array_walk(
731
            $handlers,
732
            function (&$auth, $name) use ($link) {
733
                $auth = $auth->getLoginHandler(Controller::join_links($link, $name));
734
            }
735
        );
736
737
        return $this->delegateToMultipleHandlers(
738
            $handlers,
739
            _t('Security.LOGIN', 'Log in'),
740
            $this->getTemplatesFor('login')
741
        );
742
    }
743
744
    /**
745
     * Delegate to an number of handlers, extracting their forms and rendering a tabbed form-set.
746
     * This is used to built the log-in page where there are multiple authenticators active.
747
     *
748
     * If a single handler is passed, delegateToHandler() will be called instead
749
     *
750
     * @param array|RequestHandler[] $handlers
751
     * @param string $title The title of the form
752
     * @param array $templates
753
     * @return array|HTTPResponse|RequestHandler|DBHTMLText|string
754
     */
755
    protected function delegateToMultipleHandlers(array $handlers, $title, array $templates)
756
    {
757
758
        // Simpler case for a single authenticator
759
        if (count($handlers) === 1) {
760
            return $this->delegateToHandler(array_values($handlers)[0], $title, $templates);
761
        }
762
763
        // Process each of the handlers
764
        $results = array_map(
765
            function ($handler) {
766
                return $handler->handleRequest($this->getRequest(), DataModel::inst());
767
            },
768
            $handlers
769
        );
770
771
        // Aggregate all their forms, assuming they all return
772
        $forms = [];
773
        foreach ($results as $authName => $singleResult) {
774
            // The result *must* be an array with a Form key
775
            if (!is_array($singleResult) || !isset($singleResult['Form'])) {
776
                user_error('Authenticator "' . $authName . '" doesn\'t support a tabbed login', E_USER_WARNING);
777
                continue;
778
            }
779
780
            $forms[] = $singleResult['Form'];
781
        }
782
783
        if (!$forms) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $forms of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

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

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

Loading history...
784
            throw new \LogicException('No authenticators found compatible with a tabbed login');
785
        }
786
787
        return $this->renderWrappedController(
788
            $title,
789
            [
790
                'Form' => $this->generateLoginFormSet($forms),
791
            ],
792
            $templates
793
        );
794
    }
795
796
    /**
797
     * Delegate to another RequestHandler, rendering any fragment arrays into an appropriate.
798
     * controller.
799
     *
800
     * @param RequestHandler $handler
801
     * @param string $title The title of the form
802
     * @param array $templates
803
     * @return array|HTTPResponse|RequestHandler|DBHTMLText|string
804
     */
805
    protected function delegateToHandler(RequestHandler $handler, $title, array $templates = [])
806
    {
807
        $result = $handler->handleRequest($this->getRequest(), DataModel::inst());
808
809
        // Return the customised controller - used to render in a Form
810
        // Post requests are expected to be login posts, so they'll be handled downstairs
811
        if (is_array($result)) {
812
            $result = $this->renderWrappedController($title, $result, $templates);
813
        }
814
815
        return $result;
816
    }
817
818
    /**
819
     * Render the given fragments into a security page controller with the given title.
820
     * @param string $title string The title to give the security page
821
     * @param array $fragments A map of objects to render into the page, e.g. "Form"
822
     * @param array $templates An array of templates to use for the render
823
     * @return HTTPResponse|DBHTMLText
824
     */
825
    protected function renderWrappedController($title, array $fragments, array $templates)
826
    {
827
        $controller = $this->getResponseController($title);
828
829
        // if the controller calls Director::redirect(), this will break early
830
        if (($response = $controller->getResponse()) && $response->isFinished()) {
831
            return $response;
832
        }
833
834
        // Handle any form messages from validation, etc.
835
        $messageType = '';
836
        $message = $this->getLoginMessage($messageType);
837
838
        // We've displayed the message in the form output, so reset it for the next run.
839
        static::clearLoginMessage();
840
841
        if ($message) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $message of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

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

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

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

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
842
            $messageResult = [
843
                'Content'     => DBField::create_field('HTMLFragment', $message),
844
                'Message'     => DBField::create_field('HTMLFragment', $message),
845
                'MessageType' => $messageType
846
            ];
847
            $fragments = array_merge($fragments, $messageResult);
848
        }
849
850
        return $controller->customise($fragments)->renderWith($templates);
851
    }
852
853
    public function basicauthlogin()
854
    {
855
        $member = BasicAuth::requireLogin($this->getRequest(), 'SilverStripe login', 'ADMIN');
856
        static::setCurrentUser($member);
0 ignored issues
show
Bug introduced by
It seems like $member defined by \SilverStripe\Security\B...Stripe login', 'ADMIN') on line 855 can also be of type boolean; however, SilverStripe\Security\Security::setCurrentUser() does only seem to accept null|object<SilverStripe\Security\Member>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
857
    }
858
859
    /**
860
     * Show the "lost password" page
861
     *
862
     * @return string Returns the "lost password" page as HTML code.
863
     */
864 View Code Duplication
    public function lostpassword()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

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

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

Loading history...
865
    {
866
        $handlers = [];
867
        $authenticators = $this->getApplicableAuthenticators(Authenticator::RESET_PASSWORD);
868
        /** @var Authenticator $authenticator */
869
        foreach ($authenticators as $authenticator) {
870
            $handlers[] = $authenticator->getLostPasswordHandler(
871
                Controller::join_links($this->Link(), 'lostpassword')
872
            );
873
        }
874
875
        return $this->delegateToMultipleHandlers(
876
            $handlers,
877
            _t('SilverStripe\\Security\\Security.LOSTPASSWORDHEADER', 'Lost Password'),
878
            $this->getTemplatesFor('lostpassword')
879
        );
880
    }
881
882
    /**
883
     * Show the "change password" page.
884
     * This page can either be called directly by logged-in users
885
     * (in which case they need to provide their old password),
886
     * or through a link emailed through {@link lostpassword()}.
887
     * In this case no old password is required, authentication is ensured
888
     * through the Member.AutoLoginHash property.
889
     *
890
     * @see ChangePasswordForm
891
     *
892
     * @return string|HTTPRequest Returns the "change password" page as HTML code, or a redirect response
893
     */
894 View Code Duplication
    public function changepassword()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

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

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

Loading history...
895
    {
896
        /** @var array|Authenticator[] $authenticators */
897
        $authenticators = $this->getApplicableAuthenticators(Authenticator::CHANGE_PASSWORD);
898
        $handlers = [];
899
        foreach ($authenticators as $authenticator) {
900
            $handlers[] = $authenticator->getChangePasswordHandler($this->Link('changepassword'));
901
        }
902
903
        return $this->delegateToMultipleHandlers(
904
            $handlers,
905
            _t('SilverStripe\\Security\\Security.CHANGEPASSWORDHEADER', 'Change your password'),
906
            $this->getTemplatesFor('changepassword')
907
        );
908
    }
909
910
    /**
911
     * Create a link to the password reset form.
912
     *
913
     * GET parameters used:
914
     * - m: member ID
915
     * - t: plaintext token
916
     *
917
     * @param Member $member Member object associated with this link.
918
     * @param string $autologinToken The auto login token.
919
     * @return string
920
     */
921
    public static function getPasswordResetLink($member, $autologinToken)
922
    {
923
        $autologinToken = urldecode($autologinToken);
924
925
        return static::singleton()->Link('changepassword') . "?m={$member->ID}&t=$autologinToken";
926
    }
927
928
    /**
929
     * Determine the list of templates to use for rendering the given action.
930
     *
931
     * @skipUpgrade
932
     * @param string $action
933
     * @return array Template list
934
     */
935
    public function getTemplatesFor($action)
936
    {
937
        $templates = SSViewer::get_templates_by_class(static::class, "_{$action}", __CLASS__);
938
939
        return array_merge(
940
            $templates,
941
            [
942
                "Security_{$action}",
943
                "Security",
944
                $this->stat("template_main"),
945
                "BlankPage"
946
            ]
947
        );
948
    }
949
950
    /**
951
     * Return an existing member with administrator privileges, or create one of necessary.
952
     *
953
     * Will create a default 'Administrators' group if no group is found
954
     * with an ADMIN permission. Will create a new 'Admin' member with administrative permissions
955
     * if no existing Member with these permissions is found.
956
     *
957
     * Important: Any newly created administrator accounts will NOT have valid
958
     * login credentials (Email/Password properties), which means they can't be used for login
959
     * purposes outside of any default credentials set through {@link Security::setDefaultAdmin()}.
960
     *
961
     * @return Member
962
     */
963
    public static function findAnAdministrator()
964
    {
965
        static::singleton()->extend('beforeFindAdministrator');
966
967
        /** @var Member $member */
968
        $member = null;
0 ignored issues
show
Unused Code introduced by
$member is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
969
970
        // find a group with ADMIN permission
971
        $adminGroup = Permission::get_groups_by_permission('ADMIN')->first();
972
973
        if (!$adminGroup) {
974
            Group::singleton()->requireDefaultRecords();
975
            $adminGroup = Permission::get_groups_by_permission('ADMIN')->first();
976
        }
977
978
        $member = $adminGroup->Members()->First();
979
980
        if (!$member) {
981
            Member::singleton()->requireDefaultRecords();
982
            $member = Permission::get_members_by_permission('ADMIN')->first();
983
        }
984
985
        if (!$member) {
986
            $member = Member::default_admin();
987
        }
988
989
        if (!$member) {
990
            // Failover to a blank admin
991
            $member = Member::create();
992
            $member->FirstName = _t('SilverStripe\\Security\\Member.DefaultAdminFirstname', 'Default Admin');
993
            $member->write();
994
            // Add member to group instead of adding group to member
995
            // This bypasses the privilege escallation code in Member_GroupSet
996
            $adminGroup
997
                ->DirectMembers()
998
                ->add($member);
999
        }
1000
1001
        static::singleton()->extend('afterFindAdministrator');
1002
1003
        return $member;
1004
    }
1005
1006
    /**
1007
     * Flush the default admin credentials
1008
     */
1009
    public static function clear_default_admin()
1010
    {
1011
        self::$default_username = null;
1012
        self::$default_password = null;
1013
    }
1014
1015
1016
    /**
1017
     * Set a default admin in dev-mode
1018
     *
1019
     * This will set a static default-admin which is not existing
1020
     * as a database-record. By this workaround we can test pages in dev-mode
1021
     * with a unified login. Submitted login-credentials are first checked
1022
     * against this static information in {@link Security::authenticate()}.
1023
     *
1024
     * @param string $username The user name
1025
     * @param string $password The password (in cleartext)
1026
     * @return bool True if successfully set
1027
     */
1028
    public static function setDefaultAdmin($username, $password)
1029
    {
1030
        // don't overwrite if already set
1031
        if (self::$default_username || self::$default_password) {
1032
            return false;
1033
        }
1034
1035
        self::$default_username = $username;
1036
        self::$default_password = $password;
1037
1038
        return true;
1039
    }
1040
1041
    /**
1042
     * Checks if the passed credentials are matching the default-admin.
1043
     * Compares cleartext-password set through Security::setDefaultAdmin().
1044
     *
1045
     * @param string $username
1046
     * @param string $password
1047
     * @return bool
1048
     */
1049
    public static function check_default_admin($username, $password)
1050
    {
1051
        return (
1052
            self::$default_username === $username
1053
            && self::$default_password === $password
1054
            && self::has_default_admin()
1055
        );
1056
    }
1057
1058
    /**
1059
     * Check that the default admin account has been set.
1060
     */
1061
    public static function has_default_admin()
1062
    {
1063
        return !empty(self::$default_username) && !empty(self::$default_password);
1064
    }
1065
1066
    /**
1067
     * Get default admin username
1068
     *
1069
     * @return string
1070
     */
1071
    public static function default_admin_username()
1072
    {
1073
        return self::$default_username;
1074
    }
1075
1076
    /**
1077
     * Get default admin password
1078
     *
1079
     * @return string
1080
     */
1081
    public static function default_admin_password()
1082
    {
1083
        return self::$default_password;
1084
    }
1085
1086
    /**
1087
     * Encrypt a password according to the current password encryption settings.
1088
     * If the settings are so that passwords shouldn't be encrypted, the
1089
     * result is simple the clear text password with an empty salt except when
1090
     * a custom algorithm ($algorithm parameter) was passed.
1091
     *
1092
     * @param string $password The password to encrypt
1093
     * @param string $salt Optional: The salt to use. If it is not passed, but
1094
     *  needed, the method will automatically create a
1095
     *  random salt that will then be returned as return value.
1096
     * @param string $algorithm Optional: Use another algorithm to encrypt the
1097
     *  password (so that the encryption algorithm can be changed over the time).
1098
     * @param Member $member Optional
1099
     * @return mixed Returns an associative array containing the encrypted
1100
     *  password and the used salt in the form:
1101
     * <code>
1102
     *  array(
1103
     *  'password' => string,
1104
     *  'salt' => string,
1105
     *  'algorithm' => string,
1106
     *  'encryptor' => PasswordEncryptor instance
1107
     *  )
1108
     * </code>
1109
     * If the passed algorithm is invalid, FALSE will be returned.
1110
     *
1111
     * @see encrypt_passwords()
1112
     */
1113
    public static function encrypt_password($password, $salt = null, $algorithm = null, $member = null)
1114
    {
1115
        // Fall back to the default encryption algorithm
1116
        if (!$algorithm) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $algorithm of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

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

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

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

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1117
            $algorithm = self::config()->get('password_encryption_algorithm');
1118
        }
1119
1120
        $e = PasswordEncryptor::create_for_algorithm($algorithm);
1121
1122
        // New salts will only need to be generated if the password is hashed for the first time
1123
        $salt = ($salt) ? $salt : $e->salt($password);
1124
1125
        return array(
1126
            'password'  => $e->encrypt($password, $salt, $member),
1127
            'salt'      => $salt,
1128
            'algorithm' => $algorithm,
1129
            'encryptor' => $e
1130
        );
1131
    }
1132
1133
    /**
1134
     * Checks the database is in a state to perform security checks.
1135
     * See {@link DatabaseAdmin->init()} for more information.
1136
     *
1137
     * @return bool
1138
     */
1139
    public static function database_is_ready()
1140
    {
1141
        // Used for unit tests
1142
        if (self::$force_database_is_ready !== null) {
1143
            return self::$force_database_is_ready;
1144
        }
1145
1146
        if (self::$database_is_ready) {
1147
            return self::$database_is_ready;
1148
        }
1149
1150
        $requiredClasses = ClassInfo::dataClassesFor(Member::class);
1151
        $requiredClasses[] = Group::class;
1152
        $requiredClasses[] = Permission::class;
1153
        $schema = DataObject::getSchema();
1154
        foreach ($requiredClasses as $class) {
1155
            // Skip test classes, as not all test classes are scaffolded at once
1156
            if (is_a($class, TestOnly::class, true)) {
1157
                continue;
1158
            }
1159
1160
            // if any of the tables aren't created in the database
1161
            $table = $schema->tableName($class);
1162
            if (!ClassInfo::hasTable($table)) {
1163
                return false;
1164
            }
1165
1166
            // HACK: DataExtensions aren't applied until a class is instantiated for
1167
            // the first time, so create an instance here.
1168
            singleton($class);
1169
1170
            // if any of the tables don't have all fields mapped as table columns
1171
            $dbFields = DB::field_list($table);
1172
            if (!$dbFields) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $dbFields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

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

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

Loading history...
1173
                return false;
1174
            }
1175
1176
            $objFields = $schema->databaseFields($class, false);
1177
            $missingFields = array_diff_key($objFields, $dbFields);
1178
1179
            if ($missingFields) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $missingFields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

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

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

Loading history...
1180
                return false;
1181
            }
1182
        }
1183
        self::$database_is_ready = true;
1184
1185
        return true;
1186
    }
1187
1188
    /**
1189
     * Resets the database_is_ready cache
1190
     */
1191
    public static function clear_database_is_ready()
1192
    {
1193
        self::$database_is_ready = null;
1194
        self::$force_database_is_ready = null;
1195
    }
1196
1197
    /**
1198
     * For the database_is_ready call to return a certain value - used for testing
1199
     */
1200
    public static function force_database_is_ready($isReady)
1201
    {
1202
        self::$force_database_is_ready = $isReady;
1203
    }
1204
1205
    /**
1206
     * @config
1207
     * @var string Set the default login dest
1208
     * This is the URL that users will be redirected to after they log in,
1209
     * if they haven't logged in en route to access a secured page.
1210
     * By default, this is set to the homepage.
1211
     */
1212
    private static $default_login_dest = "";
1213
1214
    protected static $ignore_disallowed_actions = false;
1215
1216
    /**
1217
     * Set to true to ignore access to disallowed actions, rather than returning permission failure
1218
     * Note that this is just a flag that other code needs to check with Security::ignore_disallowed_actions()
1219
     * @param $flag True or false
1220
     */
1221
    public static function set_ignore_disallowed_actions($flag)
1222
    {
1223
        self::$ignore_disallowed_actions = $flag;
1224
    }
1225
1226
    public static function ignore_disallowed_actions()
1227
    {
1228
        return self::$ignore_disallowed_actions;
1229
    }
1230
1231
    /**
1232
     * Get the URL of the log-in page.
1233
     *
1234
     * To update the login url use the "Security.login_url" config setting.
1235
     *
1236
     * @return string
1237
     */
1238
    public static function login_url()
1239
    {
1240
        return Controller::join_links(Director::baseURL(), self::config()->get('login_url'));
1241
    }
1242
1243
1244
    /**
1245
     * Get the URL of the logout page.
1246
     *
1247
     * To update the logout url use the "Security.logout_url" config setting.
1248
     *
1249
     * @return string
1250
     */
1251
    public static function logout_url()
1252
    {
1253
        return Controller::join_links(Director::baseURL(), self::config()->get('logout_url'));
1254
    }
1255
1256
    /**
1257
     * Get the URL of the logout page.
1258
     *
1259
     * To update the logout url use the "Security.logout_url" config setting.
1260
     *
1261
     * @return string
1262
     */
1263
    public static function lost_password_url()
1264
    {
1265
        return Controller::join_links(Director::baseURL(), self::config()->get('lost_password_url'));
1266
    }
1267
1268
    /**
1269
     * Defines global accessible templates variables.
1270
     *
1271
     * @return array
1272
     */
1273
    public static function get_template_global_variables()
1274
    {
1275
        return array(
1276
            "LoginURL"        => "login_url",
1277
            "LogoutURL"       => "logout_url",
1278
            "LostPasswordURL" => "lost_password_url",
1279
            "CurrentMember"   => "getCurrentUser",
1280
            "currentUser"     => "getCurrentUser"
1281
        );
1282
    }
1283
}
1284