Completed
Pull Request — master (#7026)
by Damian
08:24
created

Security::getResponseController()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 23
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 12
nc 2
nop 1
dl 0
loc 23
rs 9.0856
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Security;
4
5
use LogicException;
6
use Page;
7
use SilverStripe\CMS\Controllers\ModelAsController;
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\RequestHandler;
14
use SilverStripe\Control\Session;
15
use SilverStripe\Core\ClassInfo;
16
use SilverStripe\Core\Convert;
17
use SilverStripe\Core\Injector\Injector;
18
use SilverStripe\Dev\Deprecation;
19
use SilverStripe\Dev\TestOnly;
20
use SilverStripe\Forms\Form;
21
use SilverStripe\ORM\ArrayList;
22
use SilverStripe\ORM\DataModel;
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\Security\DefaultAdminService;
29
use SilverStripe\View\ArrayData;
30
use SilverStripe\View\SSViewer;
31
use SilverStripe\View\TemplateGlobalProvider;
32
33
/**
34
 * Implements a basic security model
35
 */
36
class Security extends Controller implements TemplateGlobalProvider
37
{
38
39
    private static $allowed_actions = array(
40
        'index',
41
        'login',
42
        'logout',
43
        'basicauthlogin',
44
        'lostpassword',
45
        'passwordsent',
46
        'changepassword',
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;
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';
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;
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;
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';
93
94
    /**
95
     * @config
96
     * @var string
97
     */
98
    private static $template = 'BlankPage';
99
100
    /**
101
     * Template that is used to render the pages.
102
     *
103
     * @var string
104
     * @config
105
     */
106
    private static $template_main = 'Page';
107
108
    /**
109
     * Class to use for page rendering
110
     *
111
     * @var string
112
     * @config
113
     */
114
    private static $page_class = Page::class;
115
116
    /**
117
     * Default message set used in permission failures.
118
     *
119
     * @config
120
     * @var array|string
121
     */
122
    private static $default_message_set;
123
124
    /**
125
     * Random secure token, can be used as a crypto key internally.
126
     * Generate one through 'sake dev/generatesecuretoken'.
127
     *
128
     * @config
129
     * @var String
130
     */
131
    private static $token;
132
133
    /**
134
     * The default login URL
135
     *
136
     * @config
137
     *
138
     * @var string
139
     */
140
    private static $login_url = 'Security/login';
141
142
    /**
143
     * The default logout URL
144
     *
145
     * @config
146
     *
147
     * @var string
148
     */
149
    private static $logout_url = 'Security/logout';
150
151
    /**
152
     * The default lost password URL
153
     *
154
     * @config
155
     *
156
     * @var string
157
     */
158
    private static $lost_password_url = 'Security/lostpassword';
159
160
    /**
161
     * Value of X-Frame-Options header
162
     *
163
     * @config
164
     * @var string
165
     */
166
    private static $frame_options = 'SAMEORIGIN';
167
168
    /**
169
     * Value of the X-Robots-Tag header (for the Security section)
170
     *
171
     * @config
172
     * @var string
173
     */
174
    private static $robots_tag = 'noindex, nofollow';
175
176
    /**
177
     * Enable or disable recording of login attempts
178
     * through the {@link LoginRecord} object.
179
     *
180
     * @config
181
     * @var boolean $login_recording
182
     */
183
    private static $login_recording = false;
184
185
    /**
186
     * @var boolean If set to TRUE or FALSE, {@link database_is_ready()}
187
     * will always return FALSE. Used for unit testing.
188
     */
189
    protected static $force_database_is_ready;
190
191
    /**
192
     * When the database has once been verified as ready, it will not do the
193
     * checks again.
194
     *
195
     * @var bool
196
     */
197
    protected static $database_is_ready = false;
198
199
    /**
200
     * @var Authenticator[] available authenticators
201
     */
202
    private $authenticators = [];
203
204
    /**
205
     * @var Member Currently logged in user (if available)
206
     */
207
    protected static $currentUser;
208
209
    /**
210
     * @return Authenticator[]
211
     */
212
    public function getAuthenticators()
213
    {
214
        return $this->authenticators;
215
    }
216
217
    /**
218
     * @param Authenticator[] $authenticators
219
     */
220
    public function setAuthenticators(array $authenticators)
221
    {
222
        $this->authenticators = $authenticators;
223
    }
224
225
    protected function init()
226
    {
227
        parent::init();
228
229
        // Prevent clickjacking, see https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options
230
        $frameOptions = static::config()->get('frame_options');
231
        if ($frameOptions) {
232
            $this->getResponse()->addHeader('X-Frame-Options', $frameOptions);
233
        }
234
235
        // Prevent search engines from indexing the login page
236
        $robotsTag = static::config()->get('robots_tag');
237
        if ($robotsTag) {
238
            $this->getResponse()->addHeader('X-Robots-Tag', $robotsTag);
239
        }
240
    }
241
242
    public function index()
243
    {
244
        return $this->httpError(404); // no-op
245
    }
246
247
    /**
248
     * Get the selected authenticator for this request
249
     *
250
     * @param string $name The identifier of the authenticator in your config
251
     * @return Authenticator Class name of Authenticator
252
     * @throws LogicException
253
     */
254
    protected function getAuthenticator($name = 'default')
255
    {
256
        $authenticators = $this->authenticators;
257
258
        if (isset($authenticators[$name])) {
259
            return $authenticators[$name];
260
        }
261
262
        throw new LogicException('No valid authenticator found');
263
    }
264
265
    /**
266
     * Get all registered authenticators
267
     *
268
     * @param int $service The type of service that is requested
269
     * @return Authenticator[] Return an array of Authenticator objects
270
     */
271
    public function getApplicableAuthenticators($service = Authenticator::LOGIN)
272
    {
273
        $authenticators = $this->getAuthenticators();
274
275
        /** @var Authenticator $authenticator */
276
        foreach ($authenticators as $name => $authenticator) {
277
            if (!($authenticator->supportedServices() & $service)) {
278
                unset($authenticators[$name]);
279
            }
280
        }
281
282
        if (empty($authenticators)) {
283
            throw new LogicException('No applicable authenticators found');
284
        }
285
286
        return $authenticators;
287
    }
288
289
    /**
290
     * Check if a given authenticator is registered
291
     *
292
     * @param string $authenticator The configured identifier of the authenicator
293
     * @return bool Returns TRUE if the authenticator is registered, FALSE
294
     *              otherwise.
295
     */
296
    public function hasAuthenticator($authenticator)
297
    {
298
        $authenticators = $this->authenticators;
299
300
        return !empty($authenticators[$authenticator]);
301
    }
302
303
    /**
304
     * Register that we've had a permission failure trying to view the given page
305
     *
306
     * This will redirect to a login page.
307
     * If you don't provide a messageSet, a default will be used.
308
     *
309
     * @param Controller $controller The controller that you were on to cause the permission
310
     *                               failure.
311
     * @param string|array $messageSet The message to show to the user. This
312
     *                                 can be a string, or a map of different
313
     *                                 messages for different contexts.
314
     *                                 If you pass an array, you can use the
315
     *                                 following keys:
316
     *                                   - default: The default message
317
     *                                   - alreadyLoggedIn: The message to
318
     *                                                      show if the user
319
     *                                                      is already logged
320
     *                                                      in and lacks the
321
     *                                                      permission to
322
     *                                                      access the item.
323
     *
324
     * The alreadyLoggedIn value can contain a '%s' placeholder that will be replaced with a link
325
     * to log in.
326
     * @return HTTPResponse
327
     */
328
    public static function permissionFailure($controller = null, $messageSet = null)
0 ignored issues
show
Coding Style introduced by
permissionFailure uses the super-global variable $_SERVER which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
329
    {
330
        self::set_ignore_disallowed_actions(true);
331
332
        if (!$controller) {
333
            $controller = Controller::curr();
334
        }
335
336
        if (Director::is_ajax()) {
337
            $response = ($controller) ? $controller->getResponse() : new HTTPResponse();
338
            $response->setStatusCode(403);
339
            if (!static::getCurrentUser()) {
340
                $response->setBody(
341
                    _t('SilverStripe\\CMS\\Controllers\\ContentController.NOTLOGGEDIN', 'Not logged in')
342
                );
343
                $response->setStatusDescription(
344
                    _t('SilverStripe\\CMS\\Controllers\\ContentController.NOTLOGGEDIN', 'Not logged in')
345
                );
346
                // Tell the CMS to allow re-authentication
347
                if (CMSSecurity::singleton()->enabled()) {
348
                    $response->addHeader('X-Reauthenticate', '1');
349
                }
350
            }
351
352
            return $response;
353
        }
354
355
        // Prepare the messageSet provided
356
        if (!$messageSet) {
357
            if ($configMessageSet = static::config()->get('default_message_set')) {
358
                $messageSet = $configMessageSet;
359
            } else {
360
                $messageSet = array(
361
                    'default'         => _t(
362
                        'SilverStripe\\Security\\Security.NOTEPAGESECURED',
363
                        "That page is secured. Enter your credentials below and we will send "
364
                        . "you right along."
365
                    ),
366
                    'alreadyLoggedIn' => _t(
367
                        'SilverStripe\\Security\\Security.ALREADYLOGGEDIN',
368
                        "You don't have access to this page.  If you have another account that "
369
                        . "can access that page, you can log in again below.",
370
                        "%s will be replaced with a link to log in."
371
                    )
372
                );
373
            }
374
        }
375
376
        if (!is_array($messageSet)) {
377
            $messageSet = array('default' => $messageSet);
378
        }
379
380
        $member = static::getCurrentUser();
381
382
        // Work out the right message to show
383
        if ($member && $member->exists()) {
384
            $response = ($controller) ? $controller->getResponse() : new HTTPResponse();
385
            $response->setStatusCode(403);
386
387
            //If 'alreadyLoggedIn' is not specified in the array, then use the default
388
            //which should have been specified in the lines above
389
            if (isset($messageSet['alreadyLoggedIn'])) {
390
                $message = $messageSet['alreadyLoggedIn'];
391
            } else {
392
                $message = $messageSet['default'];
393
            }
394
395
            static::singleton()->setLoginMessage($message, ValidationResult::TYPE_WARNING);
396
            $loginResponse = static::singleton()->login();
397
            if ($loginResponse instanceof HTTPResponse) {
398
                return $loginResponse;
399
            }
400
401
            $response->setBody((string)$loginResponse);
402
403
            $controller->extend('permissionDenied', $member);
404
405
            return $response;
406
        } else {
407
            $message = $messageSet['default'];
408
        }
409
410
        static::singleton()->setLoginMessage($message, ValidationResult::TYPE_WARNING);
411
412
        Session::set("BackURL", $_SERVER['REQUEST_URI']);
413
414
        // TODO AccessLogEntry needs an extension to handle permission denied errors
415
        // Audit logging hook
416
        $controller->extend('permissionDenied', $member);
417
418
        return $controller->redirect(Controller::join_links(
419
            Security::config()->uninherited('login_url'),
0 ignored issues
show
Coding Style introduced by
As per coding style, self should be used for accessing local static members.

This check looks for accesses to local static members using the fully qualified name instead of self::.

<?php

class Certificate {
    const TRIPLEDES_CBC = 'ASDFGHJKL';

    private $key;

    public function __construct()
    {
        $this->key = Certificate::TRIPLEDES_CBC;
    }
}

While this is perfectly valid, the fully qualified name of Certificate::TRIPLEDES_CBC could just as well be replaced by self::TRIPLEDES_CBC. Referencing local members with self:: assured the access will still work when the class is renamed, makes it perfectly clear that the member is in fact local and will usually be shorter.

Loading history...
420
            "?BackURL=" . urlencode($_SERVER['REQUEST_URI'])
421
        ));
422
    }
423
424
    /**
425
     * @param null|Member $currentUser
426
     */
427
    public static function setCurrentUser($currentUser = null)
428
    {
429
        self::$currentUser = $currentUser;
430
    }
431
432
    /**
433
     * @return null|Member
434
     */
435
    public static function getCurrentUser()
436
    {
437
        return self::$currentUser;
438
    }
439
440
    /**
441
     * Get the login forms for all available authentication methods
442
     *
443
     * @deprecated 5.0.0 Now handled by {@link static::delegateToMultipleHandlers}
444
     *
445
     * @return array Returns an array of available login forms (array of Form
446
     *               objects).
447
     *
448
     */
449
    public function getLoginForms()
450
    {
451
        Deprecation::notice('5.0.0', 'Now handled by delegateToMultipleHandlers');
452
453
        return array_map(
454
            function (Authenticator $authenticator) {
455
                return [
456
                    $authenticator->getLoginHandler($this->Link())->loginForm()
457
                ];
458
            },
459
            $this->getApplicableAuthenticators()
460
        );
461
    }
462
463
464
    /**
465
     * Get a link to a security action
466
     *
467
     * @param string $action Name of the action
468
     * @return string Returns the link to the given action
469
     */
470
    public function Link($action = null)
471
    {
472
        /** @skipUpgrade */
473
        return Controller::join_links(Director::baseURL(), "Security", $action);
474
    }
475
476
    /**
477
     * This action is available as a keep alive, so user
478
     * sessions don't timeout. A common use is in the admin.
479
     */
480
    public function ping()
481
    {
482
        return 1;
483
    }
484
485
    /**
486
     * Log the currently logged in user out
487
     *
488
     * Logging out without ID-parameter in the URL, will log the user out of all applicable Authenticators.
489
     *
490
     * Adding an ID will only log the user out of that Authentication method.
491
     *
492
     * Logging out of Default will <i>always</i> completely log out the user.
493
     *
494
     * @param bool $redirect Redirect the user back to where they came.
495
     *                       - If it's false, the code calling logout() is
496
     *                         responsible for sending the user where-ever
497
     *                         they should go.
498
     * @return HTTPResponse|null
499
     */
500
    public function logout($redirect = true)
501
    {
502
        $this->extend('beforeMemberLoggedOut');
503
        $member = static::getCurrentUser();
504
505
        if ($member) { // If we don't have a member, there's not much to log out.
506
            /** @var array|Authenticator[] $authenticators */
507
            $authenticators = $this->getApplicableAuthenticators(Authenticator::LOGOUT);
508
509
            /** @var Authenticator[] $authenticator */
510
            foreach ($authenticators as $name => $authenticator) {
511
                $handler = $authenticator->getLogOutHandler(Controller::join_links($this->Link(), 'logout'));
512
                $this->delegateToHandler($handler, $name);
513
            }
514
            // In the rare case, but plausible with e.g. an external IdentityStore, the user is not logged out.
515
            if (static::getCurrentUser() !== null) {
516
                $this->extend('failureMemberLoggedOut', $authenticator);
517
518
                return $this->redirectBack();
519
            }
520
            $this->extend('successMemberLoggedOut', $authenticator);
521
            // Member is successfully logged out. Write possible changes to the database.
522
            $member->write();
523
        }
524
        $this->extend('afterMemberLoggedOut');
525
526
        if ($redirect && (!$this->getResponse()->isFinished())) {
527
            return $this->redirectBack();
528
        }
529
530
        return null;
531
    }
532
533
    /**
534
     * Perform pre-login checking and prepare a response if available prior to login
535
     *
536
     * @return HTTPResponse Substitute response object if the login process should be curcumvented.
537
     * Returns null if should proceed as normal.
538
     */
539
    protected function preLogin()
540
    {
541
        // Event handler for pre-login, with an option to let it break you out of the login form
542
        $eventResults = $this->extend('onBeforeSecurityLogin');
543
        // If there was a redirection, return
544
        if ($this->redirectedTo()) {
545
            return $this->getResponse();
546
        }
547
        // If there was an HTTPResponse object returned, then return that
548
        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...
549
            foreach ($eventResults as $result) {
550
                if ($result instanceof HTTPResponse) {
551
                    return $result;
552
                }
553
            }
554
        }
555
556
        // If arriving on the login page already logged in, with no security error, and a ReturnURL then redirect
557
        // back. The login message check is neccesary to prevent infinite loops where BackURL links to
558
        // an action that triggers Security::permissionFailure.
559
        // This step is necessary in cases such as automatic redirection where a user is authenticated
560
        // upon landing on an SSL secured site and is automatically logged in, or some other case
561
        // where the user has permissions to continue but is not given the option.
562
        if (!$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...
563
            && ($member = static::getCurrentUser())
564
            && $member->exists()
565
            && $this->getRequest()->requestVar('BackURL')
566
        ) {
567
            return $this->redirectBack();
568
        }
569
570
        return null;
571
    }
572
573
    /**
574
     * Prepare the controller for handling the response to this request
575
     *
576
     * @param string $title Title to use
577
     * @return Controller
578
     */
579
    protected function getResponseController($title)
580
    {
581
        // Use the default setting for which Page to use to render the security page
582
        $pageClass = $this->stat('page_class');
583
        if (!$pageClass || !class_exists($pageClass)) {
584
            return $this;
585
        }
586
587
        // Create new instance of page holder
588
        /** @var Page $holderPage */
589
        $holderPage = Injector::inst()->create($pageClass);
590
        $holderPage->Title = $title;
591
        /** @skipUpgrade */
592
        $holderPage->URLSegment = 'Security';
593
        // Disable ID-based caching  of the log-in page by making it a random number
594
        $holderPage->ID = -1 * random_int(1, 10000000);
595
596
        $controller = ModelAsController::controller_for($holderPage);
597
        $controller->setDataModel($this->model);
598
        $controller->doInit();
599
600
        return $controller;
601
    }
602
603
    /**
604
     * Combine the given forms into a formset with a tabbed interface
605
     *
606
     * @param array|Form[] $forms
607
     * @return string
608
     */
609
    protected function generateLoginFormSet($forms)
610
    {
611
        $viewData = new ArrayData(array(
612
            'Forms' => new ArrayList($forms),
613
        ));
614
615
        return $viewData->renderWith(
616
            $this->getTemplatesFor('MultiAuthenticatorLogin')
617
        );
618
    }
619
620
    /**
621
     * Get the HTML Content for the $Content area during login
622
     *
623
     * @param string &$messageType Type of message, if available, passed back to caller
624
     * @return string Message in HTML format
625
     */
626
    protected function getLoginMessage(&$messageType = null)
627
    {
628
        $message = Session::get('Security.Message.message');
629
        $messageType = null;
630
        if (empty($message)) {
631
            return null;
632
        }
633
634
        $messageType = Session::get('Security.Message.type');
635
        $messageCast = Session::get('Security.Message.cast');
636
        if ($messageCast !== ValidationResult::CAST_HTML) {
637
            $message = Convert::raw2xml($message);
638
        }
639
640
        return sprintf('<p class="message %s">%s</p>', Convert::raw2att($messageType), $message);
641
    }
642
643
    /**
644
     * Set the next message to display for the security login page. Defaults to warning
645
     *
646
     * @param string $message Message
647
     * @param string $messageType Message type. One of ValidationResult::TYPE_*
648
     * @param string $messageCast Message cast. One of ValidationResult::CAST_*
649
     */
650
    public function setLoginMessage(
651
        $message,
652
        $messageType = ValidationResult::TYPE_WARNING,
653
        $messageCast = ValidationResult::CAST_TEXT
654
    ) {
655
        Session::set('Security.Message.message', $message);
656
        Session::set('Security.Message.type', $messageType);
657
        Session::set('Security.Message.cast', $messageCast);
658
    }
659
660
    /**
661
     * Clear login message
662
     */
663
    public static function clearLoginMessage()
664
    {
665
        Session::clear('Security.Message');
666
    }
667
668
669
    /**
670
     * Show the "login" page
671
     *
672
     * For multiple authenticators, Security_MultiAuthenticatorLogin is used.
673
     * See getTemplatesFor and getIncludeTemplate for how to override template logic
674
     *
675
     * @param null|HTTPRequest $request
676
     * @param int $service
677
     * @return HTTPResponse|string Returns the "login" page as HTML code.
678
     * @throws HTTPResponse_Exception
679
     */
680
    public function login($request = null, $service = Authenticator::LOGIN)
681
    {
682
        // Check pre-login process
683
        if ($response = $this->preLogin()) {
684
            return $response;
685
        }
686
        $authName = null;
687
688
        if (!$request) {
689
            $request = $this->getRequest();
690
        }
691
692
        if ($request && $request->param('ID')) {
693
            $authName = $request->param('ID');
694
        }
695
696
        $link = $this->Link('login');
697
698
        // Delegate to a single handler - Security/login/<authname>/...
699
        if ($authName && $this->hasAuthenticator($authName)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $authName of type string|null 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...
700
            if ($request) {
701
                $request->shift();
702
            }
703
704
            $authenticator = $this->getAuthenticator($authName);
705
706
            if (!$authenticator->supportedServices() & $service) {
707
                throw new HTTPResponse_Exception('Invalid Authenticator "' . $authName . '" for login action', 418);
708
            }
709
710
            $handlers = [$authName => $authenticator];
711
        } else {
712
            // Delegate to all of them, building a tabbed view - Security/login
713
            $handlers = $this->getApplicableAuthenticators($service);
714
        }
715
716
        array_walk(
717
            $handlers,
718
            function (Authenticator &$auth, $name) use ($link) {
719
                $auth = $auth->getLoginHandler(Controller::join_links($link, $name));
720
            }
721
        );
722
723
        return $this->delegateToMultipleHandlers(
724
            $handlers,
725
            _t('Security.LOGIN', 'Log in'),
726
            $this->getTemplatesFor('login')
727
        );
728
    }
729
730
    /**
731
     * Delegate to an number of handlers, extracting their forms and rendering a tabbed form-set.
732
     * This is used to built the log-in page where there are multiple authenticators active.
733
     *
734
     * If a single handler is passed, delegateToHandler() will be called instead
735
     *
736
     * @param array|RequestHandler[] $handlers
737
     * @param string $title The title of the form
738
     * @param array $templates
739
     * @return array|HTTPResponse|RequestHandler|DBHTMLText|string
740
     */
741
    protected function delegateToMultipleHandlers(array $handlers, $title, array $templates)
742
    {
743
744
        // Simpler case for a single authenticator
745
        if (count($handlers) === 1) {
746
            return $this->delegateToHandler(array_values($handlers)[0], $title, $templates);
747
        }
748
749
        // Process each of the handlers
750
        $results = array_map(
751
            function (RequestHandler $handler) {
752
                return $handler->handleRequest($this->getRequest(), DataModel::inst());
753
            },
754
            $handlers
755
        );
756
757
        // Aggregate all their forms, assuming they all return
758
        $forms = [];
759
        foreach ($results as $authName => $singleResult) {
760
            // The result *must* be an array with a Form key
761
            if (!is_array($singleResult) || !isset($singleResult['Form'])) {
762
                user_error('Authenticator "' . $authName . '" doesn\'t support a tabbed login', E_USER_WARNING);
763
                continue;
764
            }
765
766
            $forms[] = $singleResult['Form'];
767
        }
768
769
        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...
770
            throw new \LogicException('No authenticators found compatible with a tabbed login');
771
        }
772
773
        return $this->renderWrappedController(
774
            $title,
775
            [
776
                'Form' => $this->generateLoginFormSet($forms),
777
            ],
778
            $templates
779
        );
780
    }
781
782
    /**
783
     * Delegate to another RequestHandler, rendering any fragment arrays into an appropriate.
784
     * controller.
785
     *
786
     * @param RequestHandler $handler
787
     * @param string $title The title of the form
788
     * @param array $templates
789
     * @return array|HTTPResponse|RequestHandler|DBHTMLText|string
790
     */
791
    protected function delegateToHandler(RequestHandler $handler, $title, array $templates = [])
792
    {
793
        $result = $handler->handleRequest($this->getRequest(), DataModel::inst());
794
795
        // Return the customised controller - used to render in a Form
796
        // Post requests are expected to be login posts, so they'll be handled downstairs
797
        if (is_array($result)) {
798
            $result = $this->renderWrappedController($title, $result, $templates);
799
        }
800
801
        return $result;
802
    }
803
804
    /**
805
     * Render the given fragments into a security page controller with the given title.
806
     * @param string $title string The title to give the security page
807
     * @param array $fragments A map of objects to render into the page, e.g. "Form"
808
     * @param array $templates An array of templates to use for the render
809
     * @return HTTPResponse|DBHTMLText
810
     */
811
    protected function renderWrappedController($title, array $fragments, array $templates)
812
    {
813
        $controller = $this->getResponseController($title);
814
815
        // if the controller calls Director::redirect(), this will break early
816
        if (($response = $controller->getResponse()) && $response->isFinished()) {
817
            return $response;
818
        }
819
820
        // Handle any form messages from validation, etc.
821
        $messageType = '';
822
        $message = $this->getLoginMessage($messageType);
823
824
        // We've displayed the message in the form output, so reset it for the next run.
825
        static::clearLoginMessage();
826
827
        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...
828
            $messageResult = [
829
                'Content'     => DBField::create_field('HTMLFragment', $message),
830
                'Message'     => DBField::create_field('HTMLFragment', $message),
831
                'MessageType' => $messageType
832
            ];
833
            $fragments = array_merge($fragments, $messageResult);
834
        }
835
836
        return $controller->customise($fragments)->renderWith($templates);
837
    }
838
839
    public function basicauthlogin()
840
    {
841
        $member = BasicAuth::requireLogin($this->getRequest(), 'SilverStripe login', 'ADMIN');
842
        static::setCurrentUser($member);
0 ignored issues
show
Bug introduced by
It seems like $member defined by \SilverStripe\Security\B...Stripe login', 'ADMIN') on line 841 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...
843
    }
844
845
    /**
846
     * Show the "lost password" page
847
     *
848
     * @return string Returns the "lost password" page as HTML code.
849
     */
850
    public function lostpassword()
851
    {
852
        $handlers = [];
853
        $authenticators = $this->getApplicableAuthenticators(Authenticator::RESET_PASSWORD);
854
        /** @var Authenticator $authenticator */
855
        foreach ($authenticators as $authenticator) {
856
            $handlers[] = $authenticator->getLostPasswordHandler(
857
                Controller::join_links($this->Link(), 'lostpassword')
858
            );
859
        }
860
861
        return $this->delegateToMultipleHandlers(
862
            $handlers,
863
            _t('SilverStripe\\Security\\Security.LOSTPASSWORDHEADER', 'Lost Password'),
864
            $this->getTemplatesFor('lostpassword')
865
        );
866
    }
867
868
    /**
869
     * Show the "change password" page.
870
     * This page can either be called directly by logged-in users
871
     * (in which case they need to provide their old password),
872
     * or through a link emailed through {@link lostpassword()}.
873
     * In this case no old password is required, authentication is ensured
874
     * through the Member.AutoLoginHash property.
875
     *
876
     * @see ChangePasswordForm
877
     *
878
     * @return string|HTTPRequest Returns the "change password" page as HTML code, or a redirect response
879
     */
880
    public function changepassword()
881
    {
882
        /** @var array|Authenticator[] $authenticators */
883
        $authenticators = $this->getApplicableAuthenticators(Authenticator::CHANGE_PASSWORD);
884
        $handlers = [];
885
        foreach ($authenticators as $authenticator) {
886
            $handlers[] = $authenticator->getChangePasswordHandler($this->Link('changepassword'));
887
        }
888
889
        return $this->delegateToMultipleHandlers(
890
            $handlers,
891
            _t('SilverStripe\\Security\\Security.CHANGEPASSWORDHEADER', 'Change your password'),
892
            $this->getTemplatesFor('changepassword')
893
        );
894
    }
895
896
    /**
897
     * Create a link to the password reset form.
898
     *
899
     * GET parameters used:
900
     * - m: member ID
901
     * - t: plaintext token
902
     *
903
     * @param Member $member Member object associated with this link.
904
     * @param string $autologinToken The auto login token.
905
     * @return string
906
     */
907
    public static function getPasswordResetLink($member, $autologinToken)
908
    {
909
        $autologinToken = urldecode($autologinToken);
910
911
        return static::singleton()->Link('changepassword') . "?m={$member->ID}&t=$autologinToken";
912
    }
913
914
    /**
915
     * Determine the list of templates to use for rendering the given action.
916
     *
917
     * @skipUpgrade
918
     * @param string $action
919
     * @return array Template list
920
     */
921
    public function getTemplatesFor($action)
922
    {
923
        $templates = SSViewer::get_templates_by_class(static::class, "_{$action}", __CLASS__);
924
925
        return array_merge(
926
            $templates,
927
            [
928
                "Security_{$action}",
929
                "Security",
930
                $this->stat("template_main"),
931
                "BlankPage"
932
            ]
933
        );
934
    }
935
936
    /**
937
     * Return an existing member with administrator privileges, or create one of necessary.
938
     *
939
     * Will create a default 'Administrators' group if no group is found
940
     * with an ADMIN permission. Will create a new 'Admin' member with administrative permissions
941
     * if no existing Member with these permissions is found.
942
     *
943
     * Important: Any newly created administrator accounts will NOT have valid
944
     * login credentials (Email/Password properties), which means they can't be used for login
945
     * purposes outside of any default credentials set through {@link Security::setDefaultAdmin()}.
946
     *
947
     * @return Member
948
     *
949
     * @deprecated 4.0.0..5.0.0 Please use DefaultAdminService::findOrCreateDefaultAdmin()
950
     */
951
    public static function findAnAdministrator()
952
    {
953
        Deprecation::notice('5.0.0', 'Please use DefaultAdminService::findOrCreateDefaultAdmin()');
954
955
        $service = DefaultAdminService::singleton();
956
        return $service->findOrCreateDefaultAdmin();
957
    }
958
959
    /**
960
     * Flush the default admin credentials
961
     *
962
     * @deprecated 4.0.0..5.0.0 Please use DefaultAdminService::clearDefaultAdmin()
963
     */
964
    public static function clear_default_admin()
965
    {
966
        Deprecation::notice('5.0.0', 'Please use DefaultAdminService::clearDefaultAdmin()');
967
968
        DefaultAdminService::clearDefaultAdmin();
969
    }
970
971
972
    /**
973
     * Set a default admin in dev-mode
974
     *
975
     * This will set a static default-admin which is not existing
976
     * as a database-record. By this workaround we can test pages in dev-mode
977
     * with a unified login. Submitted login-credentials are first checked
978
     * against this static information in {@link Security::authenticate()}.
979
     *
980
     * @param string $username The user name
981
     * @param string $password The password (in cleartext)
982
     * @return bool True if successfully set
983
     *
984
     * @deprecated 4.0.0..5.0.0 Please use DefaultAdminService::setDefaultAdmin($username, $password)
985
     */
986
    public static function setDefaultAdmin($username, $password)
987
    {
988
        Deprecation::notice('5.0.0', 'Please use DefaultAdminService::setDefaultAdmin($username, $password)');
989
990
        DefaultAdminService::setDefaultAdmin($username, $password);
991
        return true;
992
    }
993
994
    /**
995
     * Checks if the passed credentials are matching the default-admin.
996
     * Compares cleartext-password set through Security::setDefaultAdmin().
997
     *
998
     * @param string $username
999
     * @param string $password
1000
     * @return bool
1001
     *
1002
     * @deprecated 4.0.0..5.0.0 Use DefaultAdminService::isDefaultAdminCredentials() instead
1003
     */
1004
    public static function check_default_admin($username, $password)
1005
    {
1006
        Deprecation::notice('5.0.0', 'Please use DefaultAdminService::isDefaultAdminCredentials($username, $password)');
1007
1008
        /** @var DefaultAdminService $service */
1009
        return DefaultAdminService::isDefaultAdminCredentials($username, $password);
1010
    }
1011
1012
    /**
1013
     * Check that the default admin account has been set.
1014
     *
1015
     * @deprecated 4.0.0..5.0.0 Use DefaultAdminService::hasDefaultAdmin() instead
1016
     */
1017
    public static function has_default_admin()
1018
    {
1019
        Deprecation::notice('5.0.0', 'Please use DefaultAdminService::hasDefaultAdmin()');
1020
1021
        return DefaultAdminService::hasDefaultAdmin();
1022
    }
1023
1024
    /**
1025
     * Get default admin username
1026
     *
1027
     * @deprecated 4.0.0..5.0.0 Use DefaultAdminService::getDefaultAdminUsername()
1028
     * @return string
1029
     */
1030
    public static function default_admin_username()
1031
    {
1032
        Deprecation::notice('5.0.0', 'Please use DefaultAdminService::getDefaultAdminUsername()');
1033
1034
        return DefaultAdminService::getDefaultAdminUsername();
1035
    }
1036
1037
    /**
1038
     * Get default admin password
1039
     *
1040
     * @deprecated 4.0.0..5.0.0 Use DefaultAdminService::getDefaultAdminPassword()
1041
     * @return string
1042
     */
1043
    public static function default_admin_password()
1044
    {
1045
        Deprecation::notice('5.0.0', 'Please use DefaultAdminService::getDefaultAdminPassword()');
1046
1047
        return DefaultAdminService::getDefaultAdminPassword();
1048
    }
1049
1050
    /**
1051
     * Encrypt a password according to the current password encryption settings.
1052
     * If the settings are so that passwords shouldn't be encrypted, the
1053
     * result is simple the clear text password with an empty salt except when
1054
     * a custom algorithm ($algorithm parameter) was passed.
1055
     *
1056
     * @param string $password The password to encrypt
1057
     * @param string $salt Optional: The salt to use. If it is not passed, but
1058
     *  needed, the method will automatically create a
1059
     *  random salt that will then be returned as return value.
1060
     * @param string $algorithm Optional: Use another algorithm to encrypt the
1061
     *  password (so that the encryption algorithm can be changed over the time).
1062
     * @param Member $member Optional
1063
     * @return mixed Returns an associative array containing the encrypted
1064
     *  password and the used salt in the form:
1065
     * <code>
1066
     *  array(
1067
     *  'password' => string,
1068
     *  'salt' => string,
1069
     *  'algorithm' => string,
1070
     *  'encryptor' => PasswordEncryptor instance
1071
     *  )
1072
     * </code>
1073
     * If the passed algorithm is invalid, FALSE will be returned.
1074
     *
1075
     * @see encrypt_passwords()
1076
     */
1077
    public static function encrypt_password($password, $salt = null, $algorithm = null, $member = null)
1078
    {
1079
        // Fall back to the default encryption algorithm
1080
        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...
1081
            $algorithm = self::config()->get('password_encryption_algorithm');
1082
        }
1083
1084
        $encryptor = PasswordEncryptor::create_for_algorithm($algorithm);
1085
1086
        // New salts will only need to be generated if the password is hashed for the first time
1087
        $salt = ($salt) ? $salt : $encryptor->salt($password);
1088
1089
        return array(
1090
            'password'  => $encryptor->encrypt($password, $salt, $member),
1091
            'salt'      => $salt,
1092
            'algorithm' => $algorithm,
1093
            'encryptor' => $encryptor
1094
        );
1095
    }
1096
1097
    /**
1098
     * Checks the database is in a state to perform security checks.
1099
     * See {@link DatabaseAdmin->init()} for more information.
1100
     *
1101
     * @return bool
1102
     */
1103
    public static function database_is_ready()
1104
    {
1105
        // Used for unit tests
1106
        if (self::$force_database_is_ready !== null) {
1107
            return self::$force_database_is_ready;
1108
        }
1109
1110
        if (self::$database_is_ready) {
1111
            return self::$database_is_ready;
1112
        }
1113
1114
        $requiredClasses = ClassInfo::dataClassesFor(Member::class);
1115
        $requiredClasses[] = Group::class;
1116
        $requiredClasses[] = Permission::class;
1117
        $schema = DataObject::getSchema();
1118
        foreach ($requiredClasses as $class) {
1119
            // Skip test classes, as not all test classes are scaffolded at once
1120
            if (is_a($class, TestOnly::class, true)) {
1121
                continue;
1122
            }
1123
1124
            // if any of the tables aren't created in the database
1125
            $table = $schema->tableName($class);
1126
            if (!ClassInfo::hasTable($table)) {
1127
                return false;
1128
            }
1129
1130
            // HACK: DataExtensions aren't applied until a class is instantiated for
1131
            // the first time, so create an instance here.
1132
            singleton($class);
1133
1134
            // if any of the tables don't have all fields mapped as table columns
1135
            $dbFields = DB::field_list($table);
1136
            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...
1137
                return false;
1138
            }
1139
1140
            $objFields = $schema->databaseFields($class, false);
1141
            $missingFields = array_diff_key($objFields, $dbFields);
1142
1143
            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...
1144
                return false;
1145
            }
1146
        }
1147
        self::$database_is_ready = true;
1148
1149
        return true;
1150
    }
1151
1152
    /**
1153
     * Resets the database_is_ready cache
1154
     */
1155
    public static function clear_database_is_ready()
1156
    {
1157
        self::$database_is_ready = null;
1158
        self::$force_database_is_ready = null;
1159
    }
1160
1161
    /**
1162
     * For the database_is_ready call to return a certain value - used for testing
1163
     *
1164
     * @param bool $isReady
1165
     */
1166
    public static function force_database_is_ready($isReady)
1167
    {
1168
        self::$force_database_is_ready = $isReady;
1169
    }
1170
1171
    /**
1172
     * @config
1173
     * @var string Set the default login dest
1174
     * This is the URL that users will be redirected to after they log in,
1175
     * if they haven't logged in en route to access a secured page.
1176
     * By default, this is set to the homepage.
1177
     */
1178
    private static $default_login_dest = "";
1179
1180
    protected static $ignore_disallowed_actions = false;
1181
1182
    /**
1183
     * Set to true to ignore access to disallowed actions, rather than returning permission failure
1184
     * Note that this is just a flag that other code needs to check with Security::ignore_disallowed_actions()
1185
     * @param bool $flag True or false
1186
     */
1187
    public static function set_ignore_disallowed_actions($flag)
1188
    {
1189
        self::$ignore_disallowed_actions = $flag;
1190
    }
1191
1192
    public static function ignore_disallowed_actions()
1193
    {
1194
        return self::$ignore_disallowed_actions;
1195
    }
1196
1197
    /**
1198
     * Get the URL of the log-in page.
1199
     *
1200
     * To update the login url use the "Security.login_url" config setting.
1201
     *
1202
     * @return string
1203
     */
1204
    public static function login_url()
1205
    {
1206
        return Controller::join_links(Director::baseURL(), self::config()->get('login_url'));
1207
    }
1208
1209
1210
    /**
1211
     * Get the URL of the logout page.
1212
     *
1213
     * To update the logout url use the "Security.logout_url" config setting.
1214
     *
1215
     * @return string
1216
     */
1217
    public static function logout_url()
1218
    {
1219
        return Controller::join_links(Director::baseURL(), self::config()->get('logout_url'));
1220
    }
1221
1222
    /**
1223
     * Get the URL of the logout page.
1224
     *
1225
     * To update the logout url use the "Security.logout_url" config setting.
1226
     *
1227
     * @return string
1228
     */
1229
    public static function lost_password_url()
1230
    {
1231
        return Controller::join_links(Director::baseURL(), self::config()->get('lost_password_url'));
1232
    }
1233
1234
    /**
1235
     * Defines global accessible templates variables.
1236
     *
1237
     * @return array
1238
     */
1239
    public static function get_template_global_variables()
1240
    {
1241
        return array(
1242
            "LoginURL"        => "login_url",
1243
            "LogoutURL"       => "logout_url",
1244
            "LostPasswordURL" => "lost_password_url",
1245
            "CurrentMember"   => "getCurrentUser",
1246
            "currentUser"     => "getCurrentUser"
1247
        );
1248
    }
1249
}
1250