Completed
Push — master ( 644ae6...bba86b )
by Daniel
10:38
created

Security::getResponseController()   B

Complexity

Conditions 3
Paths 2

Size

Total Lines 24
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 13
nc 2
nop 1
dl 0
loc 24
rs 8.9713
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Security;
4
5
use Page;
6
use SilverStripe\CMS\Controllers\ContentController;
7
use SilverStripe\Control\Controller;
8
use SilverStripe\Control\Director;
9
use SilverStripe\Control\HTTPRequest;
10
use SilverStripe\Control\HTTPResponse;
11
use SilverStripe\Control\Session;
12
use SilverStripe\Core\ClassInfo;
13
use SilverStripe\Core\Convert;
14
use SilverStripe\Dev\Deprecation;
15
use SilverStripe\Dev\TestOnly;
16
use SilverStripe\Forms\EmailField;
17
use SilverStripe\Forms\FieldList;
18
use SilverStripe\Forms\Form;
19
use SilverStripe\Forms\FormAction;
20
use SilverStripe\ORM\ArrayList;
21
use SilverStripe\ORM\DB;
22
use SilverStripe\ORM\DataObject;
23
use SilverStripe\ORM\FieldType\DBField;
24
use SilverStripe\ORM\ValidationResult;
25
use SilverStripe\View\ArrayData;
26
use SilverStripe\View\SSViewer;
27
use SilverStripe\View\TemplateGlobalProvider;
28
use Exception;
29
use SilverStripe\View\ViewableData_Customised;
30
use Subsite;
31
32
/**
33
 * Implements a basic security model
34
 */
35
class Security extends Controller implements TemplateGlobalProvider
36
{
37
38
    private static $allowed_actions = array(
39
        'index',
40
        'login',
41
        'logout',
42
        'basicauthlogin',
43
        'lostpassword',
44
        'passwordsent',
45
        'changepassword',
46
        'ping',
47
        'LoginForm',
48
        'ChangePasswordForm',
49
        'LostPasswordForm',
50
    );
51
52
    /**
53
     * Default user name. Only used in dev-mode by {@link setDefaultAdmin()}
54
     *
55
     * @var string
56
     * @see setDefaultAdmin()
57
     */
58
    protected static $default_username;
59
60
    /**
61
     * Default password. Only used in dev-mode by {@link setDefaultAdmin()}
62
     *
63
     * @var string
64
     * @see setDefaultAdmin()
65
     */
66
    protected static $default_password;
67
68
    /**
69
     * If set to TRUE to prevent sharing of the session across several sites
70
     * in the domain.
71
     *
72
     * @config
73
     * @var bool
74
     */
75
    protected static $strict_path_checking = false;
76
77
    /**
78
     * The password encryption algorithm to use by default.
79
     * This is an arbitrary code registered through {@link PasswordEncryptor}.
80
     *
81
     * @config
82
     * @var string
83
     */
84
    private static $password_encryption_algorithm = 'blowfish';
85
86
    /**
87
     * Showing "Remember me"-checkbox
88
     * on loginform, and saving encrypted credentials to a cookie.
89
     *
90
     * @config
91
     * @var bool
92
     */
93
    private static $autologin_enabled = true;
94
95
    /**
96
     * Determine if login username may be remembered between login sessions
97
     * If set to false this will disable autocomplete and prevent username persisting in the session
98
     *
99
     * @config
100
     * @var bool
101
     */
102
    private static $remember_username = true;
103
104
    /**
105
     * Location of word list to use for generating passwords
106
     *
107
     * @config
108
     * @var string
109
     */
110
    private static $word_list = './wordlist.txt';
111
112
    /**
113
     * @config
114
     * @var string
115
     */
116
    private static $template = 'BlankPage';
117
118
    /**
119
     * Template thats used to render the pages.
120
     *
121
     * @var string
122
     * @config
123
     */
124
    private static $template_main = 'Page';
125
126
    /**
127
     * Class to use for page rendering
128
     *
129
     * @var string
130
     * @config
131
     */
132
    private static $page_class = Page::class;
133
134
    /**
135
     * Default message set used in permission failures.
136
     *
137
     * @config
138
     * @var array|string
139
     */
140
    private static $default_message_set;
141
142
    /**
143
     * Random secure token, can be used as a crypto key internally.
144
     * Generate one through 'sake dev/generatesecuretoken'.
145
     *
146
     * @config
147
     * @var String
148
     */
149
    private static $token;
150
151
    /**
152
     * The default login URL
153
     *
154
     * @config
155
     *
156
     * @var string
157
     */
158
    private static $login_url = "Security/login";
159
160
    /**
161
     * The default logout URL
162
     *
163
     * @config
164
     *
165
     * @var string
166
     */
167
    private static $logout_url = "Security/logout";
168
169
    /**
170
     * The default lost password URL
171
     *
172
     * @config
173
     *
174
     * @var string
175
     */
176
    private static $lost_password_url = "Security/lostpassword";
177
178
    /**
179
     * Value of X-Frame-Options header
180
     *
181
     * @config
182
     * @var string
183
     */
184
    private static $frame_options = 'SAMEORIGIN';
185
186
    /**
187
     * Value of the X-Robots-Tag header (for the Security section)
188
     *
189
     * @config
190
     * @var string
191
     */
192
    private static $robots_tag = 'noindex, nofollow';
193
194
    /**
195
     * Enable or disable recording of login attempts
196
     * through the {@link LoginRecord} object.
197
     *
198
     * @config
199
     * @var boolean $login_recording
200
     */
201
    private static $login_recording = false;
202
203
    /**
204
     * @var boolean If set to TRUE or FALSE, {@link database_is_ready()}
205
     * will always return FALSE. Used for unit testing.
206
     */
207
    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...
208
209
    /**
210
     * When the database has once been verified as ready, it will not do the
211
     * checks again.
212
     *
213
     * @var bool
214
     */
215
    static $database_is_ready = false;
0 ignored issues
show
Coding Style introduced by
The visibility should be declared for property $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...
216
217
    /**
218
     * Register that we've had a permission failure trying to view the given page
219
     *
220
     * This will redirect to a login page.
221
     * If you don't provide a messageSet, a default will be used.
222
     *
223
     * @param Controller $controller The controller that you were on to cause the permission
224
     *                               failure.
225
     * @param string|array $messageSet The message to show to the user. This
226
     *                                 can be a string, or a map of different
227
     *                                 messages for different contexts.
228
     *                                 If you pass an array, you can use the
229
     *                                 following keys:
230
     *                                   - default: The default message
231
     *                                   - alreadyLoggedIn: The message to
232
     *                                                      show if the user
233
     *                                                      is already logged
234
     *                                                      in and lacks the
235
     *                                                      permission to
236
     *                                                      access the item.
237
     *
238
     * The alreadyLoggedIn value can contain a '%s' placeholder that will be replaced with a link
239
     * to log in.
240
     * @return HTTPResponse
241
     */
242
    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...
243
    {
244
        self::set_ignore_disallowed_actions(true);
245
246
        if (!$controller) {
247
            $controller = Controller::curr();
248
        }
249
250
        if (Director::is_ajax()) {
251
            $response = ($controller) ? $controller->getResponse() : new HTTPResponse();
252
            $response->setStatusCode(403);
253
            if (!Member::currentUser()) {
254
                $response->setBody(_t('ContentController.NOTLOGGEDIN', 'Not logged in'));
255
                $response->setStatusDescription(_t('ContentController.NOTLOGGEDIN', 'Not logged in'));
256
                // Tell the CMS to allow re-aunthentication
257
                if (CMSSecurity::enabled()) {
258
                    $response->addHeader('X-Reauthenticate', '1');
259
                }
260
            }
261
            return $response;
262
        }
263
264
        // Prepare the messageSet provided
265
        if (!$messageSet) {
266
            if ($configMessageSet = static::config()->get('default_message_set')) {
267
                $messageSet = $configMessageSet;
268
            } else {
269
                $messageSet = array(
270
                    'default' => _t(
271
                        'Security.NOTEPAGESECURED',
272
                        "That page is secured. Enter your credentials below and we will send "
273
                            . "you right along."
274
                    ),
275
                    'alreadyLoggedIn' => _t(
276
                        'Security.ALREADYLOGGEDIN',
277
                        "You don't have access to this page.  If you have another account that "
278
                            . "can access that page, you can log in again below.",
279
                        "%s will be replaced with a link to log in."
280
                    )
281
                );
282
            }
283
        }
284
285
        if (!is_array($messageSet)) {
286
            $messageSet = array('default' => $messageSet);
287
        }
288
289
        $member = Member::currentUser();
290
291
        // Work out the right message to show
292
        if ($member && $member->exists()) {
293
            $response = ($controller) ? $controller->getResponse() : new HTTPResponse();
294
            $response->setStatusCode(403);
295
296
            //If 'alreadyLoggedIn' is not specified in the array, then use the default
297
            //which should have been specified in the lines above
298
            if (isset($messageSet['alreadyLoggedIn'])) {
299
                $message = $messageSet['alreadyLoggedIn'];
300
            } else {
301
                $message = $messageSet['default'];
302
            }
303
304
            // Somewhat hackish way to render a login form with an error message.
305
            $me = new Security();
306
            $form = $me->LoginForm();
307
            $form->sessionMessage($message, ValidationResult::TYPE_WARNING);
308
            Session::set('MemberLoginForm.force_message', 1);
309
            $loginResponse = $me->login();
310
            if ($loginResponse instanceof HTTPResponse) {
311
                return $loginResponse;
312
            }
313
314
            $response->setBody((string)$loginResponse);
315
316
            $controller->extend('permissionDenied', $member);
317
318
            return $response;
319
        } else {
320
            $message = $messageSet['default'];
321
        }
322
323
        static::setLoginMessage($message, ValidationResult::TYPE_WARNING);
324
325
        Session::set("BackURL", $_SERVER['REQUEST_URI']);
326
327
        // TODO AccessLogEntry needs an extension to handle permission denied errors
328
        // Audit logging hook
329
        $controller->extend('permissionDenied', $member);
330
331
        return $controller->redirect(Controller::join_links(
332
            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...
333
            "?BackURL=" . urlencode($_SERVER['REQUEST_URI'])
334
        ));
335
    }
336
337
    protected function init()
338
    {
339
        parent::init();
340
341
        // Prevent clickjacking, see https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options
342
        $frameOptions = $this->config()->get('frame_options');
343
        if ($frameOptions) {
344
            $this->getResponse()->addHeader('X-Frame-Options', $frameOptions);
345
        }
346
347
        // Prevent search engines from indexing the login page
348
        $robotsTag = $this->config()->get('robots_tag');
349
        if ($robotsTag) {
350
            $this->getResponse()->addHeader('X-Robots-Tag', $robotsTag);
351
        }
352
    }
353
354
    public function index()
355
    {
356
        return $this->httpError(404); // no-op
357
    }
358
359
    /**
360
     * Get the selected authenticator for this request
361
     *
362
     * @return string Class name of Authenticator
363
     */
364
    protected function getAuthenticator()
365
    {
366
        $authenticator = $this->getRequest()->requestVar('AuthenticationMethod');
367
        if ($authenticator) {
368
            $authenticators = Authenticator::get_authenticators();
369
            if (in_array($authenticator, $authenticators)) {
370
                return $authenticator;
371
            }
372
        }
373
        return Authenticator::get_default_authenticator();
374
    }
375
376
    /**
377
     * Get the login form to process according to the submitted data
378
     *
379
     * @return Form
380
     * @throws Exception
381
     */
382
    public function LoginForm()
383
    {
384
        $authenticator = $this->getAuthenticator();
385
        if ($authenticator) {
386
            return $authenticator::get_login_form($this);
387
        }
388
        throw new Exception('Passed invalid authentication method');
389
    }
390
391
    /**
392
     * Get the login forms for all available authentication methods
393
     *
394
     * @return array Returns an array of available login forms (array of Form
395
     *               objects).
396
     *
397
     * @todo Check how to activate/deactivate authentication methods
398
     */
399
    public function GetLoginForms()
400
    {
401
        $forms = array();
402
403
        $authenticators = Authenticator::get_authenticators();
404
        foreach ($authenticators as $authenticator) {
405
            $forms[] = $authenticator::get_login_form($this);
406
        }
407
408
        return $forms;
409
    }
410
411
412
    /**
413
     * Get a link to a security action
414
     *
415
     * @param string $action Name of the action
416
     * @return string Returns the link to the given action
417
     */
418
    public function Link($action = null)
419
    {
420
        /** @skipUpgrade */
421
        return Controller::join_links(Director::baseURL(), "Security", $action);
422
    }
423
424
    /**
425
     * This action is available as a keep alive, so user
426
     * sessions don't timeout. A common use is in the admin.
427
     */
428
    public function ping()
429
    {
430
        return 1;
431
    }
432
433
    /**
434
     * Log the currently logged in user out
435
     *
436
     * @param bool $redirect Redirect the user back to where they came.
437
     *                       - If it's false, the code calling logout() is
438
     *                         responsible for sending the user where-ever
439
     *                         they should go.
440
     * @return HTTPResponse|null
441
     */
442
    public function logout($redirect = true)
443
    {
444
        $member = Member::currentUser();
445
        if ($member) {
446
            $member->logOut();
447
        }
448
449
        if ($redirect && (!$this->getResponse()->isFinished())) {
450
            return $this->redirectBack();
451
        }
452
        return null;
453
    }
454
455
    /**
456
     * Perform pre-login checking and prepare a response if available prior to login
457
     *
458
     * @return HTTPResponse Substitute response object if the login process should be curcumvented.
459
     * Returns null if should proceed as normal.
460
     */
461
    protected function preLogin()
462
    {
463
        // Event handler for pre-login, with an option to let it break you out of the login form
464
        $eventResults = $this->extend('onBeforeSecurityLogin');
465
        // If there was a redirection, return
466
        if ($this->redirectedTo()) {
467
            return $this->getResponse();
468
        }
469
        // If there was an HTTPResponse object returned, then return that
470
        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...
471
            foreach ($eventResults as $result) {
472
                if ($result instanceof HTTPResponse) {
473
                    return $result;
474
                }
475
            }
476
        }
477
478
        // If arriving on the login page already logged in, with no security error, and a ReturnURL then redirect
479
        // back. The login message check is neccesary to prevent infinite loops where BackURL links to
480
        // an action that triggers Security::permissionFailure.
481
        // This step is necessary in cases such as automatic redirection where a user is authenticated
482
        // upon landing on an SSL secured site and is automatically logged in, or some other case
483
        // where the user has permissions to continue but is not given the option.
484
        if ($this->getRequest()->requestVar('BackURL')
485
            && !$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...
486
            && ($member = Member::currentUser())
487
            && $member->exists()
488
        ) {
489
            return $this->redirectBack();
490
        }
491
492
        return null;
493
    }
494
495
    /**
496
     * Prepare the controller for handling the response to this request
497
     *
498
     * @param string $title Title to use
499
     * @return Controller
500
     */
501
    protected function getResponseController($title)
502
    {
503
        // Use the default setting for which Page to use to render the security page
504
        $pageClass = $this->stat('page_class');
505
        if (!$pageClass || !class_exists($pageClass)) {
506
            return $this;
507
        }
508
509
        // Create new instance of page holder
510
        /** @var Page $holderPage */
511
        $holderPage = new $pageClass;
512
        $holderPage->Title = $title;
513
        /** @skipUpgrade */
514
        $holderPage->URLSegment = 'Security';
515
        // Disable ID-based caching  of the log-in page by making it a random number
516
        $holderPage->ID = -1 * rand(1, 10000000);
517
518
        $controllerClass = $holderPage->getControllerName();
519
        /** @var ContentController $controller */
520
        $controller = $controllerClass::create($holderPage);
521
        $controller->setDataModel($this->model);
522
        $controller->doInit();
523
        return $controller;
524
    }
525
526
    /**
527
     * Combine the given forms into a formset with a tabbed interface
528
     *
529
     * @param array $forms List of LoginForm instances
530
     * @return string
531
     */
532
    protected function generateLoginFormSet($forms)
533
    {
534
        $viewData = new ArrayData(array(
535
            'Forms' => new ArrayList($forms),
536
        ));
537
        return $viewData->renderWith(
538
            $this->getTemplatesFor('MultiAuthenticatorLogin')
539
        );
540
    }
541
542
    /**
543
     * Get the HTML Content for the $Content area during login
544
     *
545
     * @param string &$messageType Type of message, if available, passed back to caller
546
     * @return string Message in HTML format
547
     */
548
    protected function getLoginMessage(&$messageType = null)
549
    {
550
        $message = Session::get('Security.Message.message');
551
        $messageType = null;
552
        if (empty($message)) {
553
            return null;
554
        }
555
556
        $messageType = Session::get('Security.Message.type');
557
        $messageCast = Session::get('Security.Message.cast');
558
        if ($messageCast !== ValidationResult::CAST_HTML) {
559
            $message = Convert::raw2xml($message);
560
        }
561
        return sprintf('<p class="message %s">%s</p>', Convert::raw2att($messageType), $message);
562
    }
563
564
    /**
565
     * Set the next message to display for the security login page. Defaults to warning
566
     *
567
     * @param string $message Message
568
     * @param string $messageType Message type. One of ValidationResult::TYPE_*
569
     * @param string $messageCast Message cast. One of ValidationResult::CAST_*
570
     */
571
    public static function setLoginMessage(
572
        $message,
573
        $messageType = ValidationResult::TYPE_WARNING,
574
        $messageCast = ValidationResult::CAST_TEXT
575
    ) {
576
        Session::set("Security.Message.message", $message);
577
        Session::set("Security.Message.type", $messageType);
578
        Session::set("Security.Message.cast", $messageCast);
579
    }
580
581
    /**
582
     * Clear login message
583
     */
584
    public static function clearLoginMessage()
585
    {
586
        Session::clear("Security.Message");
587
    }
588
589
590
    /**
591
     * Show the "login" page
592
     *
593
     * For multiple authenticators, Security_MultiAuthenticatorLogin is used.
594
     * See getTemplatesFor and getIncludeTemplate for how to override template logic
595
     *
596
     * @return string|HTTPResponse Returns the "login" page as HTML code.
597
     */
598
    public function login()
599
    {
600
        // Check pre-login process
601
        if ($response = $this->preLogin()) {
602
            return $response;
603
        }
604
605
        // Get response handler
606
        $controller = $this->getResponseController(_t('Security.LOGIN', 'Log in'));
607
608
        // if the controller calls Director::redirect(), this will break early
609
        if (($response = $controller->getResponse()) && $response->isFinished()) {
610
            return $response;
611
        }
612
613
        $forms = $this->GetLoginForms();
614
        if (!count($forms)) {
615
            user_error(
616
                'No login-forms found, please use Authenticator::register_authenticator() to add one',
617
                E_USER_ERROR
618
            );
619
        }
620
621
        // Handle any form messages from validation, etc.
622
        $messageType = '';
623
        $message = $this->getLoginMessage($messageType);
624
625
        // We've displayed the message in the form output, so reset it for the next run.
626
        static::clearLoginMessage();
627
628
        // only display tabs when more than one authenticator is provided
629
        // to save bandwidth and reduce the amount of custom styling needed
630
        if (count($forms) > 1) {
631
            $content = $this->generateLoginFormSet($forms);
632
        } else {
633
            $content = $forms[0]->forTemplate();
634
        }
635
636
        // Finally, customise the controller to add any form messages and the form.
637
        $customisedController = $controller->customise(array(
638
            "Content" => DBField::create_field('HTMLFragment', $message),
639
            "Message" => DBField::create_field('HTMLFragment', $message),
640
            "MessageType" => $messageType,
641
            "Form" => $content,
642
        ));
643
644
        // Return the customised controller
645
        return $customisedController->renderWith(
646
            $this->getTemplatesFor('login')
647
        );
648
    }
649
650
    public function basicauthlogin()
651
    {
652
        $member = BasicAuth::requireLogin("SilverStripe login", 'ADMIN');
653
        $member->logIn();
654
    }
655
656
    /**
657
     * Show the "lost password" page
658
     *
659
     * @return string Returns the "lost password" page as HTML code.
660
     */
661
    public function lostpassword()
662
    {
663
        $controller = $this->getResponseController(_t('Security.LOSTPASSWORDHEADER', 'Lost Password'));
664
665
        // if the controller calls Director::redirect(), this will break early
666
        if (($response = $controller->getResponse()) && $response->isFinished()) {
667
            return $response;
668
        }
669
670
        $message = _t(
671
            'Security.NOTERESETPASSWORD',
672
            'Enter your e-mail address and we will send you a link with which you can reset your password'
673
        );
674
        /** @var ViewableData_Customised $customisedController */
675
        $customisedController = $controller->customise(array(
676
            'Content' => DBField::create_field('HTMLFragment', "<p>$message</p>"),
677
            'Form' => $this->LostPasswordForm(),
678
        ));
679
680
        //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...
681
        $result = $customisedController->renderWith($this->getTemplatesFor('lostpassword'));
682
683
        return $result;
684
    }
685
686
687
    /**
688
     * Factory method for the lost password form
689
     *
690
     * @skipUpgrade
691
     * @return Form Returns the lost password form
692
     */
693
    public function LostPasswordForm()
694
    {
695
        return MemberLoginForm::create(
696
            $this,
697
            'LostPasswordForm',
698
            new FieldList(
699
                new EmailField('Email', _t('Member.EMAIL', 'Email'))
700
            ),
701
            new FieldList(
702
                new FormAction(
703
                    'forgotPassword',
704
                    _t('Security.BUTTONSEND', 'Send me the password reset link')
705
                )
706
            ),
707
            false
708
        );
709
    }
710
711
712
    /**
713
     * Show the "password sent" page, after a user has requested
714
     * to reset their password.
715
     *
716
     * @param HTTPRequest $request The HTTPRequest for this action.
717
     * @return string Returns the "password sent" page as HTML code.
718
     */
719
    public function passwordsent($request)
720
    {
721
        $controller = $this->getResponseController(_t('Security.LOSTPASSWORDHEADER', 'Lost Password'));
722
723
        // if the controller calls Director::redirect(), this will break early
724
        if (($response = $controller->getResponse()) && $response->isFinished()) {
725
            return $response;
726
        }
727
728
        $email = Convert::raw2xml(rawurldecode($request->param('ID')) . '.' . $request->getExtension());
729
730
        $message = _t(
731
            'Security.PASSWORDSENTTEXT',
732
            "Thank you! A reset link has been sent to '{email}', provided an account exists for this email"
733
            . " address.",
734
            array('email' => Convert::raw2xml($email))
735
        );
736
        $customisedController = $controller->customise(array(
737
            'Title' => _t(
738
                'Security.PASSWORDSENTHEADER',
739
                "Password reset link sent to '{email}'",
740
                array('email' => $email)
741
            ),
742
            'Content' => DBField::create_field('HTMLFragment', "<p>$message</p>"),
743
            'Email' => $email
744
        ));
745
746
        //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...
747
        return $customisedController->renderWith($this->getTemplatesFor('passwordsent'));
748
    }
749
750
751
    /**
752
     * Create a link to the password reset form.
753
     *
754
     * GET parameters used:
755
     * - m: member ID
756
     * - t: plaintext token
757
     *
758
     * @param Member $member Member object associated with this link.
759
     * @param string $autologinToken The auto login token.
760
     * @return string
761
     */
762
    public static function getPasswordResetLink($member, $autologinToken)
763
    {
764
        $autologinToken = urldecode($autologinToken);
765
        $selfControllerClass = __CLASS__;
766
        /** @var static $selfController */
767
        $selfController = new $selfControllerClass();
768
        return $selfController->Link('changepassword') . "?m={$member->ID}&t=$autologinToken";
769
    }
770
771
    /**
772
     * Show the "change password" page.
773
     * This page can either be called directly by logged-in users
774
     * (in which case they need to provide their old password),
775
     * or through a link emailed through {@link lostpassword()}.
776
     * In this case no old password is required, authentication is ensured
777
     * through the Member.AutoLoginHash property.
778
     *
779
     * @see ChangePasswordForm
780
     *
781
     * @return string|HTTPRequest Returns the "change password" page as HTML code, or a redirect response
782
     */
783
    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...
784
    {
785
        $controller = $this->getResponseController(_t('Security.CHANGEPASSWORDHEADER', 'Change your password'));
786
787
        // if the controller calls Director::redirect(), this will break early
788
        if (($response = $controller->getResponse()) && $response->isFinished()) {
789
            return $response;
790
        }
791
792
        // Extract the member from the URL.
793
        /** @var Member $member */
794
        $member = null;
795
        if (isset($_REQUEST['m'])) {
796
            $member = Member::get()->filter('ID', (int)$_REQUEST['m'])->first();
797
        }
798
799
        // Check whether we are merely changin password, or resetting.
800
        if (isset($_REQUEST['t']) && $member && $member->validateAutoLoginToken($_REQUEST['t'])) {
801
            // On first valid password reset request redirect to the same URL without hash to avoid referrer leakage.
802
803
            // if there is a current member, they should be logged out
804
            if ($curMember = Member::currentUser()) {
805
                $curMember->logOut();
806
            }
807
808
            // Store the hash for the change password form. Will be unset after reload within the ChangePasswordForm.
809
            Session::set('AutoLoginHash', $member->encryptWithUserSettings($_REQUEST['t']));
810
811
            return $this->redirect($this->Link('changepassword'));
812
        } elseif (Session::get('AutoLoginHash')) {
813
            // Subsequent request after the "first load with hash" (see previous if clause).
814
            $customisedController = $controller->customise(array(
815
                'Content' => DBField::create_field(
816
                    'HTMLFragment',
817
                    '<p>' . _t('Security.ENTERNEWPASSWORD', 'Please enter a new password.') . '</p>'
818
                ),
819
                'Form' => $this->ChangePasswordForm(),
820
            ));
821
        } elseif (Member::currentUser()) {
822
            // Logged in user requested a password change form.
823
            $customisedController = $controller->customise(array(
824
                'Content' => DBField::create_field(
825
                    'HTMLFragment',
826
                    '<p>' . _t('Security.CHANGEPASSWORDBELOW', 'You can change your password below.') . '</p>'
827
                ),
828
                'Form' => $this->ChangePasswordForm()));
829
        } else {
830
            // Show friendly message if it seems like the user arrived here via password reset feature.
831
            if (isset($_REQUEST['m']) || isset($_REQUEST['t'])) {
832
                $customisedController = $controller->customise(
833
                    array('Content' => DBField::create_field(
834
                        'HTMLFragment',
835
                        _t(
836
                            'Security.NOTERESETLINKINVALID',
837
                            '<p>The password reset link is invalid or expired.</p>'
838
                            . '<p>You can request a new one <a href="{link1}">here</a> or change your password after'
839
                            . ' you <a href="{link2}">logged in</a>.</p>',
840
                            [
841
                                'link1' => $this->Link('lostpassword'),
842
                                'link2' => $this->Link('login')
843
                            ]
844
                        )
845
                    ))
846
                );
847
            } else {
848
                return self::permissionFailure(
849
                    $this,
850
                    _t('Security.ERRORPASSWORDPERMISSION', 'You must be logged in in order to change your password!')
851
                );
852
            }
853
        }
854
855
        return $customisedController->renderWith($this->getTemplatesFor('changepassword'));
856
    }
857
858
    /**
859
     * Factory method for the lost password form
860
     *
861
     * @skipUpgrade
862
     * @return ChangePasswordForm Returns the lost password form
863
     */
864
    public function ChangePasswordForm()
865
    {
866
        return ChangePasswordForm::create($this, 'ChangePasswordForm');
867
    }
868
869
    /**
870
     * Determine the list of templates to use for rendering the given action.
871
     *
872
     * @skipUpgrade
873
     * @param string $action
874
     * @return array Template list
875
     */
876
    public function getTemplatesFor($action)
877
    {
878
        $templates = SSViewer::get_templates_by_class(get_class($this), "_{$action}", __CLASS__);
879
        return array_merge(
880
            $templates,
881
            [
882
                "Security_{$action}",
883
                "Security",
884
                $this->stat("template_main"),
885
                "BlankPage"
886
            ]
887
        );
888
    }
889
890
    /**
891
     * Return an existing member with administrator privileges, or create one of necessary.
892
     *
893
     * Will create a default 'Administrators' group if no group is found
894
     * with an ADMIN permission. Will create a new 'Admin' member with administrative permissions
895
     * if no existing Member with these permissions is found.
896
     *
897
     * Important: Any newly created administrator accounts will NOT have valid
898
     * login credentials (Email/Password properties), which means they can't be used for login
899
     * purposes outside of any default credentials set through {@link Security::setDefaultAdmin()}.
900
     *
901
     * @return Member
902
     */
903
    public static function findAnAdministrator()
904
    {
905
        // coupling to subsites module
906
        $origSubsite = null;
907
        if (is_callable('Subsite::changeSubsite')) {
908
            $origSubsite = Subsite::currentSubsiteID();
909
            Subsite::changeSubsite(0);
910
        }
911
912
        /** @var Member $member */
913
        $member = null;
914
915
        // find a group with ADMIN permission
916
        $adminGroup = Permission::get_groups_by_permission('ADMIN')->first();
917
918
        if (is_callable('Subsite::changeSubsite')) {
919
            Subsite::changeSubsite($origSubsite);
920
        }
921
922
        if ($adminGroup) {
923
            $member = $adminGroup->Members()->First();
924
        }
925
926
        if (!$adminGroup) {
927
            Group::singleton()->requireDefaultRecords();
928
            $adminGroup = Permission::get_groups_by_permission('ADMIN')->first();
929
        }
930
931
        if (!$member) {
932
            Member::singleton()->requireDefaultRecords();
933
            $member = Permission::get_members_by_permission('ADMIN')->first();
934
        }
935
936
        if (!$member) {
937
            $member = Member::default_admin();
938
        }
939
940
        if (!$member) {
941
            // Failover to a blank admin
942
            $member = Member::create();
943
            $member->FirstName = _t('Member.DefaultAdminFirstname', 'Default Admin');
944
            $member->write();
945
            // Add member to group instead of adding group to member
946
            // This bypasses the privilege escallation code in Member_GroupSet
947
            $adminGroup
948
                ->DirectMembers()
949
                ->add($member);
950
        }
951
952
        return $member;
953
    }
954
955
    /**
956
     * Flush the default admin credentials
957
     */
958
    public static function clear_default_admin()
959
    {
960
        self::$default_username = null;
961
        self::$default_password = null;
962
    }
963
964
965
    /**
966
     * Set a default admin in dev-mode
967
     *
968
     * This will set a static default-admin which is not existing
969
     * as a database-record. By this workaround we can test pages in dev-mode
970
     * with a unified login. Submitted login-credentials are first checked
971
     * against this static information in {@link Security::authenticate()}.
972
     *
973
     * @param string $username The user name
974
     * @param string $password The password (in cleartext)
975
     * @return bool True if successfully set
976
     */
977
    public static function setDefaultAdmin($username, $password)
978
    {
979
        // don't overwrite if already set
980
        if (self::$default_username || self::$default_password) {
981
            return false;
982
        }
983
984
        self::$default_username = $username;
985
        self::$default_password = $password;
986
        return true;
987
    }
988
989
    /**
990
     * Checks if the passed credentials are matching the default-admin.
991
     * Compares cleartext-password set through Security::setDefaultAdmin().
992
     *
993
     * @param string $username
994
     * @param string $password
995
     * @return bool
996
     */
997
    public static function check_default_admin($username, $password)
998
    {
999
        return (
1000
            self::$default_username === $username
1001
            && self::$default_password === $password
1002
            && self::has_default_admin()
1003
        );
1004
    }
1005
1006
    /**
1007
     * Check that the default admin account has been set.
1008
     */
1009
    public static function has_default_admin()
1010
    {
1011
        return !empty(self::$default_username) && !empty(self::$default_password);
1012
    }
1013
1014
    /**
1015
     * Get default admin username
1016
     *
1017
     * @return string
1018
     */
1019
    public static function default_admin_username()
1020
    {
1021
        return self::$default_username;
1022
    }
1023
1024
    /**
1025
     * Get default admin password
1026
     *
1027
     * @return string
1028
     */
1029
    public static function default_admin_password()
1030
    {
1031
        return self::$default_password;
1032
    }
1033
1034
    /**
1035
     * Encrypt a password according to the current password encryption settings.
1036
     * If the settings are so that passwords shouldn't be encrypted, the
1037
     * result is simple the clear text password with an empty salt except when
1038
     * a custom algorithm ($algorithm parameter) was passed.
1039
     *
1040
     * @param string $password The password to encrypt
1041
     * @param string $salt Optional: The salt to use. If it is not passed, but
1042
     *  needed, the method will automatically create a
1043
     *  random salt that will then be returned as return value.
1044
     * @param string $algorithm Optional: Use another algorithm to encrypt the
1045
     *  password (so that the encryption algorithm can be changed over the time).
1046
     * @param Member $member Optional
1047
     * @return mixed Returns an associative array containing the encrypted
1048
     *  password and the used salt in the form:
1049
     * <code>
1050
     *  array(
1051
     *  'password' => string,
1052
     *  'salt' => string,
1053
     *  'algorithm' => string,
1054
     *  'encryptor' => PasswordEncryptor instance
1055
     *  )
1056
     * </code>
1057
     * If the passed algorithm is invalid, FALSE will be returned.
1058
     *
1059
     * @see encrypt_passwords()
1060
     */
1061
    public static function encrypt_password($password, $salt = null, $algorithm = null, $member = null)
1062
    {
1063
        // Fall back to the default encryption algorithm
1064
        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...
1065
            $algorithm = self::config()->get('password_encryption_algorithm');
1066
        }
1067
1068
        $e = PasswordEncryptor::create_for_algorithm($algorithm);
1069
1070
        // New salts will only need to be generated if the password is hashed for the first time
1071
        $salt = ($salt) ? $salt : $e->salt($password);
1072
1073
        return array(
1074
            'password' => $e->encrypt($password, $salt, $member),
1075
            'salt' => $salt,
1076
            'algorithm' => $algorithm,
1077
            'encryptor' => $e
1078
        );
1079
    }
1080
1081
    /**
1082
     * Checks the database is in a state to perform security checks.
1083
     * See {@link DatabaseAdmin->init()} for more information.
1084
     *
1085
     * @return bool
1086
     */
1087
    public static function database_is_ready()
1088
    {
1089
        // Used for unit tests
1090
        if (self::$force_database_is_ready !== null) {
1091
            return self::$force_database_is_ready;
1092
        }
1093
1094
        if (self::$database_is_ready) {
1095
            return self::$database_is_ready;
1096
        }
1097
1098
        $requiredClasses = ClassInfo::dataClassesFor(Member::class);
1099
        $requiredClasses[] = Group::class;
1100
        $requiredClasses[] = Permission::class;
1101
        $schema = DataObject::getSchema();
1102
        foreach ($requiredClasses as $class) {
1103
            // Skip test classes, as not all test classes are scaffolded at once
1104
            if (is_a($class, TestOnly::class, true)) {
1105
                continue;
1106
            }
1107
1108
            // if any of the tables aren't created in the database
1109
            $table = $schema->tableName($class);
1110
            if (!ClassInfo::hasTable($table)) {
1111
                return false;
1112
            }
1113
1114
            // HACK: DataExtensions aren't applied until a class is instantiated for
1115
            // the first time, so create an instance here.
1116
            singleton($class);
1117
1118
            // if any of the tables don't have all fields mapped as table columns
1119
            $dbFields = DB::field_list($table);
1120
            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...
1121
                return false;
1122
            }
1123
1124
            $objFields = $schema->databaseFields($class, false);
1125
            $missingFields = array_diff_key($objFields, $dbFields);
1126
1127
            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...
1128
                return false;
1129
            }
1130
        }
1131
        self::$database_is_ready = true;
1132
1133
        return true;
1134
    }
1135
1136
    /**
1137
     * Enable or disable recording of login attempts
1138
     * through the {@link LoginRecord} object.
1139
     *
1140
     * @deprecated 4.0 Use the "Security.login_recording" config setting instead
1141
     * @param boolean $bool
1142
     */
1143
    public static function set_login_recording($bool)
1144
    {
1145
        Deprecation::notice('4.0', 'Use the "Security.login_recording" config setting instead');
1146
        self::$login_recording = (bool)$bool;
1147
    }
1148
1149
    /**
1150
     * @deprecated 4.0 Use the "Security.login_recording" config setting instead
1151
     * @return boolean
1152
     */
1153
    public static function login_recording()
1154
    {
1155
        Deprecation::notice('4.0', 'Use the "Security.login_recording" config setting instead');
1156
        return self::$login_recording;
1157
    }
1158
1159
    /**
1160
     * @config
1161
     * @var string Set the default login dest
1162
     * This is the URL that users will be redirected to after they log in,
1163
     * if they haven't logged in en route to access a secured page.
1164
     * By default, this is set to the homepage.
1165
     */
1166
    private static $default_login_dest = "";
1167
1168
    protected static $ignore_disallowed_actions = false;
1169
1170
    /**
1171
     * Set to true to ignore access to disallowed actions, rather than returning permission failure
1172
     * Note that this is just a flag that other code needs to check with Security::ignore_disallowed_actions()
1173
     * @param $flag True or false
1174
     */
1175
    public static function set_ignore_disallowed_actions($flag)
1176
    {
1177
        self::$ignore_disallowed_actions = $flag;
1178
    }
1179
1180
    public static function ignore_disallowed_actions()
1181
    {
1182
        return self::$ignore_disallowed_actions;
1183
    }
1184
1185
    /**
1186
     * Get the URL of the log-in page.
1187
     *
1188
     * To update the login url use the "Security.login_url" config setting.
1189
     *
1190
     * @return string
1191
     */
1192
    public static function login_url()
1193
    {
1194
        return Controller::join_links(Director::baseURL(), self::config()->get('login_url'));
1195
    }
1196
1197
1198
    /**
1199
     * Get the URL of the logout page.
1200
     *
1201
     * To update the logout url use the "Security.logout_url" config setting.
1202
     *
1203
     * @return string
1204
     */
1205
    public static function logout_url()
1206
    {
1207
        return Controller::join_links(Director::baseURL(), self::config()->get('logout_url'));
1208
    }
1209
1210
    /**
1211
     * Get the URL of the logout page.
1212
     *
1213
     * To update the logout url use the "Security.logout_url" config setting.
1214
     *
1215
     * @return string
1216
     */
1217
    public static function lost_password_url()
1218
    {
1219
        return Controller::join_links(Director::baseURL(), self::config()->get('lost_password_url'));
1220
    }
1221
1222
    /**
1223
     * Defines global accessible templates variables.
1224
     *
1225
     * @return array
1226
     */
1227
    public static function get_template_global_variables()
1228
    {
1229
        return array(
1230
            "LoginURL" => "login_url",
1231
            "LogoutURL" => "logout_url",
1232
            "LostPasswordURL" => "lost_password_url",
1233
        );
1234
    }
1235
}
1236