Completed
Pull Request — master (#7028)
by Loz
12:53
created

Security::getSessionMessage()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 16
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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