Completed
Push — authenticator-refactor ( 0c2983...495926 )
by Simon
06:32
created

Security::getResponseController()   B

Complexity

Conditions 3
Paths 2

Size

Total Lines 25
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

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

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
532
                $this->extend('failureMemberLoggedOut', $authenticator);
533
534
                return $this->redirectBack();
535
            }
536
            $this->extend('successMemberLoggedOut', $authenticator);
537
            // Member is successfully logged out. Write possible changes to the database.
538
            $member->write();
539
        }
540
        $this->extend('afterMemberLoggedOut');
541
542
        if ($redirect && (!$this->getResponse()->isFinished())) {
543
            return $this->redirectBack();
544
        }
545
546
        return null;
547
    }
548
549
    /**
550
     * Perform pre-login checking and prepare a response if available prior to login
551
     *
552
     * @return HTTPResponse Substitute response object if the login process should be curcumvented.
553
     * Returns null if should proceed as normal.
554
     */
555
    protected function preLogin()
556
    {
557
        // Event handler for pre-login, with an option to let it break you out of the login form
558
        $eventResults = $this->extend('onBeforeSecurityLogin');
559
        // If there was a redirection, return
560
        if ($this->redirectedTo()) {
561
            return $this->getResponse();
562
        }
563
        // If there was an HTTPResponse object returned, then return that
564
        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...
565
            foreach ($eventResults as $result) {
566
                if ($result instanceof HTTPResponse) {
567
                    return $result;
568
                }
569
            }
570
        }
571
572
        // If arriving on the login page already logged in, with no security error, and a ReturnURL then redirect
573
        // back. The login message check is neccesary to prevent infinite loops where BackURL links to
574
        // an action that triggers Security::permissionFailure.
575
        // This step is necessary in cases such as automatic redirection where a user is authenticated
576
        // upon landing on an SSL secured site and is automatically logged in, or some other case
577
        // where the user has permissions to continue but is not given the option.
578
        if ($this->getRequest()->requestVar('BackURL')
579
            && !$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...
580
            && ($member = static::getCurrentUser())
581
            && $member->exists()
582
        ) {
583
            return $this->redirectBack();
584
        }
585
586
        return null;
587
    }
588
589
    /**
590
     * Prepare the controller for handling the response to this request
591
     *
592
     * @param string $title Title to use
593
     * @return Controller
594
     */
595
    protected function getResponseController($title)
596
    {
597
        // Use the default setting for which Page to use to render the security page
598
        $pageClass = $this->stat('page_class');
599
        if (!$pageClass || !class_exists($pageClass)) {
600
            return $this;
601
        }
602
603
        // Create new instance of page holder
604
        /** @var Page $holderPage */
605
        $holderPage = new $pageClass;
606
        $holderPage->Title = $title;
607
        /** @skipUpgrade */
608
        $holderPage->URLSegment = 'Security';
609
        // Disable ID-based caching  of the log-in page by making it a random number
610
        $holderPage->ID = -1 * random_int(1, 10000000);
611
612
        $controllerClass = $holderPage->getControllerName();
613
        /** @var ContentController $controller */
614
        $controller = $controllerClass::create($holderPage);
615
        $controller->setDataModel($this->model);
616
        $controller->doInit();
617
618
        return $controller;
619
    }
620
621
    /**
622
     * Combine the given forms into a formset with a tabbed interface
623
     *
624
     * @param $forms
625
     * @return string
626
     */
627
    protected function generateLoginFormSet($forms)
628
    {
629
        $viewData = new ArrayData(array(
630
            'Forms' => new ArrayList($forms),
631
        ));
632
633
        return $viewData->renderWith(
634
            $this->getTemplatesFor('MultiAuthenticatorLogin')
635
        );
636
    }
637
638
    /**
639
     * Get the HTML Content for the $Content area during login
640
     *
641
     * @param string &$messageType Type of message, if available, passed back to caller
642
     * @return string Message in HTML format
643
     */
644
    protected function getLoginMessage(&$messageType = null)
645
    {
646
        $message = Session::get('Security.Message.message');
647
        $messageType = null;
648
        if (empty($message)) {
649
            return null;
650
        }
651
652
        $messageType = Session::get('Security.Message.type');
653
        $messageCast = Session::get('Security.Message.cast');
654
        if ($messageCast !== ValidationResult::CAST_HTML) {
655
            $message = Convert::raw2xml($message);
656
        }
657
658
        return sprintf('<p class="message %s">%s</p>', Convert::raw2att($messageType), $message);
659
    }
660
661
    /**
662
     * Set the next message to display for the security login page. Defaults to warning
663
     *
664
     * @param string $message Message
665
     * @param string $messageType Message type. One of ValidationResult::TYPE_*
666
     * @param string $messageCast Message cast. One of ValidationResult::CAST_*
667
     */
668
    public function setLoginMessage(
669
        $message,
670
        $messageType = ValidationResult::TYPE_WARNING,
671
        $messageCast = ValidationResult::CAST_TEXT
672
    ) {
673
        Session::set("Security.Message.message", $message);
674
        Session::set("Security.Message.type", $messageType);
675
        Session::set("Security.Message.cast", $messageCast);
676
    }
677
678
    /**
679
     * Clear login message
680
     */
681
    public static function clearLoginMessage()
682
    {
683
        Session::clear("Security.Message");
684
    }
685
686
687
    /**
688
     * Show the "login" page
689
     *
690
     * For multiple authenticators, Security_MultiAuthenticatorLogin is used.
691
     * See getTemplatesFor and getIncludeTemplate for how to override template logic
692
     *
693
     * @param $request
694
     * @param int $service
695
     * @return HTTPResponse|string Returns the "login" page as HTML code.
696
     * @throws HTTPResponse_Exception
697
     */
698
    public function login($request = null, $service = Authenticator::LOGIN)
699
    {
700
        // Check pre-login process
701
        if ($response = $this->preLogin()) {
702
            return $response;
703
        }
704
        if (!$request) {
705
            $request = $this->getRequest();
706
        }
707
        $authName = ($request && $request->param('ID')) ? $request->param('ID') : static::$default_authenticator;
708
709
        $link = $this->Link('login');
710
711
        // Delegate to a single handler - Security/login/<authname>/...
712
        if ($authName && self::hasAuthenticator($authName)) {
713
            if ($request) {
714
                $request->shift();
715
            }
716
717
            $authenticator = $this->getAuthenticator($authName);
718
            // @todo handle different Authenticator situations
719
            if (!$authenticator->supportedServices() & $service) {
720
                throw new HTTPResponse_Exception('Invalid Authenticator "' . $authName . '" for login action', 418);
721
            }
722
723
            $handlers = [$authName => $authenticator];
724
725
            // Delegate to all of them, building a tabbed view - Security/login
726
        } else {
727
            $handlers = $this->getApplicableAuthenticators($service);
728
        }
729
730
        array_walk(
731
            $handlers,
732
            function (&$auth, $name) use ($link) {
733
                $auth = $auth->getLoginHandler(Controller::join_links($link, $name));
734
            }
735
        );
736
737
        return $this->delegateToMultipleHandlers(
738
            $handlers,
739
            _t('Security.LOGIN', 'Log in'),
740
            $this->getTemplatesFor('login')
741
        );
742
    }
743
744
    /**
745
     * Delegate to an number of handlers, extracting their forms and rendering a tabbed form-set.
746
     * This is used to built the log-in page where there are multiple authenticators active.
747
     *
748
     * If a single handler is passed, delegateToHandler() will be called instead
749
     *
750
     * @param array|RequestHandler[] $handlers
751
     * @param string $title The title of the form
752
     * @param array $templates
753
     * @return array|HTTPResponse|RequestHandler|DBHTMLText|string
754
     */
755
    protected function delegateToMultipleHandlers(array $handlers, $title, array $templates)
756
    {
757
758
        // Simpler case for a single authenticator
759
        if (count($handlers) === 1) {
760
            return $this->delegateToHandler(array_values($handlers)[0], $title, $templates);
761
        }
762
763
        // Process each of the handlers
764
        $results = array_map(
765
            function ($handler) {
766
                return $handler->handleRequest($this->getRequest(), DataModel::inst());
767
            },
768
            $handlers
769
        );
770
771
        // Aggregate all their forms, assuming they all return
772
        $forms = [];
773
        foreach ($results as $authName => $singleResult) {
774
            // The result *must* be an array with a Form key
775
            if (!is_array($singleResult) || !isset($singleResult['Form'])) {
776
                user_error('Authenticator "' . $authName . '" doesn\'t support a tabbed login', E_USER_WARNING);
777
                continue;
778
            }
779
780
            $forms[] = $singleResult['Form'];
781
        }
782
783
        if (!$forms) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $forms of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

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

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

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

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

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

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

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

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

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

    return array();
}

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

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

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

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

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

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

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

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

Loading history...
895
    {
896
        /** @var array|Authenticator[] $authenticators */
897
        $authenticators = $this->getApplicableAuthenticators(Authenticator::CHANGE_PASSWORD);
898
        $handlers = [];
899
        foreach ($authenticators as $authenticator) {
900
            $handlers[] = $authenticator->getChangePasswordHandler($this->Link('changepassword'));
901
        }
902
903
        return $this->delegateToMultipleHandlers(
904
            $handlers,
905
            _t('SilverStripe\\Security\\Security.CHANGEPASSWORDHEADER', 'Change your password'),
906
            $this->getTemplatesFor('changepassword')
907
        );
908
    }
909
910
    /**
911
     * Create a link to the password reset form.
912
     *
913
     * GET parameters used:
914
     * - m: member ID
915
     * - t: plaintext token
916
     *
917
     * @param Member $member Member object associated with this link.
918
     * @param string $autologinToken The auto login token.
919
     * @return string
920
     */
921
    public static function getPasswordResetLink($member, $autologinToken)
922
    {
923
        $autologinToken = urldecode($autologinToken);
924
925
        return static::singleton()->Link('changepassword') . "?m={$member->ID}&t=$autologinToken";
926
    }
927
928
    /**
929
     * Determine the list of templates to use for rendering the given action.
930
     *
931
     * @skipUpgrade
932
     * @param string $action
933
     * @return array Template list
934
     */
935
    public function getTemplatesFor($action)
936
    {
937
        $templates = SSViewer::get_templates_by_class(static::class, "_{$action}", __CLASS__);
938
939
        return array_merge(
940
            $templates,
941
            [
942
                "Security_{$action}",
943
                "Security",
944
                $this->stat("template_main"),
945
                "BlankPage"
946
            ]
947
        );
948
    }
949
950
    /**
951
     * Return an existing member with administrator privileges, or create one of necessary.
952
     *
953
     * Will create a default 'Administrators' group if no group is found
954
     * with an ADMIN permission. Will create a new 'Admin' member with administrative permissions
955
     * if no existing Member with these permissions is found.
956
     *
957
     * Important: Any newly created administrator accounts will NOT have valid
958
     * login credentials (Email/Password properties), which means they can't be used for login
959
     * purposes outside of any default credentials set through {@link Security::setDefaultAdmin()}.
960
     *
961
     * @return Member
962
     */
963
    public static function findAnAdministrator()
964
    {
965
        // coupling to subsites module
966
        $origSubsite = null;
967
        if (is_callable('Subsite::changeSubsite')) {
968
            $origSubsite = Subsite::currentSubsiteID();
969
            Subsite::changeSubsite(0);
970
        }
971
972
        /** @var Member $member */
973
        $member = null;
974
975
        // find a group with ADMIN permission
976
        $adminGroup = Permission::get_groups_by_permission('ADMIN')->first();
977
978
        // @todo uncouple subsites
979
        if (is_callable('Subsite::changeSubsite')) {
980
            Subsite::changeSubsite($origSubsite);
981
        }
982
983
        if ($adminGroup) {
984
            $member = $adminGroup->Members()->First();
985
        }
986
987
        if (!$adminGroup) {
988
            Group::singleton()->requireDefaultRecords();
989
            $adminGroup = Permission::get_groups_by_permission('ADMIN')->first();
990
        }
991
992
        if (!$member) {
993
            Member::singleton()->requireDefaultRecords();
994
            $member = Permission::get_members_by_permission('ADMIN')->first();
995
        }
996
997
        if (!$member) {
998
            $member = Member::default_admin();
999
        }
1000
1001
        if (!$member) {
1002
            // Failover to a blank admin
1003
            $member = Member::create();
1004
            $member->FirstName = _t('SilverStripe\\Security\\Member.DefaultAdminFirstname', 'Default Admin');
1005
            $member->write();
1006
            // Add member to group instead of adding group to member
1007
            // This bypasses the privilege escallation code in Member_GroupSet
1008
            $adminGroup
1009
                ->DirectMembers()
1010
                ->add($member);
1011
        }
1012
1013
        return $member;
1014
    }
1015
1016
    /**
1017
     * Flush the default admin credentials
1018
     */
1019
    public static function clear_default_admin()
1020
    {
1021
        self::$default_username = null;
1022
        self::$default_password = null;
1023
    }
1024
1025
1026
    /**
1027
     * Set a default admin in dev-mode
1028
     *
1029
     * This will set a static default-admin which is not existing
1030
     * as a database-record. By this workaround we can test pages in dev-mode
1031
     * with a unified login. Submitted login-credentials are first checked
1032
     * against this static information in {@link Security::authenticate()}.
1033
     *
1034
     * @param string $username The user name
1035
     * @param string $password The password (in cleartext)
1036
     * @return bool True if successfully set
1037
     */
1038
    public static function setDefaultAdmin($username, $password)
1039
    {
1040
        // don't overwrite if already set
1041
        if (self::$default_username || self::$default_password) {
1042
            return false;
1043
        }
1044
1045
        self::$default_username = $username;
1046
        self::$default_password = $password;
1047
1048
        return true;
1049
    }
1050
1051
    /**
1052
     * Checks if the passed credentials are matching the default-admin.
1053
     * Compares cleartext-password set through Security::setDefaultAdmin().
1054
     *
1055
     * @param string $username
1056
     * @param string $password
1057
     * @return bool
1058
     */
1059
    public static function check_default_admin($username, $password)
1060
    {
1061
        return (
1062
            self::$default_username === $username
1063
            && self::$default_password === $password
1064
            && self::has_default_admin()
1065
        );
1066
    }
1067
1068
    /**
1069
     * Check that the default admin account has been set.
1070
     */
1071
    public static function has_default_admin()
1072
    {
1073
        return !empty(self::$default_username) && !empty(self::$default_password);
1074
    }
1075
1076
    /**
1077
     * Get default admin username
1078
     *
1079
     * @return string
1080
     */
1081
    public static function default_admin_username()
1082
    {
1083
        return self::$default_username;
1084
    }
1085
1086
    /**
1087
     * Get default admin password
1088
     *
1089
     * @return string
1090
     */
1091
    public static function default_admin_password()
1092
    {
1093
        return self::$default_password;
1094
    }
1095
1096
    /**
1097
     * Encrypt a password according to the current password encryption settings.
1098
     * If the settings are so that passwords shouldn't be encrypted, the
1099
     * result is simple the clear text password with an empty salt except when
1100
     * a custom algorithm ($algorithm parameter) was passed.
1101
     *
1102
     * @param string $password The password to encrypt
1103
     * @param string $salt Optional: The salt to use. If it is not passed, but
1104
     *  needed, the method will automatically create a
1105
     *  random salt that will then be returned as return value.
1106
     * @param string $algorithm Optional: Use another algorithm to encrypt the
1107
     *  password (so that the encryption algorithm can be changed over the time).
1108
     * @param Member $member Optional
1109
     * @return mixed Returns an associative array containing the encrypted
1110
     *  password and the used salt in the form:
1111
     * <code>
1112
     *  array(
1113
     *  'password' => string,
1114
     *  'salt' => string,
1115
     *  'algorithm' => string,
1116
     *  'encryptor' => PasswordEncryptor instance
1117
     *  )
1118
     * </code>
1119
     * If the passed algorithm is invalid, FALSE will be returned.
1120
     *
1121
     * @see encrypt_passwords()
1122
     */
1123
    public static function encrypt_password($password, $salt = null, $algorithm = null, $member = null)
1124
    {
1125
        // Fall back to the default encryption algorithm
1126
        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...
1127
            $algorithm = self::config()->get('password_encryption_algorithm');
1128
        }
1129
1130
        $e = PasswordEncryptor::create_for_algorithm($algorithm);
1131
1132
        // New salts will only need to be generated if the password is hashed for the first time
1133
        $salt = ($salt) ? $salt : $e->salt($password);
1134
1135
        return array(
1136
            'password'  => $e->encrypt($password, $salt, $member),
1137
            'salt'      => $salt,
1138
            'algorithm' => $algorithm,
1139
            'encryptor' => $e
1140
        );
1141
    }
1142
1143
    /**
1144
     * Checks the database is in a state to perform security checks.
1145
     * See {@link DatabaseAdmin->init()} for more information.
1146
     *
1147
     * @return bool
1148
     */
1149
    public static function database_is_ready()
1150
    {
1151
        // Used for unit tests
1152
        if (self::$force_database_is_ready !== null) {
1153
            return self::$force_database_is_ready;
1154
        }
1155
1156
        if (self::$database_is_ready) {
1157
            return self::$database_is_ready;
1158
        }
1159
1160
        $requiredClasses = ClassInfo::dataClassesFor(Member::class);
1161
        $requiredClasses[] = Group::class;
1162
        $requiredClasses[] = Permission::class;
1163
        $schema = DataObject::getSchema();
1164
        foreach ($requiredClasses as $class) {
1165
            // Skip test classes, as not all test classes are scaffolded at once
1166
            if (is_a($class, TestOnly::class, true)) {
1167
                continue;
1168
            }
1169
1170
            // if any of the tables aren't created in the database
1171
            $table = $schema->tableName($class);
1172
            if (!ClassInfo::hasTable($table)) {
1173
                return false;
1174
            }
1175
1176
            // HACK: DataExtensions aren't applied until a class is instantiated for
1177
            // the first time, so create an instance here.
1178
            singleton($class);
1179
1180
            // if any of the tables don't have all fields mapped as table columns
1181
            $dbFields = DB::field_list($table);
1182
            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...
1183
                return false;
1184
            }
1185
1186
            $objFields = $schema->databaseFields($class, false);
1187
            $missingFields = array_diff_key($objFields, $dbFields);
1188
1189
            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...
1190
                return false;
1191
            }
1192
        }
1193
        self::$database_is_ready = true;
1194
1195
        return true;
1196
    }
1197
1198
    /**
1199
     * Resets the database_is_ready cache
1200
     */
1201
    public static function clear_database_is_ready()
1202
    {
1203
        self::$database_is_ready = null;
1204
        self::$force_database_is_ready = null;
1205
    }
1206
1207
    /**
1208
     * For the database_is_ready call to return a certain value - used for testing
1209
     */
1210
    public static function force_database_is_ready($isReady)
1211
    {
1212
        self::$force_database_is_ready = $isReady;
1213
    }
1214
1215
    /**
1216
     * @config
1217
     * @var string Set the default login dest
1218
     * This is the URL that users will be redirected to after they log in,
1219
     * if they haven't logged in en route to access a secured page.
1220
     * By default, this is set to the homepage.
1221
     */
1222
    private static $default_login_dest = "";
1223
1224
    protected static $ignore_disallowed_actions = false;
1225
1226
    /**
1227
     * Set to true to ignore access to disallowed actions, rather than returning permission failure
1228
     * Note that this is just a flag that other code needs to check with Security::ignore_disallowed_actions()
1229
     * @param $flag True or false
1230
     */
1231
    public static function set_ignore_disallowed_actions($flag)
1232
    {
1233
        self::$ignore_disallowed_actions = $flag;
1234
    }
1235
1236
    public static function ignore_disallowed_actions()
1237
    {
1238
        return self::$ignore_disallowed_actions;
1239
    }
1240
1241
    /**
1242
     * Get the URL of the log-in page.
1243
     *
1244
     * To update the login url use the "Security.login_url" config setting.
1245
     *
1246
     * @return string
1247
     */
1248
    public static function login_url()
1249
    {
1250
        return Controller::join_links(Director::baseURL(), self::config()->get('login_url'));
1251
    }
1252
1253
1254
    /**
1255
     * Get the URL of the logout page.
1256
     *
1257
     * To update the logout url use the "Security.logout_url" config setting.
1258
     *
1259
     * @return string
1260
     */
1261
    public static function logout_url()
1262
    {
1263
        return Controller::join_links(Director::baseURL(), self::config()->get('logout_url'));
1264
    }
1265
1266
    /**
1267
     * Get the URL of the logout page.
1268
     *
1269
     * To update the logout url use the "Security.logout_url" config setting.
1270
     *
1271
     * @return string
1272
     */
1273
    public static function lost_password_url()
1274
    {
1275
        return Controller::join_links(Director::baseURL(), self::config()->get('lost_password_url'));
1276
    }
1277
1278
    /**
1279
     * Defines global accessible templates variables.
1280
     *
1281
     * @return array
1282
     */
1283
    public static function get_template_global_variables()
1284
    {
1285
        return array(
1286
            "LoginURL"        => "login_url",
1287
            "LogoutURL"       => "logout_url",
1288
            "LostPasswordURL" => "lost_password_url",
1289
            "CurrentMember"   => "getCurrentUser",
1290
            "currentUser"     => "getCurrentUser"
1291
        );
1292
    }
1293
}
1294