Completed
Push — authenticator-refactor ( 82d769...121803 )
by Sam
17:22
created

Security::getResponseController()   B

Complexity

Conditions 3
Paths 2

Size

Total Lines 24
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 13
nc 2
nop 1
dl 0
loc 24
rs 8.9713
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\Config\Config;
17
use SilverStripe\Core\Convert;
18
use SilverStripe\Dev\Deprecation;
19
use SilverStripe\Dev\TestOnly;
20
use SilverStripe\Forms\EmailField;
21
use SilverStripe\Forms\FieldList;
22
use SilverStripe\Forms\Form;
23
use SilverStripe\Forms\FormAction;
24
use SilverStripe\ORM\ArrayList;
25
use SilverStripe\ORM\DataModel;
26
use SilverStripe\ORM\DB;
27
use SilverStripe\ORM\DataObject;
28
use SilverStripe\ORM\FieldType\DBField;
29
use SilverStripe\ORM\ValidationResult;
30
use SilverStripe\View\ArrayData;
31
use SilverStripe\View\SSViewer;
32
use SilverStripe\View\TemplateGlobalProvider;
33
use Exception;
34
use SilverStripe\View\ViewableData_Customised;
35
use Subsite;
36
use SilverStripe\Core\Injector\Injector;
37
38
/**
39
 * Implements a basic security model
40
 */
41
class Security extends Controller implements TemplateGlobalProvider
42
{
43
44
    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...
45
        'index',
46
        'login',
47
        'logout',
48
        'basicauthlogin',
49
        'lostpassword',
50
        'passwordsent',
51
        'changepassword',
52
        'ping',
53
        'LoginForm',
54
        'ChangePasswordForm',
55
        'LostPasswordForm',
56
    );
57
58
    /**
59
     * Default user name. Only used in dev-mode by {@link setDefaultAdmin()}
60
     *
61
     * @var string
62
     * @see setDefaultAdmin()
63
     */
64
    protected static $default_username;
65
66
    /**
67
     * Default password. Only used in dev-mode by {@link setDefaultAdmin()}
68
     *
69
     * @var string
70
     * @see setDefaultAdmin()
71
     */
72
    protected static $default_password;
73
74
    /**
75
     * If set to TRUE to prevent sharing of the session across several sites
76
     * in the domain.
77
     *
78
     * @config
79
     * @var bool
80
     */
81
    protected static $strict_path_checking = false;
82
83
    /**
84
     * The password encryption algorithm to use by default.
85
     * This is an arbitrary code registered through {@link PasswordEncryptor}.
86
     *
87
     * @config
88
     * @var string
89
     */
90
    private static $password_encryption_algorithm = 'blowfish';
91
92
    /**
93
     * Showing "Remember me"-checkbox
94
     * on loginform, and saving encrypted credentials to a cookie.
95
     *
96
     * @config
97
     * @var bool
98
     */
99
    private static $autologin_enabled = true;
100
101
    /**
102
     * Determine if login username may be remembered between login sessions
103
     * If set to false this will disable autocomplete and prevent username persisting in the session
104
     *
105
     * @config
106
     * @var bool
107
     */
108
    private static $remember_username = true;
109
110
    /**
111
     * Location of word list to use for generating passwords
112
     *
113
     * @config
114
     * @var string
115
     */
116
    private static $word_list = './wordlist.txt';
117
118
    /**
119
     * @config
120
     * @var string
121
     */
122
    private static $template = 'BlankPage';
123
124
    /**
125
     * Template thats used to render the pages.
126
     *
127
     * @var string
128
     * @config
129
     */
130
    private static $template_main = 'Page';
131
132
    /**
133
     * Class to use for page rendering
134
     *
135
     * @var string
136
     * @config
137
     */
138
    private static $page_class = Page::class;
139
140
    /**
141
     * Default message set used in permission failures.
142
     *
143
     * @config
144
     * @var array|string
145
     */
146
    private static $default_message_set;
147
148
    /**
149
     * Random secure token, can be used as a crypto key internally.
150
     * Generate one through 'sake dev/generatesecuretoken'.
151
     *
152
     * @config
153
     * @var String
154
     */
155
    private static $token;
156
157
    /**
158
     * The default login URL
159
     *
160
     * @config
161
     *
162
     * @var string
163
     */
164
    private static $login_url = "Security/login";
165
166
    /**
167
     * The default logout URL
168
     *
169
     * @config
170
     *
171
     * @var string
172
     */
173
    private static $logout_url = "Security/logout";
174
175
    /**
176
     * The default lost password URL
177
     *
178
     * @config
179
     *
180
     * @var string
181
     */
182
    private static $lost_password_url = "Security/lostpassword";
183
184
    /**
185
     * Value of X-Frame-Options header
186
     *
187
     * @config
188
     * @var string
189
     */
190
    private static $frame_options = 'SAMEORIGIN';
191
192
    /**
193
     * Value of the X-Robots-Tag header (for the Security section)
194
     *
195
     * @config
196
     * @var string
197
     */
198
    private static $robots_tag = 'noindex, nofollow';
199
200
    /**
201
     * Enable or disable recording of login attempts
202
     * through the {@link LoginRecord} object.
203
     *
204
     * @config
205
     * @var boolean $login_recording
206
     */
207
    private static $login_recording = false;
208
209
    /**
210
     * @var boolean If set to TRUE or FALSE, {@link database_is_ready()}
211
     * will always return FALSE. Used for unit testing.
212
     */
213
    protected static $force_database_is_ready = null;
214
215
    /**
216
     * When the database has once been verified as ready, it will not do the
217
     * checks again.
218
     *
219
     * @var bool
220
     */
221
    protected static $database_is_ready = false;
222
223
    protected static $authenticators = [];
224
225
    protected static $default_authenticator = MemberAuthenticator\Authenticator::class;
226
227
    /**
228
     * Get all registered authenticators
229
     *
230
     * @return array Return an array of Authenticator objects
231
     */
232
    public static function getAuthenticators()
233
    {
234
        $authenticators = self::config()->authenticators;
0 ignored issues
show
Documentation introduced by
The property authenticators does not exist on object<SilverStripe\Core\Config\Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
235
236
        return array_map(function ($class) {
237
            return Injector::inst()->get($class);
238
        }, $authenticators);
239
    }
240
241
    /**
242
     * Check if a given authenticator is registered
243
     *
244
     * @param string $authenticator The configured identifier of the authenicator
245
     * @return bool Returns TRUE if the authenticator is registered, FALSE
246
     *              otherwise.
247
     */
248
    public static function hasAuthenticator($authenticator)
249
    {
250
        $authenticators = self::config()->get('authenticators');
251
        return !empty($authenticators[$authenticator]);
252
    }
253
254
    /**
255
     * Register that we've had a permission failure trying to view the given page
256
     *
257
     * This will redirect to a login page.
258
     * If you don't provide a messageSet, a default will be used.
259
     *
260
     * @param Controller $controller The controller that you were on to cause the permission
261
     *                               failure.
262
     * @param string|array $messageSet The message to show to the user. This
263
     *                                 can be a string, or a map of different
264
     *                                 messages for different contexts.
265
     *                                 If you pass an array, you can use the
266
     *                                 following keys:
267
     *                                   - default: The default message
268
     *                                   - alreadyLoggedIn: The message to
269
     *                                                      show if the user
270
     *                                                      is already logged
271
     *                                                      in and lacks the
272
     *                                                      permission to
273
     *                                                      access the item.
274
     *
275
     * The alreadyLoggedIn value can contain a '%s' placeholder that will be replaced with a link
276
     * to log in.
277
     * @return HTTPResponse
278
     */
279
    public static function permissionFailure($controller = null, $messageSet = null)
280
    {
281
        self::set_ignore_disallowed_actions(true);
282
283
        if (!$controller) {
284
            $controller = Controller::curr();
285
        }
286
287
        if (Director::is_ajax()) {
288
            $response = ($controller) ? $controller->getResponse() : new HTTPResponse();
289
            $response->setStatusCode(403);
290
            if (!Member::currentUser()) {
291
                $response->setBody(_t('ContentController.NOTLOGGEDIN', 'Not logged in'));
292
                $response->setStatusDescription(_t('ContentController.NOTLOGGEDIN', 'Not logged in'));
293
                // Tell the CMS to allow re-aunthentication
294
                if (CMSSecurity::enabled()) {
295
                    $response->addHeader('X-Reauthenticate', '1');
296
                }
297
            }
298
            return $response;
299
        }
300
301
        // Prepare the messageSet provided
302
        if (!$messageSet) {
303
            if ($configMessageSet = static::config()->get('default_message_set')) {
304
                $messageSet = $configMessageSet;
305
            } else {
306
                $messageSet = array(
307
                    'default' => _t(
308
                        'Security.NOTEPAGESECURED',
309
                        "That page is secured. Enter your credentials below and we will send "
310
                            . "you right along."
311
                    ),
312
                    'alreadyLoggedIn' => _t(
313
                        'Security.ALREADYLOGGEDIN',
314
                        "You don't have access to this page.  If you have another account that "
315
                            . "can access that page, you can log in again below.",
316
                        "%s will be replaced with a link to log in."
317
                    )
318
                );
319
            }
320
        }
321
322
        if (!is_array($messageSet)) {
323
            $messageSet = array('default' => $messageSet);
324
        }
325
326
        $member = Member::currentUser();
327
328
        // Work out the right message to show
329
        if ($member && $member->exists()) {
330
            $response = ($controller) ? $controller->getResponse() : new HTTPResponse();
331
            $response->setStatusCode(403);
332
333
            //If 'alreadyLoggedIn' is not specified in the array, then use the default
334
            //which should have been specified in the lines above
335
            if (isset($messageSet['alreadyLoggedIn'])) {
336
                $message = $messageSet['alreadyLoggedIn'];
337
            } else {
338
                $message = $messageSet['default'];
339
            }
340
341
            Security::setLoginMessage($message, ValidationResult::TYPE_WARNING);
342
            $loginResponse = (new Security())->login(new HTTPRequest());
0 ignored issues
show
Bug introduced by
The call to HTTPRequest::__construct() misses some required arguments starting with $httpMethod.
Loading history...
343
            if ($loginResponse instanceof HTTPResponse) {
344
                return $loginResponse;
345
            }
346
347
            $response->setBody((string)$loginResponse);
348
349
            $controller->extend('permissionDenied', $member);
350
351
            return $response;
352
        } else {
353
            $message = $messageSet['default'];
354
        }
355
356
        static::setLoginMessage($message, ValidationResult::TYPE_WARNING);
357
358
        Session::set("BackURL", $_SERVER['REQUEST_URI']);
359
360
        // TODO AccessLogEntry needs an extension to handle permission denied errors
361
        // Audit logging hook
362
        $controller->extend('permissionDenied', $member);
363
364
        return $controller->redirect(Controller::join_links(
365
            Security::config()->uninherited('login_url'),
366
            "?BackURL=" . urlencode($_SERVER['REQUEST_URI'])
367
        ));
368
    }
369
370
    protected function init()
371
    {
372
        parent::init();
373
374
        // Prevent clickjacking, see https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options
375
        $frameOptions = $this->config()->get('frame_options');
376
        if ($frameOptions) {
377
            $this->getResponse()->addHeader('X-Frame-Options', $frameOptions);
378
        }
379
380
        // Prevent search engines from indexing the login page
381
        $robotsTag = $this->config()->get('robots_tag');
382
        if ($robotsTag) {
383
            $this->getResponse()->addHeader('X-Robots-Tag', $robotsTag);
384
        }
385
    }
386
387
    public function index()
388
    {
389
        return $this->httpError(404); // no-op
390
    }
391
392
    /**
393
     * Get the selected authenticator for this request
394
     *
395
     * @param $name string The identifier of the authenticator in your config
396
     * @return string Class name of Authenticator
397
     * @throws LogicException
398
     */
399
    protected function getAuthenticator($name)
400
    {
401
        $authenticators = self::config()->authenticators;
0 ignored issues
show
Documentation introduced by
The property authenticators does not exist on object<SilverStripe\Core\Config\Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
402
403
        if (!$name) $name = 'default';
404
405
        if (isset($authenticators[$name])) {
406
            return Injector::inst()->get($authenticators[$name]);
407
        }
408
409
        throw new LogicException('No valid authenticator found');
410
    }
411
412
    /**
413
     * Get the login form to process according to the submitted data
414
     *
415
     * @return Form
416
     * @throws Exception
417
     */
418
    public function LoginForm()
419
    {
420
        $authenticator = $this->getAuthenticator('default');
421
        if ($authenticator) {
422
            $handler = $authenticator->getLoginHandler($this->Link());
0 ignored issues
show
Bug introduced by
The method getLoginHandler cannot be called on $authenticator (of type string).

Methods can only be called on objects. This check looks for methods being called on variables that have been inferred to never be objects.

Loading history...
423
            return $handler->handleRequest($this->request, DataModel::inst());
424
        }
425
        throw new Exception('Passed invalid authentication method');
426
    }
427
428
    /**
429
     * Get the login forms for all available authentication methods
430
     *
431
     * @return array Returns an array of available login forms (array of Form
432
     *               objects).
433
     *
434
     * @todo Check how to activate/deactivate authentication methods
435
     */
436
    public function getLoginForms()
437
    {
438
        return array_map(
439
            function ($authenticator) {
440
                return $authenticator->getLoginHandler($this->Link())->handleRequest($this->getRequest(), DataModel::inst());
441
            },
442
            Security::getAuthenticators()
443
        );
444
    }
445
446
447
    /**
448
     * Get a link to a security action
449
     *
450
     * @param string $action Name of the action
451
     * @return string Returns the link to the given action
452
     */
453
    public function Link($action = null)
454
    {
455
        /** @skipUpgrade */
456
        return Controller::join_links(Director::baseURL(), "Security", $action);
457
    }
458
459
    /**
460
     * This action is available as a keep alive, so user
461
     * sessions don't timeout. A common use is in the admin.
462
     */
463
    public function ping()
464
    {
465
        return 1;
466
    }
467
468
    /**
469
     * Log the currently logged in user out
470
     *
471
     * @param bool $redirect Redirect the user back to where they came.
472
     *                       - If it's false, the code calling logout() is
473
     *                         responsible for sending the user where-ever
474
     *                         they should go.
475
     * @return HTTPResponse|null
476
     */
477
    public function logout($redirect = true)
478
    {
479
        $member = Member::currentUser();
480
        if ($member) {
481
            $member->logOut();
482
        }
483
484
        if ($redirect && (!$this->getResponse()->isFinished())) {
485
            return $this->redirectBack();
486
        }
487
        return null;
488
    }
489
490
    /**
491
     * Perform pre-login checking and prepare a response if available prior to login
492
     *
493
     * @return HTTPResponse Substitute response object if the login process should be curcumvented.
494
     * Returns null if should proceed as normal.
495
     */
496
    protected function preLogin()
497
    {
498
        // Event handler for pre-login, with an option to let it break you out of the login form
499
        $eventResults = $this->extend('onBeforeSecurityLogin');
500
        // If there was a redirection, return
501
        if ($this->redirectedTo()) {
502
            return $this->getResponse();
503
        }
504
        // If there was an HTTPResponse object returned, then return that
505
        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...
506
            foreach ($eventResults as $result) {
507
                if ($result instanceof HTTPResponse) {
508
                    return $result;
509
                }
510
            }
511
        }
512
513
        // If arriving on the login page already logged in, with no security error, and a ReturnURL then redirect
514
        // back. The login message check is neccesary to prevent infinite loops where BackURL links to
515
        // an action that triggers Security::permissionFailure.
516
        // This step is necessary in cases such as automatic redirection where a user is authenticated
517
        // upon landing on an SSL secured site and is automatically logged in, or some other case
518
        // where the user has permissions to continue but is not given the option.
519
        if ($this->getRequest()->requestVar('BackURL')
520
            && !$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...
521
            && ($member = Member::currentUser())
522
            && $member->exists()
523
        ) {
524
            return $this->redirectBack();
525
        }
526
527
        return null;
528
    }
529
530
    /**
531
     * Prepare the controller for handling the response to this request
532
     *
533
     * @param string $title Title to use
534
     * @return Controller
535
     */
536
    protected function getResponseController($title)
537
    {
538
        // Use the default setting for which Page to use to render the security page
539
        $pageClass = $this->stat('page_class');
540
        if (!$pageClass || !class_exists($pageClass)) {
541
            return $this;
542
        }
543
544
        // Create new instance of page holder
545
        /** @var Page $holderPage */
546
        $holderPage = new $pageClass;
547
        $holderPage->Title = $title;
548
        /** @skipUpgrade */
549
        $holderPage->URLSegment = 'Security';
550
        // Disable ID-based caching  of the log-in page by making it a random number
551
        $holderPage->ID = -1 * rand(1, 10000000);
552
553
        $controllerClass = $holderPage->getControllerName();
554
        /** @var ContentController $controller */
555
        $controller = $controllerClass::create($holderPage);
556
        $controller->setDataModel($this->model);
557
        $controller->doInit();
558
        return $controller;
559
    }
560
561
    /**
562
     * Combine the given forms into a formset with a tabbed interface
563
     *
564
     * @param array $authenticators List of Authenticator instances
0 ignored issues
show
Bug introduced by
There is no parameter named $authenticators. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
565
     * @return string
566
     */
567
    protected function generateLoginFormSet($forms)
568
    {
569
        $viewData = new ArrayData(array(
570
            'Forms' => new ArrayList($forms),
571
        ));
572
        return $viewData->renderWith(
573
            $this->getTemplatesFor('MultiAuthenticatorLogin')
574
        );
575
    }
576
577
    /**
578
     * Get the HTML Content for the $Content area during login
579
     *
580
     * @param string &$messageType Type of message, if available, passed back to caller
581
     * @return string Message in HTML format
582
     */
583
    protected function getLoginMessage(&$messageType = null)
584
    {
585
        $message = Session::get('Security.Message.message');
586
        $messageType = null;
587
        if (empty($message)) {
588
            return null;
589
        }
590
591
        $messageType = Session::get('Security.Message.type');
592
        $messageCast = Session::get('Security.Message.cast');
593
        if ($messageCast !== ValidationResult::CAST_HTML) {
594
            $message = Convert::raw2xml($message);
595
        }
596
        return sprintf('<p class="message %s">%s</p>', Convert::raw2att($messageType), $message);
597
    }
598
599
    /**
600
     * Set the next message to display for the security login page. Defaults to warning
601
     *
602
     * @param string $message Message
603
     * @param string $messageType Message type. One of ValidationResult::TYPE_*
604
     * @param string $messageCast Message cast. One of ValidationResult::CAST_*
605
     */
606
    public static function setLoginMessage(
607
        $message,
608
        $messageType = ValidationResult::TYPE_WARNING,
609
        $messageCast = ValidationResult::CAST_TEXT
610
    ) {
611
        Session::set("Security.Message.message", $message);
612
        Session::set("Security.Message.type", $messageType);
613
        Session::set("Security.Message.cast", $messageCast);
614
    }
615
616
    /**
617
     * Clear login message
618
     */
619
    public static function clearLoginMessage()
620
    {
621
        Session::clear("Security.Message");
622
    }
623
624
625
    /**
626
     * Show the "login" page
627
     *
628
     * For multiple authenticators, Security_MultiAuthenticatorLogin is used.
629
     * See getTemplatesFor and getIncludeTemplate for how to override template logic
630
     *
631
     * @return string|HTTPResponse Returns the "login" page as HTML code.
632
     */
633
    public function login($request)
634
    {
635
        // Check pre-login process
636
        if ($response = $this->preLogin()) {
637
            return $response;
638
        }
639
640
        $link = $this->link("login");
641
642
        // Delegate to a single handler - Security/login/<authname>/...
643
        if ($name = $request->param('ID')) {
644
            $request->shift();
645
646
            $authenticator = $this->getAuthenticator($name);
647
            if (!$authenticator) {
648
                throw new HTTPResponse_Exception(404, 'No authenticator "' . $name . '"');
649
            }
650
651
            $authenticators = [ $name => $authenticator ];
652
653
        // Delegate to all of them, building a tabbed view - Security/login
654
        } else {
655
            $authenticators = $this->getAuthenticators();
656
        }
657
658
        $handlers = $authenticators;
659
        array_walk(
660
            $handlers,
661
            function (&$auth, $name) use ($link) {
662
                $auth = $auth->getLoginHandler(Controller::join_links($link, $name));
663
            }
664
        );
665
666
        return $this->delegateToMultipleHandlers(
667
            $handlers,
668
            _t('Security.LOGIN', 'Log in'),
669
            $this->getTemplatesFor('login')
670
        );
671
    }
672
673
    /**
674
     * Delegate to an number of handlers, extracting their forms and rendering a tabbed form-set.
675
     * This is used to built the log-in page where there are multiple authenticators active.
676
     *
677
     * If a single handler is passed, delegateToHandler() will be called instead
678
     *
679
     * @param string $title The title of the form
680
     * @param array $templates
681
     * @return array|HTTPResponse|RequestHandler|\SilverStripe\ORM\FieldType\DBHTMLText|string
682
     */
683
    protected function delegateToMultipleHandlers(array $handlers, $title, array $templates)
684
    {
685
686
        // Simpler case for a single authenticator
687
        if (count($handlers) === 1) {
688
            return $this->delegateToHandler(array_values($handlers)[0], $title, $templates);
689
        }
690
691
        // Process each of the handlers
692
        $results = array_map(
693
            function ($handler) {
694
                return $handler->handleRequest($this->getRequest(), \SilverStripe\ORM\DataModel::inst());
695
            },
696
            $handlers
697
        );
698
699
        // Aggregate all their forms, assuming they all return
700
        $forms = [];
701
        foreach ($results as $authName => $singleResult) {
702
            // The result *must* be an array with a Form key
703
            if (!is_array($singleResult) || !isset($singleResult['Form'])) {
704
                user_error('Authenticator "' . $authName . '" doesn\'t support a tabbed login', E_USER_WARNING);
705
                continue;
706
            }
707
708
            $forms[] = $singleResult['Form'];
709
        }
710
711
        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...
712
            throw new \LogicException("No authenticators found compatible with a tabbed login");
713
        }
714
715
        return $this->renderWrappedController(
716
            $title,
717
            [
718
                'Form' => $this->generateLoginFormSet($forms),
719
            ],
720
            $templates
721
        );
722
723
    }
724
725
    /**
726
     * Delegate to another RequestHandler, rendering any fragment arrays into an appropriate.
727
     * controller.
728
     *
729
     * @param string $title The title of the form
730
     * @param array $templates
731
     * @return array|HTTPResponse|RequestHandler|\SilverStripe\ORM\FieldType\DBHTMLText|string
732
     */
733
    protected function delegateToHandler(RequestHandler $handler, $title, array $templates)
734
    {
735
        $result = $handler->handleRequest($this->getRequest(), \SilverStripe\ORM\DataModel::inst());
736
737
        // Return the customised controller - used to render in a Form
738
        // Post requests are expected to be login posts, so they'll be handled downstairs
739
        if (is_array($result)) {
740
            $result = $this->renderWrappedController($title, $result, $templates);
741
        }
742
743
        return $result;
744
    }
745
746
    /**
747
     * Render the given fragments into a security page controller with the given title.
748
     * @param $title string The title to give the security page
749
     * @param $fragments A map of objects to render into the page, e.g. "Form"
750
     * @param $templates An array of templates to use for the render
751
     */
752
    protected function renderWrappedController($title, array $fragments, array $templates)
753
    {
754
        $controller = $this->getResponseController($title);
755
756
        // if the controller calls Director::redirect(), this will break early
757
        if (($response = $controller->getResponse()) && $response->isFinished()) {
758
            return $response;
759
        }
760
761
        // Handle any form messages from validation, etc.
762
        $messageType = '';
763
        $message = $this->getLoginMessage($messageType);
764
765
        // We've displayed the message in the form output, so reset it for the next run.
766
        static::clearLoginMessage();
767
768
        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...
769
            $messageResult = [
770
                'Content'     => DBField::create_field('HTMLFragment', $message),
771
                'Message'     => DBField::create_field('HTMLFragment', $message),
772
                'MessageType' => $messageType
773
            ];
774
            $result = array_merge($fragments, $messageResult);
0 ignored issues
show
Unused Code introduced by
$result is not used, you could remove the assignment.

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

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

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

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

Loading history...
775
        }
776
777
        return $controller->customise($fragments)->renderWith($templates);
778
    }
779
780
    public function basicauthlogin()
781
    {
782
        $member = BasicAuth::requireLogin("SilverStripe login", 'ADMIN');
783
        $member->logIn();
784
    }
785
786
    /**
787
     * Show the "lost password" page
788
     *
789
     * @return string Returns the "lost password" page as HTML code.
790
     */
791
    public function lostpassword()
792
    {
793
        $handler = $this->getAuthenticator('default')->getLostPasswordHandler(
0 ignored issues
show
Bug introduced by
The method getLostPasswordHandler cannot be called on $this->getAuthenticator('default') (of type string).

Methods can only be called on objects. This check looks for methods being called on variables that have been inferred to never be objects.

Loading history...
794
            Controller::join_links($this->link(), 'lostpassword')
795
        );
796
797
        return $this->delegateToHandler(
798
            $handler,
799
            _t('Security.LOSTPASSWORDHEADER', 'Lost Password'),
800
            $this->getTemplatesFor('lostpassword')
801
        );
802
    }
803
804
    /**
805
     * Create a link to the password reset form.
806
     *
807
     * GET parameters used:
808
     * - m: member ID
809
     * - t: plaintext token
810
     *
811
     * @param Member $member Member object associated with this link.
812
     * @param string $autologinToken The auto login token.
813
     * @return string
814
     */
815
    public static function getPasswordResetLink($member, $autologinToken)
816
    {
817
        $autologinToken = urldecode($autologinToken);
818
        $selfControllerClass = __CLASS__;
819
        /** @var static $selfController */
820
        $selfController = new $selfControllerClass();
821
        return $selfController->Link('changepassword') . "?m={$member->ID}&t=$autologinToken";
822
    }
823
824
    /**
825
     * Show the "change password" page.
826
     * This page can either be called directly by logged-in users
827
     * (in which case they need to provide their old password),
828
     * or through a link emailed through {@link lostpassword()}.
829
     * In this case no old password is required, authentication is ensured
830
     * through the Member.AutoLoginHash property.
831
     *
832
     * @see ChangePasswordForm
833
     *
834
     * @return string|HTTPRequest Returns the "change password" page as HTML code, or a redirect response
835
     */
836
    public function changepassword()
837
    {
838
        $controller = $this->getResponseController(_t('Security.CHANGEPASSWORDHEADER', 'Change your password'));
839
840
        // if the controller calls Director::redirect(), this will break early
841
        if (($response = $controller->getResponse()) && $response->isFinished()) {
842
            return $response;
843
        }
844
845
        // Extract the member from the URL.
846
        /** @var Member $member */
847
        $member = null;
848
        if (isset($_REQUEST['m'])) {
849
            $member = Member::get()->filter('ID', (int)$_REQUEST['m'])->first();
850
        }
851
852
        // Check whether we are merely changin password, or resetting.
853
        if (isset($_REQUEST['t']) && $member && $member->validateAutoLoginToken($_REQUEST['t'])) {
854
            // On first valid password reset request redirect to the same URL without hash to avoid referrer leakage.
855
856
            // if there is a current member, they should be logged out
857
            if ($curMember = Member::currentUser()) {
858
                $curMember->logOut();
859
            }
860
861
            // Store the hash for the change password form. Will be unset after reload within the ChangePasswordForm.
862
            Session::set('AutoLoginHash', $member->encryptWithUserSettings($_REQUEST['t']));
863
864
            return $this->redirect($this->Link('changepassword'));
0 ignored issues
show
Bug Compatibility introduced by
The expression $this->redirect($this->Link('changepassword')); of type SilverStripe\Control\HTTPResponse|null adds the type SilverStripe\Control\HTTPResponse to the return on line 864 which is incompatible with the return type documented by SilverStripe\Security\Security::changepassword of type string|SilverStripe\Control\HTTPRequest.
Loading history...
865
        } elseif (Session::get('AutoLoginHash')) {
866
            // Subsequent request after the "first load with hash" (see previous if clause).
867
            $customisedController = $controller->customise(array(
868
                'Content' => DBField::create_field(
869
                    'HTMLFragment',
870
                    '<p>' . _t('Security.ENTERNEWPASSWORD', 'Please enter a new password.') . '</p>'
871
                ),
872
                'Form' => $this->ChangePasswordForm(),
873
            ));
874
        } elseif (Member::currentUser()) {
875
            // Logged in user requested a password change form.
876
            $customisedController = $controller->customise(array(
877
                'Content' => DBField::create_field(
878
                    'HTMLFragment',
879
                    '<p>' . _t('Security.CHANGEPASSWORDBELOW', 'You can change your password below.') . '</p>'
880
                ),
881
                'Form' => $this->ChangePasswordForm()));
882
        } else {
883
            // Show friendly message if it seems like the user arrived here via password reset feature.
884
            if (isset($_REQUEST['m']) || isset($_REQUEST['t'])) {
885
                $customisedController = $controller->customise(
886
                    array('Content' => DBField::create_field(
887
                        'HTMLFragment',
888
                        _t(
889
                            'Security.NOTERESETLINKINVALID',
890
                            '<p>The password reset link is invalid or expired.</p>'
891
                            . '<p>You can request a new one <a href="{link1}">here</a> or change your password after'
892
                            . ' you <a href="{link2}">logged in</a>.</p>',
893
                            [
894
                                'link1' => $this->Link('lostpassword'),
895
                                'link2' => $this->Link('login')
896
                            ]
897
                        )
898
                    ))
899
                );
900
            } else {
901
                return self::permissionFailure(
0 ignored issues
show
Bug Compatibility introduced by
The expression self::permissionFailure(...ange your password!')); of type SilverStripe\Control\HTTPResponse|null adds the type SilverStripe\Control\HTTPResponse to the return on line 901 which is incompatible with the return type documented by SilverStripe\Security\Security::changepassword of type string|SilverStripe\Control\HTTPRequest.
Loading history...
902
                    $this,
903
                    _t('Security.ERRORPASSWORDPERMISSION', 'You must be logged in in order to change your password!')
904
                );
905
            }
906
        }
907
908
        return $customisedController->renderWith($this->getTemplatesFor('changepassword'));
909
    }
910
911
    /**
912
     * Factory method for the lost password form
913
     *
914
     * @skipUpgrade
915
     * @return ChangePasswordForm Returns the lost password form
916
     */
917
    public function ChangePasswordForm()
918
    {
919
        return MemberAuthenticator\ChangePasswordForm::create($this, 'ChangePasswordForm');
920
    }
921
922
    /**
923
     * Determine the list of templates to use for rendering the given action.
924
     *
925
     * @skipUpgrade
926
     * @param string $action
927
     * @return array Template list
928
     */
929
    public function getTemplatesFor($action)
930
    {
931
        $templates = SSViewer::get_templates_by_class(get_class($this), "_{$action}", __CLASS__);
932
        return array_merge(
933
            $templates,
934
            [
935
                "Security_{$action}",
936
                "Security",
937
                $this->stat("template_main"),
938
                "BlankPage"
939
            ]
940
        );
941
    }
942
943
    /**
944
     * Return an existing member with administrator privileges, or create one of necessary.
945
     *
946
     * Will create a default 'Administrators' group if no group is found
947
     * with an ADMIN permission. Will create a new 'Admin' member with administrative permissions
948
     * if no existing Member with these permissions is found.
949
     *
950
     * Important: Any newly created administrator accounts will NOT have valid
951
     * login credentials (Email/Password properties), which means they can't be used for login
952
     * purposes outside of any default credentials set through {@link Security::setDefaultAdmin()}.
953
     *
954
     * @return Member
955
     */
956
    public static function findAnAdministrator()
957
    {
958
        // coupling to subsites module
959
        $origSubsite = null;
960
        if (is_callable('Subsite::changeSubsite')) {
961
            $origSubsite = Subsite::currentSubsiteID();
962
            Subsite::changeSubsite(0);
963
        }
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 (is_callable('Subsite::changeSubsite')) {
972
            Subsite::changeSubsite($origSubsite);
973
        }
974
975
        if ($adminGroup) {
976
            $member = $adminGroup->Members()->First();
977
        }
978
979
        if (!$adminGroup) {
980
            Group::singleton()->requireDefaultRecords();
981
            $adminGroup = Permission::get_groups_by_permission('ADMIN')->first();
982
        }
983
984
        if (!$member) {
985
            Member::singleton()->requireDefaultRecords();
986
            $member = Permission::get_members_by_permission('ADMIN')->first();
987
        }
988
989
        if (!$member) {
990
            $member = Member::default_admin();
991
        }
992
993
        if (!$member) {
994
            // Failover to a blank admin
995
            $member = Member::create();
996
            $member->FirstName = _t('Member.DefaultAdminFirstname', 'Default Admin');
997
            $member->write();
998
            // Add member to group instead of adding group to member
999
            // This bypasses the privilege escallation code in Member_GroupSet
1000
            $adminGroup
1001
                ->DirectMembers()
1002
                ->add($member);
1003
        }
1004
1005
        return $member;
1006
    }
1007
1008
    /**
1009
     * Flush the default admin credentials
1010
     */
1011
    public static function clear_default_admin()
1012
    {
1013
        self::$default_username = null;
1014
        self::$default_password = null;
1015
    }
1016
1017
1018
    /**
1019
     * Set a default admin in dev-mode
1020
     *
1021
     * This will set a static default-admin which is not existing
1022
     * as a database-record. By this workaround we can test pages in dev-mode
1023
     * with a unified login. Submitted login-credentials are first checked
1024
     * against this static information in {@link Security::authenticate()}.
1025
     *
1026
     * @param string $username The user name
1027
     * @param string $password The password (in cleartext)
1028
     * @return bool True if successfully set
1029
     */
1030
    public static function setDefaultAdmin($username, $password)
1031
    {
1032
        // don't overwrite if already set
1033
        if (self::$default_username || self::$default_password) {
1034
            return false;
1035
        }
1036
1037
        self::$default_username = $username;
1038
        self::$default_password = $password;
1039
        return true;
1040
    }
1041
1042
    /**
1043
     * Checks if the passed credentials are matching the default-admin.
1044
     * Compares cleartext-password set through Security::setDefaultAdmin().
1045
     *
1046
     * @param string $username
1047
     * @param string $password
1048
     * @return bool
1049
     */
1050
    public static function check_default_admin($username, $password)
1051
    {
1052
        return (
1053
            self::$default_username === $username
1054
            && self::$default_password === $password
1055
            && self::has_default_admin()
1056
        );
1057
    }
1058
1059
    /**
1060
     * Check that the default admin account has been set.
1061
     */
1062
    public static function has_default_admin()
1063
    {
1064
        return !empty(self::$default_username) && !empty(self::$default_password);
1065
    }
1066
1067
    /**
1068
     * Get default admin username
1069
     *
1070
     * @return string
1071
     */
1072
    public static function default_admin_username()
1073
    {
1074
        return self::$default_username;
1075
    }
1076
1077
    /**
1078
     * Get default admin password
1079
     *
1080
     * @return string
1081
     */
1082
    public static function default_admin_password()
1083
    {
1084
        return self::$default_password;
1085
    }
1086
1087
    /**
1088
     * Encrypt a password according to the current password encryption settings.
1089
     * If the settings are so that passwords shouldn't be encrypted, the
1090
     * result is simple the clear text password with an empty salt except when
1091
     * a custom algorithm ($algorithm parameter) was passed.
1092
     *
1093
     * @param string $password The password to encrypt
1094
     * @param string $salt Optional: The salt to use. If it is not passed, but
1095
     *  needed, the method will automatically create a
1096
     *  random salt that will then be returned as return value.
1097
     * @param string $algorithm Optional: Use another algorithm to encrypt the
1098
     *  password (so that the encryption algorithm can be changed over the time).
1099
     * @param Member $member Optional
1100
     * @return mixed Returns an associative array containing the encrypted
1101
     *  password and the used salt in the form:
1102
     * <code>
1103
     *  array(
1104
     *  'password' => string,
1105
     *  'salt' => string,
1106
     *  'algorithm' => string,
1107
     *  'encryptor' => PasswordEncryptor instance
1108
     *  )
1109
     * </code>
1110
     * If the passed algorithm is invalid, FALSE will be returned.
1111
     *
1112
     * @see encrypt_passwords()
1113
     */
1114
    public static function encrypt_password($password, $salt = null, $algorithm = null, $member = null)
1115
    {
1116
        // Fall back to the default encryption algorithm
1117
        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...
1118
            $algorithm = self::config()->get('password_encryption_algorithm');
1119
        }
1120
1121
        $e = PasswordEncryptor::create_for_algorithm($algorithm);
1122
1123
        // New salts will only need to be generated if the password is hashed for the first time
1124
        $salt = ($salt) ? $salt : $e->salt($password);
1125
1126
        return array(
1127
            'password' => $e->encrypt($password, $salt, $member),
1128
            'salt' => $salt,
1129
            'algorithm' => $algorithm,
1130
            'encryptor' => $e
1131
        );
1132
    }
1133
1134
    /**
1135
     * Checks the database is in a state to perform security checks.
1136
     * See {@link DatabaseAdmin->init()} for more information.
1137
     *
1138
     * @return bool
1139
     */
1140
    public static function database_is_ready()
1141
    {
1142
        // Used for unit tests
1143
        if (self::$force_database_is_ready !== null) {
1144
            return self::$force_database_is_ready;
1145
        }
1146
1147
        if (self::$database_is_ready) {
1148
            return self::$database_is_ready;
1149
        }
1150
1151
        $requiredClasses = ClassInfo::dataClassesFor(Member::class);
1152
        $requiredClasses[] = Group::class;
1153
        $requiredClasses[] = Permission::class;
1154
        $schema = DataObject::getSchema();
1155
        foreach ($requiredClasses as $class) {
1156
            // Skip test classes, as not all test classes are scaffolded at once
1157
            if (is_a($class, TestOnly::class, true)) {
1158
                continue;
1159
            }
1160
1161
            // if any of the tables aren't created in the database
1162
            $table = $schema->tableName($class);
1163
            if (!ClassInfo::hasTable($table)) {
1164
                return false;
1165
            }
1166
1167
            // HACK: DataExtensions aren't applied until a class is instantiated for
1168
            // the first time, so create an instance here.
1169
            singleton($class);
1170
1171
            // if any of the tables don't have all fields mapped as table columns
1172
            $dbFields = DB::field_list($table);
1173
            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...
1174
                return false;
1175
            }
1176
1177
            $objFields = $schema->databaseFields($class, false);
1178
            $missingFields = array_diff_key($objFields, $dbFields);
1179
1180
            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...
1181
                return false;
1182
            }
1183
        }
1184
        self::$database_is_ready = true;
1185
1186
        return true;
1187
    }
1188
1189
    /**
1190
     * Resets the database_is_ready cache
1191
     */
1192
    public static function clear_database_is_ready()
1193
    {
1194
        self::$database_is_ready = null;
1195
        self::$force_database_is_ready = null;
1196
    }
1197
1198
    /**
1199
     * For the database_is_ready call to return a certain value - used for testing
1200
     */
1201
    public static function force_database_is_ready($isReady)
1202
    {
1203
        self::$force_database_is_ready = $isReady;
1204
    }
1205
1206
    /**
1207
     * Enable or disable recording of login attempts
1208
     * through the {@link LoginRecord} object.
1209
     *
1210
     * @deprecated 4.0 Use the "Security.login_recording" config setting instead
1211
     * @param boolean $bool
1212
     */
1213
    public static function set_login_recording($bool)
1214
    {
1215
        Deprecation::notice('4.0', 'Use the "Security.login_recording" config setting instead');
1216
        self::$login_recording = (bool)$bool;
1217
    }
1218
1219
    /**
1220
     * @deprecated 4.0 Use the "Security.login_recording" config setting instead
1221
     * @return boolean
1222
     */
1223
    public static function login_recording()
1224
    {
1225
        Deprecation::notice('4.0', 'Use the "Security.login_recording" config setting instead');
1226
        return self::$login_recording;
1227
    }
1228
1229
    /**
1230
     * @config
1231
     * @var string Set the default login dest
1232
     * This is the URL that users will be redirected to after they log in,
1233
     * if they haven't logged in en route to access a secured page.
1234
     * By default, this is set to the homepage.
1235
     */
1236
    private static $default_login_dest = "";
1237
1238
    protected static $ignore_disallowed_actions = false;
1239
1240
    /**
1241
     * Set to true to ignore access to disallowed actions, rather than returning permission failure
1242
     * Note that this is just a flag that other code needs to check with Security::ignore_disallowed_actions()
1243
     * @param $flag True or false
1244
     */
1245
    public static function set_ignore_disallowed_actions($flag)
1246
    {
1247
        self::$ignore_disallowed_actions = $flag;
1248
    }
1249
1250
    public static function ignore_disallowed_actions()
1251
    {
1252
        return self::$ignore_disallowed_actions;
1253
    }
1254
1255
    /**
1256
     * Get the URL of the log-in page.
1257
     *
1258
     * To update the login url use the "Security.login_url" config setting.
1259
     *
1260
     * @return string
1261
     */
1262
    public static function login_url()
1263
    {
1264
        return Controller::join_links(Director::baseURL(), self::config()->get('login_url'));
1265
    }
1266
1267
1268
    /**
1269
     * Get the URL of the logout page.
1270
     *
1271
     * To update the logout url use the "Security.logout_url" config setting.
1272
     *
1273
     * @return string
1274
     */
1275
    public static function logout_url()
1276
    {
1277
        return Controller::join_links(Director::baseURL(), self::config()->get('logout_url'));
1278
    }
1279
1280
    /**
1281
     * Get the URL of the logout page.
1282
     *
1283
     * To update the logout url use the "Security.logout_url" config setting.
1284
     *
1285
     * @return string
1286
     */
1287
    public static function lost_password_url()
1288
    {
1289
        return Controller::join_links(Director::baseURL(), self::config()->get('lost_password_url'));
1290
    }
1291
1292
    /**
1293
     * Defines global accessible templates variables.
1294
     *
1295
     * @return array
1296
     */
1297
    public static function get_template_global_variables()
1298
    {
1299
        return array(
1300
            "LoginURL" => "login_url",
1301
            "LogoutURL" => "logout_url",
1302
            "LostPasswordURL" => "lost_password_url",
1303
        );
1304
    }
1305
}
1306