Completed
Pull Request — master (#7007)
by Simon
08:19
created

Security::findAnAdministrator()   B

Complexity

Conditions 5
Paths 16

Size

Total Lines 42
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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

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

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