Completed
Push — authenticator-refactor ( d33fab...54d3b4 )
by Sam
06:45
created

Security::lostpassword()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 12
Code Lines 7

Duplication

Lines 12
Ratio 100 %

Importance

Changes 0
Metric Value
dl 12
loc 12
c 0
b 0
f 0
cc 1
eloc 7
nc 1
nop 0
rs 9.4285
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\Session;
13
use SilverStripe\Control\RequestHandler;
14
use SilverStripe\Core\ClassInfo;
15
use SilverStripe\Core\Config\Config;
16
use SilverStripe\Core\Convert;
17
use SilverStripe\Dev\Deprecation;
18
use SilverStripe\Dev\TestOnly;
19
use SilverStripe\Forms\EmailField;
20
use SilverStripe\Forms\FieldList;
21
use SilverStripe\Forms\Form;
22
use SilverStripe\Forms\FormAction;
23
use SilverStripe\ORM\ArrayList;
24
use SilverStripe\ORM\DB;
25
use SilverStripe\ORM\DataObject;
26
use SilverStripe\ORM\FieldType\DBField;
27
use SilverStripe\ORM\ValidationResult;
28
use SilverStripe\View\ArrayData;
29
use SilverStripe\View\SSViewer;
30
use SilverStripe\View\TemplateGlobalProvider;
31
use Exception;
32
use SilverStripe\View\ViewableData_Customised;
33
use Subsite;
34
use SilverStripe\Core\Injector\Injector;
35
36
/**
37
 * Implements a basic security model
38
 */
39
class Security extends Controller implements TemplateGlobalProvider
40
{
41
42
    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...
43
        'index',
44
        'login',
45
        'logout',
46
        'basicauthlogin',
47
        'lostpassword',
48
        'passwordsent',
49
        'changepassword',
50
        'ping',
51
        'LoginForm',
52
        'ChangePasswordForm',
53
        'LostPasswordForm',
54
    );
55
56
    /**
57
     * Default user name. Only used in dev-mode by {@link setDefaultAdmin()}
58
     *
59
     * @var string
60
     * @see setDefaultAdmin()
61
     */
62
    protected static $default_username;
63
64
    /**
65
     * Default password. Only used in dev-mode by {@link setDefaultAdmin()}
66
     *
67
     * @var string
68
     * @see setDefaultAdmin()
69
     */
70
    protected static $default_password;
71
72
    /**
73
     * If set to TRUE to prevent sharing of the session across several sites
74
     * in the domain.
75
     *
76
     * @config
77
     * @var bool
78
     */
79
    protected static $strict_path_checking = false;
80
81
    /**
82
     * The password encryption algorithm to use by default.
83
     * This is an arbitrary code registered through {@link PasswordEncryptor}.
84
     *
85
     * @config
86
     * @var string
87
     */
88
    private static $password_encryption_algorithm = 'blowfish';
89
90
    /**
91
     * Showing "Remember me"-checkbox
92
     * on loginform, and saving encrypted credentials to a cookie.
93
     *
94
     * @config
95
     * @var bool
96
     */
97
    private static $autologin_enabled = true;
98
99
    /**
100
     * Determine if login username may be remembered between login sessions
101
     * If set to false this will disable autocomplete and prevent username persisting in the session
102
     *
103
     * @config
104
     * @var bool
105
     */
106
    private static $remember_username = true;
107
108
    /**
109
     * Location of word list to use for generating passwords
110
     *
111
     * @config
112
     * @var string
113
     */
114
    private static $word_list = './wordlist.txt';
115
116
    /**
117
     * @config
118
     * @var string
119
     */
120
    private static $template = 'BlankPage';
121
122
    /**
123
     * Template thats used to render the pages.
124
     *
125
     * @var string
126
     * @config
127
     */
128
    private static $template_main = 'Page';
129
130
    /**
131
     * Class to use for page rendering
132
     *
133
     * @var string
134
     * @config
135
     */
136
    private static $page_class = Page::class;
137
138
    /**
139
     * Default message set used in permission failures.
140
     *
141
     * @config
142
     * @var array|string
143
     */
144
    private static $default_message_set;
145
146
    /**
147
     * Random secure token, can be used as a crypto key internally.
148
     * Generate one through 'sake dev/generatesecuretoken'.
149
     *
150
     * @config
151
     * @var String
152
     */
153
    private static $token;
154
155
    /**
156
     * The default login URL
157
     *
158
     * @config
159
     *
160
     * @var string
161
     */
162
    private static $login_url = "Security/login";
163
164
    /**
165
     * The default logout URL
166
     *
167
     * @config
168
     *
169
     * @var string
170
     */
171
    private static $logout_url = "Security/logout";
172
173
    /**
174
     * The default lost password URL
175
     *
176
     * @config
177
     *
178
     * @var string
179
     */
180
    private static $lost_password_url = "Security/lostpassword";
181
182
    /**
183
     * Value of X-Frame-Options header
184
     *
185
     * @config
186
     * @var string
187
     */
188
    private static $frame_options = 'SAMEORIGIN';
189
190
    /**
191
     * Value of the X-Robots-Tag header (for the Security section)
192
     *
193
     * @config
194
     * @var string
195
     */
196
    private static $robots_tag = 'noindex, nofollow';
197
198
    /**
199
     * Enable or disable recording of login attempts
200
     * through the {@link LoginRecord} object.
201
     *
202
     * @config
203
     * @var boolean $login_recording
204
     */
205
    private static $login_recording = false;
206
207
    /**
208
     * @var boolean If set to TRUE or FALSE, {@link database_is_ready()}
209
     * will always return FALSE. Used for unit testing.
210
     */
211
    static $force_database_is_ready = null;
212
213
    /**
214
     * When the database has once been verified as ready, it will not do the
215
     * checks again.
216
     *
217
     * @var bool
218
     */
219
    static $database_is_ready = false;
220
221
    /**
222
     * Get all registered authenticators
223
     *
224
     * @return array Return an array of Authenticator objects
225
     */
226
    public static function getAuthenticators()
227
    {
228
        $authenticatorClasses = 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...
229
        $default = self::config()->default_authenticator;
0 ignored issues
show
Documentation introduced by
The property default_authenticator 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...
230
231
        if (!$authenticatorClasses) {
232
            if ($default) {
233
                $authenticatorClasses = [$default];
234
            } else {
235
                return [];
236
            }
237
        }
238
239
        // put default authenticator first (mainly for tab-order on loginform)
240
        // But only if there's no other authenticator
241
        if (($key = array_search($default, $authenticatorClasses, true)) && count($$authenticatorClasses) > 1) {
242
            unset($authenticatorClasses[$key]);
243
            array_unshift($authenticatorClasses, $default);
244
        }
245
246
        return array_map(function ($class) {
247
            return Injector::inst()->get($class);
248
        }, $authenticatorClasses);
249
    }
250
251
    /**
252
     * Check if a given authenticator is registered
253
     *
254
     * @param string $authenticator Name of the authenticator class to check
255
     * @return bool Returns TRUE if the authenticator is registered, FALSE
256
     *              otherwise.
257
     */
258
    public static function hasAuthenticator($authenticator)
259
    {
260
        $authenticators = self::config()->get('authenticators');
261
        if (count($authenticators) === 0) {
262
            $authenticators = [self::config()->get('default_authenticator')];
263
        }
264
265
        return in_array($authenticator, $authenticators, true);
266
    }
267
268
    /**
269
     * Register that we've had a permission failure trying to view the given page
270
     *
271
     * This will redirect to a login page.
272
     * If you don't provide a messageSet, a default will be used.
273
     *
274
     * @param Controller $controller The controller that you were on to cause the permission
275
     *                               failure.
276
     * @param string|array $messageSet The message to show to the user. This
277
     *                                 can be a string, or a map of different
278
     *                                 messages for different contexts.
279
     *                                 If you pass an array, you can use the
280
     *                                 following keys:
281
     *                                   - default: The default message
282
     *                                   - alreadyLoggedIn: The message to
283
     *                                                      show if the user
284
     *                                                      is already logged
285
     *                                                      in and lacks the
286
     *                                                      permission to
287
     *                                                      access the item.
288
     *
289
     * The alreadyLoggedIn value can contain a '%s' placeholder that will be replaced with a link
290
     * to log in.
291
     * @return HTTPResponse
292
     */
293
    public static function permissionFailure($controller = null, $messageSet = null)
294
    {
295
        self::set_ignore_disallowed_actions(true);
296
297
        if (!$controller) {
298
            $controller = Controller::curr();
299
        }
300
301
        if (Director::is_ajax()) {
302
            $response = ($controller) ? $controller->getResponse() : new HTTPResponse();
303
            $response->setStatusCode(403);
304
            if (!Member::currentUser()) {
305
                $response->setBody(_t('ContentController.NOTLOGGEDIN', 'Not logged in'));
306
                $response->setStatusDescription(_t('ContentController.NOTLOGGEDIN', 'Not logged in'));
307
                // Tell the CMS to allow re-aunthentication
308
                if (CMSSecurity::enabled()) {
309
                    $response->addHeader('X-Reauthenticate', '1');
310
                }
311
            }
312
            return $response;
313
        }
314
315
        // Prepare the messageSet provided
316
        if (!$messageSet) {
317
            if ($configMessageSet = static::config()->get('default_message_set')) {
318
                $messageSet = $configMessageSet;
319
            } else {
320
                $messageSet = array(
321
                    'default' => _t(
322
                        'Security.NOTEPAGESECURED',
323
                        "That page is secured. Enter your credentials below and we will send "
324
                            . "you right along."
325
                    ),
326
                    'alreadyLoggedIn' => _t(
327
                        'Security.ALREADYLOGGEDIN',
328
                        "You don't have access to this page.  If you have another account that "
329
                            . "can access that page, you can log in again below.",
330
                        "%s will be replaced with a link to log in."
331
                    )
332
                );
333
            }
334
        }
335
336
        if (!is_array($messageSet)) {
337
            $messageSet = array('default' => $messageSet);
338
        }
339
340
        $member = Member::currentUser();
341
342
        // Work out the right message to show
343
        if ($member && $member->exists()) {
344
            $response = ($controller) ? $controller->getResponse() : new HTTPResponse();
345
            $response->setStatusCode(403);
346
347
            //If 'alreadyLoggedIn' is not specified in the array, then use the default
348
            //which should have been specified in the lines above
349
            if (isset($messageSet['alreadyLoggedIn'])) {
350
                $message = $messageSet['alreadyLoggedIn'];
351
            } else {
352
                $message = $messageSet['default'];
353
            }
354
355
            // Somewhat hackish way to render a login form with an error message.
356
            $me = new Security();
357
            $form = $me->LoginForm();
358
            $form->sessionMessage($message, ValidationResult::TYPE_WARNING);
359
            Session::set('MemberLoginForm.force_message', 1);
360
            $loginResponse = $me->login();
0 ignored issues
show
Bug introduced by
The call to login() misses a required argument $request.

This check looks for function calls that miss required arguments.

Loading history...
361
            if ($loginResponse instanceof HTTPResponse) {
362
                return $loginResponse;
363
            }
364
365
            $response->setBody((string)$loginResponse);
366
367
            $controller->extend('permissionDenied', $member);
368
369
            return $response;
370
        } else {
371
            $message = $messageSet['default'];
372
        }
373
374
        static::setLoginMessage($message, ValidationResult::TYPE_WARNING);
375
376
        Session::set("BackURL", $_SERVER['REQUEST_URI']);
377
378
        // TODO AccessLogEntry needs an extension to handle permission denied errors
379
        // Audit logging hook
380
        $controller->extend('permissionDenied', $member);
381
382
        return $controller->redirect(Controller::join_links(
383
            Security::config()->uninherited('login_url'),
384
            "?BackURL=" . urlencode($_SERVER['REQUEST_URI'])
385
        ));
386
    }
387
388
    protected function init()
389
    {
390
        parent::init();
391
392
        // Prevent clickjacking, see https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options
393
        $frameOptions = $this->config()->get('frame_options');
394
        if ($frameOptions) {
395
            $this->getResponse()->addHeader('X-Frame-Options', $frameOptions);
396
        }
397
398
        // Prevent search engines from indexing the login page
399
        $robotsTag = $this->config()->get('robots_tag');
400
        if ($robotsTag) {
401
            $this->getResponse()->addHeader('X-Robots-Tag', $robotsTag);
402
        }
403
    }
404
405
    public function index()
406
    {
407
        return $this->httpError(404); // no-op
408
    }
409
410
    /**
411
     * Get the selected authenticator for this request
412
     *
413
     * @return string Class name of Authenticator
414
     * @throws LogicException
415
     */
416
    protected function getAuthenticator()
417
    {
418
        $authenticator = $this->getRequest()->requestVar('AuthenticationMethod');
419
        if ($authenticator && Security::hasAuthenticator($authenticator)) {
420
            return Injector::inst()->get($authenticator);
421
422
        } elseif ($authenticator !== '') {
423
            $authenticators = self::getAuthenticators();
424
            if (sizeof($authenticators) > 0) {
425
                return $authenticators[0];
426
            }
427
        }
428
429
        throw new LogicException('No valid authenticator found');
430
    }
431
432
    /**
433
     * Get the login form to process according to the submitted data
434
     *
435
     * @return Form
436
     * @throws Exception
437
     */
438
    public function LoginForm()
439
    {
440
        $authenticator = $this->getAuthenticator();
441
        if ($authenticator) {
442
            return $authenticator::get_login_form($this);
443
        }
444
        throw new Exception('Passed invalid authentication method');
445
    }
446
447
    /**
448
     * Get the login forms for all available authentication methods
449
     *
450
     * @return array Returns an array of available login forms (array of Form
451
     *               objects).
452
     *
453
     * @todo Check how to activate/deactivate authentication methods
454
     */
455
    public function getLoginForms()
456
    {
457
        $forms = array();
0 ignored issues
show
Unused Code introduced by
$forms 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...
458
459
        return array_map(
460
            Security::getAuthenticators(),
461
            function ($authenticator) {
462
                return $authenticator->getLoginHandler();
463
            }
464
        );
465
    }
466
467
468
    /**
469
     * Get a link to a security action
470
     *
471
     * @param string $action Name of the action
472
     * @return string Returns the link to the given action
473
     */
474
    public function Link($action = null)
475
    {
476
        /** @skipUpgrade */
477
        return Controller::join_links(Director::baseURL(), "Security", $action);
478
    }
479
480
    /**
481
     * This action is available as a keep alive, so user
482
     * sessions don't timeout. A common use is in the admin.
483
     */
484
    public function ping()
485
    {
486
        return 1;
487
    }
488
489
    /**
490
     * Log the currently logged in user out
491
     *
492
     * @param bool $redirect Redirect the user back to where they came.
493
     *                       - If it's false, the code calling logout() is
494
     *                         responsible for sending the user where-ever
495
     *                         they should go.
496
     * @return HTTPResponse|null
497
     */
498
    public function logout($redirect = true)
499
    {
500
        $member = Member::currentUser();
501
        if ($member) {
502
            $member->logOut();
503
        }
504
505
        if ($redirect && (!$this->getResponse()->isFinished())) {
506
            return $this->redirectBack();
507
        }
508
        return null;
509
    }
510
511
    /**
512
     * Perform pre-login checking and prepare a response if available prior to login
513
     *
514
     * @return HTTPResponse Substitute response object if the login process should be curcumvented.
515
     * Returns null if should proceed as normal.
516
     */
517
    protected function preLogin()
518
    {
519
        // Event handler for pre-login, with an option to let it break you out of the login form
520
        $eventResults = $this->extend('onBeforeSecurityLogin');
521
        // If there was a redirection, return
522
        if ($this->redirectedTo()) {
523
            return $this->getResponse();
524
        }
525
        // If there was an HTTPResponse object returned, then return that
526
        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...
527
            foreach ($eventResults as $result) {
528
                if ($result instanceof HTTPResponse) {
529
                    return $result;
530
                }
531
            }
532
        }
533
534
        // If arriving on the login page already logged in, with no security error, and a ReturnURL then redirect
535
        // back. The login message check is neccesary to prevent infinite loops where BackURL links to
536
        // an action that triggers Security::permissionFailure.
537
        // This step is necessary in cases such as automatic redirection where a user is authenticated
538
        // upon landing on an SSL secured site and is automatically logged in, or some other case
539
        // where the user has permissions to continue but is not given the option.
540
        if ($this->getRequest()->requestVar('BackURL')
541
            && !$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...
542
            && ($member = Member::currentUser())
543
            && $member->exists()
544
        ) {
545
            return $this->redirectBack();
546
        }
547
548
        return null;
549
    }
550
551
    /**
552
     * Prepare the controller for handling the response to this request
553
     *
554
     * @param string $title Title to use
555
     * @return Controller
556
     */
557
    protected function getResponseController($title)
558
    {
559
        // Use the default setting for which Page to use to render the security page
560
        $pageClass = $this->stat('page_class');
561
        if (!$pageClass || !class_exists($pageClass)) {
562
            return $this;
563
        }
564
565
        // Create new instance of page holder
566
        /** @var Page $holderPage */
567
        $holderPage = new $pageClass;
568
        $holderPage->Title = $title;
569
        /** @skipUpgrade */
570
        $holderPage->URLSegment = 'Security';
571
        // Disable ID-based caching  of the log-in page by making it a random number
572
        $holderPage->ID = -1 * rand(1, 10000000);
573
574
        $controllerClass = $holderPage->getControllerName();
575
        /** @var ContentController $controller */
576
        $controller = $controllerClass::create($holderPage);
577
        $controller->setDataModel($this->model);
578
        $controller->doInit();
579
        return $controller;
580
    }
581
582
    /**
583
     * Combine the given forms into a formset with a tabbed interface
584
     *
585
     * @param array $forms List of LoginForm instances
586
     * @return string
587
     */
588
    protected function generateLoginFormSet($forms)
589
    {
590
        $viewData = new ArrayData(array(
591
            'Forms' => new ArrayList($forms),
592
        ));
593
        return $viewData->renderWith(
594
            $this->getTemplatesFor('MultiAuthenticatorLogin')
595
        );
596
    }
597
598
    /**
599
     * Get the HTML Content for the $Content area during login
600
     *
601
     * @param string &$messageType Type of message, if available, passed back to caller
602
     * @return string Message in HTML format
603
     */
604
    protected function getLoginMessage(&$messageType = null)
605
    {
606
        $message = Session::get('Security.Message.message');
607
        $messageType = null;
608
        if (empty($message)) {
609
            return null;
610
        }
611
612
        $messageType = Session::get('Security.Message.type');
613
        $messageCast = Session::get('Security.Message.cast');
614
        if ($messageCast !== ValidationResult::CAST_HTML) {
615
            $message = Convert::raw2xml($message);
616
        }
617
        return sprintf('<p class="message %s">%s</p>', Convert::raw2att($messageType), $message);
618
    }
619
620
    /**
621
     * Set the next message to display for the security login page. Defaults to warning
622
     *
623
     * @param string $message Message
624
     * @param string $messageType Message type. One of ValidationResult::TYPE_*
625
     * @param string $messageCast Message cast. One of ValidationResult::CAST_*
626
     */
627
    public static function setLoginMessage(
628
        $message,
629
        $messageType = ValidationResult::TYPE_WARNING,
630
        $messageCast = ValidationResult::CAST_TEXT
631
    ) {
632
        Session::set("Security.Message.message", $message);
633
        Session::set("Security.Message.type", $messageType);
634
        Session::set("Security.Message.cast", $messageCast);
635
    }
636
637
    /**
638
     * Clear login message
639
     */
640
    public static function clearLoginMessage()
641
    {
642
        Session::clear("Security.Message");
643
    }
644
645
646
    /**
647
     * Show the "login" page
648
     *
649
     * For multiple authenticators, Security_MultiAuthenticatorLogin is used.
650
     * See getTemplatesFor and getIncludeTemplate for how to override template logic
651
     *
652
     * @return string|HTTPResponse Returns the "login" page as HTML code.
653
     */
654 View Code Duplication
    public function login($request)
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Duplication introduced by
This method seems to be duplicated in your project.

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

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

Loading history...
655
    {
656
        // Check pre-login process
657
        if ($response = $this->preLogin()) {
658
            return $response;
659
        }
660
661
        // Create the login handler
662
        $handler = $this->getAuthenticator()->getLoginHandler(
0 ignored issues
show
Bug introduced by
The method getLoginHandler cannot be called on $this->getAuthenticator() (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...
663
            Controller::join_links($this->link(), 'login')
664
        );
665
666
        return $this->delegateToHandler(
667
            $handler,
668
            _t('Security.LOGIN', 'Log in'),
669
            $this->getTemplatesFor('login')
670
        );
671
    }
672
673
    /**
674
     * Delegate to another RequestHandler, rendering any fragment arrays into an appropriate.
675
     * controller.
676
     * @param string $title The title of the form
677
     * @param $template The template stack to render
678
     */
679
    protected function delegateToHandler(RequestHandler $handler, $title, array $templates)
680
    {
681
        // Process the result
682
        $result = $handler->handleRequest($this->getRequest(), \SilverStripe\ORM\DataModel::inst());
683
684
        // Return the customised controller - used to render in a Form
685
        if (is_array($result)) {
686
            $controller = $this->getResponseController($title);
687
688
            // if the controller calls Director::redirect(), this will break early
689
            if (($response = $controller->getResponse()) && $response->isFinished()) {
690
                return $response;
691
            }
692
693
            // Handle any form messages from validation, etc.
694
            $messageType = '';
695
            $message = $this->getLoginMessage($messageType);
696
697
            // We've displayed the message in the form output, so reset it for the next run.
698
            static::clearLoginMessage();
699
700
            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...
701
                $result["Content"] = DBField::create_field('HTMLFragment', $message);
702
                $result["Message"] = DBField::create_field('HTMLFragment', $message);
703
                $result["MessageType"] = $messageType;
704
            }
705
706
            return $controller->customise($result)->renderWith($templates);
707
708
        // Return a complete result
709
        } else {
710
            return $result;
711
        }
712
713
        /*
714
715
        TO DO: Implement multi-login-form support
716
717
        $forms = $this->GetLoginForms();
718
        if (!count($forms)) {
719
            user_error(
720
                'No login-forms found, please use Authenticator::register_authenticator() to add one',
721
                E_USER_ERROR
722
            );
723
        }
724
725
726
        // only display tabs when more than one authenticator is provided
727
        // to save bandwidth and reduce the amount of custom styling needed
728
        if (count($forms) > 1) {
729
            $content = $this->generateLoginFormSet($forms);
730
        } else {
731
            $content = $forms[0]->forTemplate();
732
        }
733
734
        */
735
    }
736
737
    public function basicauthlogin()
738
    {
739
        $member = BasicAuth::requireLogin("SilverStripe login", 'ADMIN');
740
        $member->logIn();
741
    }
742
743
    /**
744
     * Show the "lost password" page
745
     *
746
     * @return string Returns the "lost password" page as HTML code.
747
     */
748 View Code Duplication
    public function lostpassword()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

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

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

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