Completed
Push — master ( e2b0c5...f862ce )
by Sam
08:22
created

Security::getAuthenticator()   B

Complexity

Conditions 5
Paths 3

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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

The PSR-2 coding standard requires that all properties in a class have their visibility explicitly declared. If you declare a property using

class A {
    var $property;
}

the property is implicitly global.

To learn more about the PSR-2, please see the PHP-FIG site on the PSR-2.

Loading history...
210
211
    /**
212
     * When the database has once been verified as ready, it will not do the
213
     * checks again.
214
     *
215
     * @var bool
216
     */
217
    static $database_is_ready = false;
218
219
    /**
220
     * Register that we've had a permission failure trying to view the given page
221
     *
222
     * This will redirect to a login page.
223
     * If you don't provide a messageSet, a default will be used.
224
     *
225
     * @param Controller $controller The controller that you were on to cause the permission
226
     *                               failure.
227
     * @param string|array $messageSet The message to show to the user. This
228
     *                                 can be a string, or a map of different
229
     *                                 messages for different contexts.
230
     *                                 If you pass an array, you can use the
231
     *                                 following keys:
232
     *                                   - default: The default message
233
     *                                   - alreadyLoggedIn: The message to
234
     *                                                      show if the user
235
     *                                                      is already logged
236
     *                                                      in and lacks the
237
     *                                                      permission to
238
     *                                                      access the item.
239
     *
240
     * The alreadyLoggedIn value can contain a '%s' placeholder that will be replaced with a link
241
     * to log in.
242
     * @return HTTPResponse
243
     */
244
    public static function permissionFailure($controller = null, $messageSet = null)
0 ignored issues
show
Coding Style introduced by
permissionFailure uses the super-global variable $_SERVER which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
245
    {
246
        self::set_ignore_disallowed_actions(true);
247
248
        if (!$controller) {
249
            $controller = Controller::curr();
250
        }
251
252
        if (Director::is_ajax()) {
253
            $response = ($controller) ? $controller->getResponse() : new HTTPResponse();
254
            $response->setStatusCode(403);
255
            if (!Member::currentUser()) {
256
                $response->setBody(_t('ContentController.NOTLOGGEDIN', 'Not logged in'));
257
                $response->setStatusDescription(_t('ContentController.NOTLOGGEDIN', 'Not logged in'));
258
                // Tell the CMS to allow re-aunthentication
259
                if (CMSSecurity::enabled()) {
260
                    $response->addHeader('X-Reauthenticate', '1');
261
                }
262
            }
263
            return $response;
264
        }
265
266
        // Prepare the messageSet provided
267
        if (!$messageSet) {
268
            if ($configMessageSet = static::config()->get('default_message_set')) {
269
                $messageSet = $configMessageSet;
270
            } else {
271
                $messageSet = array(
272
                    'default' => _t(
273
                        'Security.NOTEPAGESECURED',
274
                        "That page is secured. Enter your credentials below and we will send "
275
                            . "you right along."
276
                    ),
277
                    'alreadyLoggedIn' => _t(
278
                        'Security.ALREADYLOGGEDIN',
279
                        "You don't have access to this page.  If you have another account that "
280
                            . "can access that page, you can log in again below.",
281
                        "%s will be replaced with a link to log in."
282
                    )
283
                );
284
            }
285
        }
286
287
        if (!is_array($messageSet)) {
288
            $messageSet = array('default' => $messageSet);
289
        }
290
291
        $member = Member::currentUser();
292
293
        // Work out the right message to show
294
        if ($member && $member->exists()) {
295
            $response = ($controller) ? $controller->getResponse() : new HTTPResponse();
296
            $response->setStatusCode(403);
297
298
            //If 'alreadyLoggedIn' is not specified in the array, then use the default
299
            //which should have been specified in the lines above
300
            if (isset($messageSet['alreadyLoggedIn'])) {
301
                $message = $messageSet['alreadyLoggedIn'];
302
            } else {
303
                $message = $messageSet['default'];
304
            }
305
306
            // Somewhat hackish way to render a login form with an error message.
307
            $me = new Security();
308
            $form = $me->LoginForm();
309
            $form->sessionMessage($message, ValidationResult::TYPE_WARNING);
310
            Session::set('MemberLoginForm.force_message', 1);
311
            $loginResponse = $me->login();
312
            if ($loginResponse instanceof HTTPResponse) {
313
                return $loginResponse;
314
            }
315
316
            $response->setBody((string)$loginResponse);
317
318
            $controller->extend('permissionDenied', $member);
319
320
            return $response;
321
        } else {
322
            $message = $messageSet['default'];
323
        }
324
325
        static::setLoginMessage($message, ValidationResult::TYPE_WARNING);
326
327
        Session::set("BackURL", $_SERVER['REQUEST_URI']);
328
329
        // TODO AccessLogEntry needs an extension to handle permission denied errors
330
        // Audit logging hook
331
        $controller->extend('permissionDenied', $member);
332
333
        return $controller->redirect(Controller::join_links(
334
            Security::config()->uninherited('login_url'),
0 ignored issues
show
Coding Style introduced by
As per coding style, self should be used for accessing local static members.

This check looks for accesses to local static members using the fully qualified name instead of self::.

<?php

class Certificate {
    const TRIPLEDES_CBC = 'ASDFGHJKL';

    private $key;

    public function __construct()
    {
        $this->key = Certificate::TRIPLEDES_CBC;
    }
}

While this is perfectly valid, the fully qualified name of Certificate::TRIPLEDES_CBC could just as well be replaced by self::TRIPLEDES_CBC. Referencing local members with self:: assured the access will still work when the class is renamed, makes it perfectly clear that the member is in fact local and will usually be shorter.

Loading history...
335
            "?BackURL=" . urlencode($_SERVER['REQUEST_URI'])
336
        ));
337
    }
338
339
    protected function init()
340
    {
341
        parent::init();
342
343
        // Prevent clickjacking, see https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options
344
        $frameOptions = $this->config()->get('frame_options');
345
        if ($frameOptions) {
346
            $this->getResponse()->addHeader('X-Frame-Options', $frameOptions);
347
        }
348
349
        // Prevent search engines from indexing the login page
350
        $robotsTag = $this->config()->get('robots_tag');
351
        if ($robotsTag) {
352
            $this->getResponse()->addHeader('X-Robots-Tag', $robotsTag);
353
        }
354
    }
355
356
    public function index()
357
    {
358
        return $this->httpError(404); // no-op
359
    }
360
361
    /**
362
     * Get the selected authenticator for this request
363
     *
364
     * @return string Class name of Authenticator
365
     * @throws LogicException
366
     */
367
    protected function getAuthenticator()
368
    {
369
        $authenticator = $this->getRequest()->requestVar('AuthenticationMethod');
370
        if ($authenticator && Authenticator::is_registered($authenticator)) {
371
            return $authenticator;
372
        } elseif ($authenticator !== "" && Authenticator::is_registered(Authenticator::get_default_authenticator())) {
373
            return Authenticator::get_default_authenticator();
374
        }
375
376
        throw new LogicException('No valid authenticator found');
377
    }
378
379
    /**
380
     * Get the login form to process according to the submitted data
381
     *
382
     * @return Form
383
     * @throws Exception
384
     */
385
    public function LoginForm()
386
    {
387
        $authenticator = $this->getAuthenticator();
388
        if ($authenticator) {
389
            return $authenticator::get_login_form($this);
390
        }
391
        throw new Exception('Passed invalid authentication method');
392
    }
393
394
    /**
395
     * Get the login forms for all available authentication methods
396
     *
397
     * @return array Returns an array of available login forms (array of Form
398
     *               objects).
399
     *
400
     * @todo Check how to activate/deactivate authentication methods
401
     */
402
    public function GetLoginForms()
403
    {
404
        $forms = array();
405
406
        $authenticators = Authenticator::get_authenticators();
407
        foreach ($authenticators as $authenticator) {
408
            $forms[] = $authenticator::get_login_form($this);
409
        }
410
411
        return $forms;
412
    }
413
414
415
    /**
416
     * Get a link to a security action
417
     *
418
     * @param string $action Name of the action
419
     * @return string Returns the link to the given action
420
     */
421
    public function Link($action = null)
422
    {
423
        /** @skipUpgrade */
424
        return Controller::join_links(Director::baseURL(), "Security", $action);
425
    }
426
427
    /**
428
     * This action is available as a keep alive, so user
429
     * sessions don't timeout. A common use is in the admin.
430
     */
431
    public function ping()
432
    {
433
        return 1;
434
    }
435
436
    /**
437
     * Log the currently logged in user out
438
     *
439
     * @param bool $redirect Redirect the user back to where they came.
440
     *                       - If it's false, the code calling logout() is
441
     *                         responsible for sending the user where-ever
442
     *                         they should go.
443
     * @return HTTPResponse|null
444
     */
445
    public function logout($redirect = true)
446
    {
447
        $member = Member::currentUser();
448
        if ($member) {
449
            $member->logOut();
450
        }
451
452
        if ($redirect && (!$this->getResponse()->isFinished())) {
453
            return $this->redirectBack();
454
        }
455
        return null;
456
    }
457
458
    /**
459
     * Perform pre-login checking and prepare a response if available prior to login
460
     *
461
     * @return HTTPResponse Substitute response object if the login process should be curcumvented.
462
     * Returns null if should proceed as normal.
463
     */
464
    protected function preLogin()
465
    {
466
        // Event handler for pre-login, with an option to let it break you out of the login form
467
        $eventResults = $this->extend('onBeforeSecurityLogin');
468
        // If there was a redirection, return
469
        if ($this->redirectedTo()) {
470
            return $this->getResponse();
471
        }
472
        // If there was an HTTPResponse object returned, then return that
473
        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...
474
            foreach ($eventResults as $result) {
475
                if ($result instanceof HTTPResponse) {
476
                    return $result;
477
                }
478
            }
479
        }
480
481
        // If arriving on the login page already logged in, with no security error, and a ReturnURL then redirect
482
        // back. The login message check is neccesary to prevent infinite loops where BackURL links to
483
        // an action that triggers Security::permissionFailure.
484
        // This step is necessary in cases such as automatic redirection where a user is authenticated
485
        // upon landing on an SSL secured site and is automatically logged in, or some other case
486
        // where the user has permissions to continue but is not given the option.
487
        if ($this->getRequest()->requestVar('BackURL')
488
            && !$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...
489
            && ($member = Member::currentUser())
490
            && $member->exists()
491
        ) {
492
            return $this->redirectBack();
493
        }
494
495
        return null;
496
    }
497
498
    /**
499
     * Prepare the controller for handling the response to this request
500
     *
501
     * @param string $title Title to use
502
     * @return Controller
503
     */
504
    protected function getResponseController($title)
505
    {
506
        // Use the default setting for which Page to use to render the security page
507
        $pageClass = $this->stat('page_class');
508
        if (!$pageClass || !class_exists($pageClass)) {
509
            return $this;
510
        }
511
512
        // Create new instance of page holder
513
        /** @var Page $holderPage */
514
        $holderPage = new $pageClass;
515
        $holderPage->Title = $title;
516
        /** @skipUpgrade */
517
        $holderPage->URLSegment = 'Security';
518
        // Disable ID-based caching  of the log-in page by making it a random number
519
        $holderPage->ID = -1 * rand(1, 10000000);
520
521
        $controllerClass = $holderPage->getControllerName();
522
        /** @var ContentController $controller */
523
        $controller = $controllerClass::create($holderPage);
524
        $controller->setDataModel($this->model);
525
        $controller->doInit();
526
        return $controller;
527
    }
528
529
    /**
530
     * Combine the given forms into a formset with a tabbed interface
531
     *
532
     * @param array $forms List of LoginForm instances
533
     * @return string
534
     */
535
    protected function generateLoginFormSet($forms)
536
    {
537
        $viewData = new ArrayData(array(
538
            'Forms' => new ArrayList($forms),
539
        ));
540
        return $viewData->renderWith(
541
            $this->getTemplatesFor('MultiAuthenticatorLogin')
542
        );
543
    }
544
545
    /**
546
     * Get the HTML Content for the $Content area during login
547
     *
548
     * @param string &$messageType Type of message, if available, passed back to caller
549
     * @return string Message in HTML format
550
     */
551
    protected function getLoginMessage(&$messageType = null)
552
    {
553
        $message = Session::get('Security.Message.message');
554
        $messageType = null;
555
        if (empty($message)) {
556
            return null;
557
        }
558
559
        $messageType = Session::get('Security.Message.type');
560
        $messageCast = Session::get('Security.Message.cast');
561
        if ($messageCast !== ValidationResult::CAST_HTML) {
562
            $message = Convert::raw2xml($message);
563
        }
564
        return sprintf('<p class="message %s">%s</p>', Convert::raw2att($messageType), $message);
565
    }
566
567
    /**
568
     * Set the next message to display for the security login page. Defaults to warning
569
     *
570
     * @param string $message Message
571
     * @param string $messageType Message type. One of ValidationResult::TYPE_*
572
     * @param string $messageCast Message cast. One of ValidationResult::CAST_*
573
     */
574
    public static function setLoginMessage(
575
        $message,
576
        $messageType = ValidationResult::TYPE_WARNING,
577
        $messageCast = ValidationResult::CAST_TEXT
578
    ) {
579
        Session::set("Security.Message.message", $message);
580
        Session::set("Security.Message.type", $messageType);
581
        Session::set("Security.Message.cast", $messageCast);
582
    }
583
584
    /**
585
     * Clear login message
586
     */
587
    public static function clearLoginMessage()
588
    {
589
        Session::clear("Security.Message");
590
    }
591
592
593
    /**
594
     * Show the "login" page
595
     *
596
     * For multiple authenticators, Security_MultiAuthenticatorLogin is used.
597
     * See getTemplatesFor and getIncludeTemplate for how to override template logic
598
     *
599
     * @return string|HTTPResponse Returns the "login" page as HTML code.
600
     */
601
    public function login()
602
    {
603
        // Check pre-login process
604
        if ($response = $this->preLogin()) {
605
            return $response;
606
        }
607
608
        // Get response handler
609
        $controller = $this->getResponseController(_t('Security.LOGIN', 'Log in'));
610
611
        // if the controller calls Director::redirect(), this will break early
612
        if (($response = $controller->getResponse()) && $response->isFinished()) {
613
            return $response;
614
        }
615
616
        $forms = $this->GetLoginForms();
617
        if (!count($forms)) {
618
            user_error(
619
                'No login-forms found, please use Authenticator::register_authenticator() to add one',
620
                E_USER_ERROR
621
            );
622
        }
623
624
        // Handle any form messages from validation, etc.
625
        $messageType = '';
626
        $message = $this->getLoginMessage($messageType);
627
628
        // We've displayed the message in the form output, so reset it for the next run.
629
        static::clearLoginMessage();
630
631
        // only display tabs when more than one authenticator is provided
632
        // to save bandwidth and reduce the amount of custom styling needed
633
        if (count($forms) > 1) {
634
            $content = $this->generateLoginFormSet($forms);
635
        } else {
636
            $content = $forms[0]->forTemplate();
637
        }
638
639
        // Finally, customise the controller to add any form messages and the form.
640
        $customisedController = $controller->customise(array(
641
            "Content" => DBField::create_field('HTMLFragment', $message),
642
            "Message" => DBField::create_field('HTMLFragment', $message),
643
            "MessageType" => $messageType,
644
            "Form" => $content,
645
        ));
646
647
        // Return the customised controller
648
        return $customisedController->renderWith(
649
            $this->getTemplatesFor('login')
650
        );
651
    }
652
653
    public function basicauthlogin()
654
    {
655
        $member = BasicAuth::requireLogin("SilverStripe login", 'ADMIN');
656
        $member->logIn();
657
    }
658
659
    /**
660
     * Show the "lost password" page
661
     *
662
     * @return string Returns the "lost password" page as HTML code.
663
     */
664
    public function lostpassword()
665
    {
666
        $controller = $this->getResponseController(_t('Security.LOSTPASSWORDHEADER', 'Lost Password'));
667
668
        // if the controller calls Director::redirect(), this will break early
669
        if (($response = $controller->getResponse()) && $response->isFinished()) {
670
            return $response;
671
        }
672
673
        $message = _t(
674
            'Security.NOTERESETPASSWORD',
675
            'Enter your e-mail address and we will send you a link with which you can reset your password'
676
        );
677
        /** @var ViewableData_Customised $customisedController */
678
        $customisedController = $controller->customise(array(
679
            'Content' => DBField::create_field('HTMLFragment', "<p>$message</p>"),
680
            'Form' => $this->LostPasswordForm(),
681
        ));
682
683
        //Controller::$currentController = $controller;
0 ignored issues
show
Unused Code Comprehensibility introduced by
50% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
684
        $result = $customisedController->renderWith($this->getTemplatesFor('lostpassword'));
685
686
        return $result;
687
    }
688
689
690
    /**
691
     * Factory method for the lost password form
692
     *
693
     * @skipUpgrade
694
     * @return Form Returns the lost password form
695
     */
696
    public function LostPasswordForm()
697
    {
698
        return MemberLoginForm::create(
699
            $this,
700
            Config::inst()->get('Authenticator', 'default_authenticator'),
701
            'LostPasswordForm',
702
            new FieldList(
703
                new EmailField('Email', _t('Member.EMAIL', 'Email'))
704
            ),
705
            new FieldList(
706
                new FormAction(
707
                    'forgotPassword',
708
                    _t('Security.BUTTONSEND', 'Send me the password reset link')
709
                )
710
            ),
711
            false
712
        );
713
    }
714
715
716
    /**
717
     * Show the "password sent" page, after a user has requested
718
     * to reset their password.
719
     *
720
     * @param HTTPRequest $request The HTTPRequest for this action.
721
     * @return string Returns the "password sent" page as HTML code.
722
     */
723
    public function passwordsent($request)
724
    {
725
        $controller = $this->getResponseController(_t('Security.LOSTPASSWORDHEADER', 'Lost Password'));
726
727
        // if the controller calls Director::redirect(), this will break early
728
        if (($response = $controller->getResponse()) && $response->isFinished()) {
729
            return $response;
730
        }
731
732
        $email = Convert::raw2xml(rawurldecode($request->param('ID')) . '.' . $request->getExtension());
733
734
        $message = _t(
735
            'Security.PASSWORDSENTTEXT',
736
            "Thank you! A reset link has been sent to '{email}', provided an account exists for this email"
737
            . " address.",
738
            array('email' => Convert::raw2xml($email))
739
        );
740
        $customisedController = $controller->customise(array(
741
            'Title' => _t(
742
                'Security.PASSWORDSENTHEADER',
743
                "Password reset link sent to '{email}'",
744
                array('email' => $email)
745
            ),
746
            'Content' => DBField::create_field('HTMLFragment', "<p>$message</p>"),
747
            'Email' => $email
748
        ));
749
750
        //Controller::$currentController = $controller;
0 ignored issues
show
Unused Code Comprehensibility introduced by
50% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
751
        return $customisedController->renderWith($this->getTemplatesFor('passwordsent'));
752
    }
753
754
755
    /**
756
     * Create a link to the password reset form.
757
     *
758
     * GET parameters used:
759
     * - m: member ID
760
     * - t: plaintext token
761
     *
762
     * @param Member $member Member object associated with this link.
763
     * @param string $autologinToken The auto login token.
764
     * @return string
765
     */
766
    public static function getPasswordResetLink($member, $autologinToken)
767
    {
768
        $autologinToken = urldecode($autologinToken);
769
        $selfControllerClass = __CLASS__;
770
        /** @var static $selfController */
771
        $selfController = new $selfControllerClass();
772
        return $selfController->Link('changepassword') . "?m={$member->ID}&t=$autologinToken";
773
    }
774
775
    /**
776
     * Show the "change password" page.
777
     * This page can either be called directly by logged-in users
778
     * (in which case they need to provide their old password),
779
     * or through a link emailed through {@link lostpassword()}.
780
     * In this case no old password is required, authentication is ensured
781
     * through the Member.AutoLoginHash property.
782
     *
783
     * @see ChangePasswordForm
784
     *
785
     * @return string|HTTPRequest Returns the "change password" page as HTML code, or a redirect response
786
     */
787
    public function changepassword()
0 ignored issues
show
Coding Style introduced by
changepassword uses the super-global variable $_REQUEST which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

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