diff --git a/_config.php b/_config.php
index 80a3b2e13..8aaf823c7 100644
--- a/_config.php
+++ b/_config.php
@@ -12,12 +12,6 @@ use SilverStripe\View\Parsers\ShortcodeParser;
* Here you can make different settings for the Framework module (the core
* module).
*
- * For example you can register the authentication methods you wish to use
- * on your site, e.g. to register the OpenID authentication method type
- *
- *
- * Authenticator::register_authenticator('OpenIDAuthenticator');
- *
*/
ShortcodeParser::get('default')
diff --git a/_config/security.yml b/_config/security.yml
index 10051c588..ef8c59fc1 100644
--- a/_config/security.yml
+++ b/_config/security.yml
@@ -1,4 +1,35 @@
-SilverStripe\Security\MemberLoginForm:
- required_fields:
- - Email
- - Password
\ No newline at end of file
+---
+Name: coreauthentication
+---
+SilverStripe\Core\Injector\Injector:
+ SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler:
+ properties:
+ SessionVariable: loggedInAs
+ SilverStripe\Security\MemberAuthenticator\CookieAuthenticationHandler:
+ properties:
+ TokenCookieName: alc_enc
+ DeviceCookieName: alc_device
+ CascadeInTo: %$SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler
+ SilverStripe\Security\AuthenticationHandler:
+ class: SilverStripe\Security\RequestAuthenticationHandler
+ properties:
+ Handlers:
+ session: %$SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler
+ alc: %$SilverStripe\Security\MemberAuthenticator\CookieAuthenticationHandler
+---
+Name: coresecurity
+---
+SilverStripe\Core\Injector\Injector:
+ SilverStripe\Security\AuthenticationRequestFilter:
+ properties:
+ AuthenticationHandler: %$SilverStripe\Security\AuthenticationHandler
+ SilverStripe\Control\RequestProcessor:
+ properties:
+ filters:
+ - %$SilverStripe\Security\AuthenticationRequestFilter
+ SilverStripe\Security\Security:
+ properties:
+ Authenticators:
+ default: %$SilverStripe\Security\MemberAuthenticator\MemberAuthenticator
+ cms: %$SilverStripe\Security\MemberAuthenticator\CMSMemberAuthenticator
+ SilverStripe\Security\IdentityStore: %$SilverStripe\Security\AuthenticationHandler
diff --git a/docs/en/02_Developer_Guides/00_Model/05_Extending_DataObjects.md b/docs/en/02_Developer_Guides/00_Model/05_Extending_DataObjects.md
index c1519bb8b..f27c6778c 100644
--- a/docs/en/02_Developer_Guides/00_Model/05_Extending_DataObjects.md
+++ b/docs/en/02_Developer_Guides/00_Model/05_Extending_DataObjects.md
@@ -30,7 +30,7 @@ Example: Disallow creation of new players if the currently logged-in player is n
public function onBeforeWrite() {
// check on first write action, aka "database row creation" (ID-property is not set)
if(!$this->isInDb()) {
- $currentPlayer = Member::currentUser();
+ $currentPlayer = Security::getCurrentUser();
if(!$currentPlayer->IsTeamManager()) {
user_error('Player-creation not allowed', E_USER_ERROR);
diff --git a/docs/en/02_Developer_Guides/00_Model/07_Permissions.md b/docs/en/02_Developer_Guides/00_Model/07_Permissions.md
index 49f8d4082..28205497e 100644
--- a/docs/en/02_Developer_Guides/00_Model/07_Permissions.md
+++ b/docs/en/02_Developer_Guides/00_Model/07_Permissions.md
@@ -9,7 +9,7 @@ checks. Often it makes sense to centralize those checks on the model, regardless
The API provides four methods for this purpose: `canEdit()`, `canCreate()`, `canView()` and `canDelete()`.
Since they're PHP methods, they can contain arbitrary logic matching your own requirements. They can optionally receive
-a `$member` argument, and default to the currently logged in member (through `Member::currentUser()`).
+a `$member` argument, and default to the currently logged in member (through `Security::getCurrentUser()`).
Login success. If you are not automatically redirected '. + '
Login success. If you are not automatically redirected ' . 'click here
', 'Login message displayed in the cms popup once a user has re-authenticated themselves', array('link' => Convert::raw2att($backURL)) diff --git a/src/Security/ChangePasswordForm.php b/src/Security/ChangePasswordForm.php deleted file mode 100644 index e2db15e14..000000000 --- a/src/Security/ChangePasswordForm.php +++ /dev/null @@ -1,65 +0,0 @@ -getBackURL() ?: Session::get('BackURL'); - - if (!$fields) { - $fields = new FieldList(); - - // Security/changepassword?h=XXX redirects to Security/changepassword - // without GET parameter to avoid potential HTTP referer leakage. - // In this case, a user is not logged in, and no 'old password' should be necessary. - if (Member::currentUser()) { - $fields->push(new PasswordField("OldPassword", _t('SilverStripe\\Security\\Member.YOUROLDPASSWORD', "Your old password"))); - } - - $fields->push(new PasswordField("NewPassword1", _t('SilverStripe\\Security\\Member.NEWPASSWORD', "New Password"))); - $fields->push(new PasswordField("NewPassword2", _t('SilverStripe\\Security\\Member.CONFIRMNEWPASSWORD', "Confirm New Password"))); - } - if (!$actions) { - $actions = new FieldList( - new FormAction("doChangePassword", _t('SilverStripe\\Security\\Member.BUTTONCHANGEPASSWORD', "Change Password")) - ); - } - - if ($backURL) { - $fields->push(new HiddenField('BackURL', false, $backURL)); - } - - parent::__construct($controller, $name, $fields, $actions); - } - - /** - * @return ChangePasswordHandler - */ - protected function buildRequestHandler() - { - return ChangePasswordHandler::create($this); - } -} diff --git a/src/Security/ChangePasswordHandler.php b/src/Security/ChangePasswordHandler.php deleted file mode 100644 index ccc9cd326..000000000 --- a/src/Security/ChangePasswordHandler.php +++ /dev/null @@ -1,103 +0,0 @@ -checkPassword($data['OldPassword'])->isValid() - )) { - $this->form->sessionMessage( - _t('SilverStripe\\Security\\Member.ERRORPASSWORDNOTMATCH', "Your current password does not match, please try again"), - "bad" - ); - // redirect back to the form, instead of using redirectBack() which could send the user elsewhere. - return $this->redirectBackToForm(); - } - - if (!$member) { - if (Session::get('AutoLoginHash')) { - $member = Member::member_from_autologinhash(Session::get('AutoLoginHash')); - } - - // The user is not logged in and no valid auto login hash is available - if (!$member) { - Session::clear('AutoLoginHash'); - return $this->redirect($this->addBackURLParam(Security::singleton()->Link('login'))); - } - } - - // Check the new password - if (empty($data['NewPassword1'])) { - $this->form->sessionMessage( - _t('SilverStripe\\Security\\Member.EMPTYNEWPASSWORD', "The new password can't be empty, please try again"), - "bad" - ); - - // redirect back to the form, instead of using redirectBack() which could send the user elsewhere. - return $this->redirectBackToForm(); - } - - // Fail if passwords do not match - if ($data['NewPassword1'] !== $data['NewPassword2']) { - $this->form->sessionMessage( - _t('SilverStripe\\Security\\Member.ERRORNEWPASSWORD', "You have entered your new password differently, try again"), - "bad" - ); - // redirect back to the form, instead of using redirectBack() which could send the user elsewhere. - return $this->redirectBackToForm(); - } - - // Check if the new password is accepted - $validationResult = $member->changePassword($data['NewPassword1']); - if (!$validationResult->isValid()) { - $this->form->setSessionValidationResult($validationResult); - return $this->redirectBackToForm(); - } - - // Clear locked out status - $member->LockedOutUntil = null; - $member->FailedLoginCount = null; - $member->write(); - - if ($member->canLogIn()->isValid()) { - $member->logIn(); - } - - // TODO Add confirmation message to login redirect - Session::clear('AutoLoginHash'); - - // Redirect to backurl - $backURL = $this->getBackURL(); - if ($backURL) { - return $this->redirect($backURL); - } - - // Redirect to default location - the login form saying "You are logged in as..." - $url = Security::singleton()->Link('login'); - return $this->redirect($url); - } - - public function redirectBackToForm() - { - // Redirect back to form - $url = $this->addBackURLParam(CMSSecurity::singleton()->Link('changepassword')); - return $this->redirect($url); - } -} diff --git a/src/Security/Group.php b/src/Security/Group.php index 204f1a077..333ead2d5 100755 --- a/src/Security/Group.php +++ b/src/Security/Group.php @@ -4,24 +4,24 @@ namespace SilverStripe\Security; use SilverStripe\Admin\SecurityAdmin; use SilverStripe\Core\Convert; -use SilverStripe\Forms\Form; -use SilverStripe\Forms\GridField\GridFieldAddExistingAutocompleter; -use SilverStripe\Forms\GridField\GridFieldDetailForm; -use SilverStripe\Forms\TextField; use SilverStripe\Forms\DropdownField; -use SilverStripe\Forms\TextareaField; -use SilverStripe\Forms\Tab; -use SilverStripe\Forms\TabSet; use SilverStripe\Forms\FieldList; -use SilverStripe\Forms\LiteralField; -use SilverStripe\Forms\ListboxField; -use SilverStripe\Forms\HiddenField; -use SilverStripe\Forms\HTMLEditor\HTMLEditorConfig; -use SilverStripe\Forms\GridField\GridFieldConfig_RelationEditor; +use SilverStripe\Forms\Form; +use SilverStripe\Forms\GridField\GridField; +use SilverStripe\Forms\GridField\GridFieldAddExistingAutocompleter; use SilverStripe\Forms\GridField\GridFieldButtonRow; +use SilverStripe\Forms\GridField\GridFieldConfig_RelationEditor; +use SilverStripe\Forms\GridField\GridFieldDetailForm; use SilverStripe\Forms\GridField\GridFieldExportButton; use SilverStripe\Forms\GridField\GridFieldPrintButton; -use SilverStripe\Forms\GridField\GridField; +use SilverStripe\Forms\HiddenField; +use SilverStripe\Forms\HTMLEditor\HTMLEditorConfig; +use SilverStripe\Forms\ListboxField; +use SilverStripe\Forms\LiteralField; +use SilverStripe\Forms\Tab; +use SilverStripe\Forms\TabSet; +use SilverStripe\Forms\TextareaField; +use SilverStripe\Forms\TextField; use SilverStripe\ORM\ArrayList; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataQuery; @@ -29,7 +29,6 @@ use SilverStripe\ORM\HasManyList; use SilverStripe\ORM\Hierarchy\Hierarchy; use SilverStripe\ORM\ManyManyList; use SilverStripe\ORM\UnsavedRelationList; -use SilverStripe\View\Requirements; /** * A security group. @@ -95,6 +94,7 @@ class Group extends DataObject $doSet = new ArrayList(); $children = Group::get()->filter("ParentID", $this->ID); + /** @var Group $child */ foreach ($children as $child) { $doSet->push($child); $doSet->merge($child->getAllChildren()); @@ -159,7 +159,7 @@ class Group extends DataObject $detailForm = $config->getComponentByType(GridFieldDetailForm::class); $detailForm ->setValidator(Member_Validator::create()) - ->setItemEditFormCallback(function ($form, $component) use ($group) { + ->setItemEditFormCallback(function ($form) use ($group) { /** @var Form $form */ $record = $form->getRecord(); $groupsField = $form->Fields()->dataFieldByName('DirectGroups'); @@ -369,9 +369,9 @@ class Group extends DataObject { $parent = $this; $items = []; - while (isset($parent) && $parent instanceof Group) { + while ($parent instanceof Group) { $items[] = $parent->ID; - $parent = $parent->Parent; + $parent = $parent->getParent(); } return $items; } @@ -395,12 +395,14 @@ class Group extends DataObject ->sort('"Sort"'); } + /** + * @return string + */ public function getTreeTitle() { - if ($this->hasMethod('alternateTreeTitle')) { - return $this->alternateTreeTitle(); - } - return htmlspecialchars($this->Title, ENT_QUOTES); + $title = htmlspecialchars($this->Title, ENT_QUOTES); + $this->extend('updateTreeTitle', $title); + return $title; } /** @@ -476,7 +478,7 @@ class Group extends DataObject public function canEdit($member = null) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } // extended access checks @@ -512,7 +514,7 @@ class Group extends DataObject public function canView($member = null) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } // extended access checks @@ -534,7 +536,7 @@ class Group extends DataObject public function canDelete($member = null) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } // extended access checks diff --git a/src/Security/GroupCsvBulkLoader.php b/src/Security/GroupCsvBulkLoader.php index 8abd7f3e9..182efeade 100644 --- a/src/Security/GroupCsvBulkLoader.php +++ b/src/Security/GroupCsvBulkLoader.php @@ -2,8 +2,8 @@ namespace SilverStripe\Security; -use SilverStripe\ORM\DataObject; use SilverStripe\Dev\CsvBulkLoader; +use SilverStripe\ORM\DataObject; /** * @todo Migrate Permission->Arg and Permission->Type values @@ -15,12 +15,8 @@ class GroupCsvBulkLoader extends CsvBulkLoader 'Code' => 'Code', ); - public function __construct($objectClass = null) + public function __construct($objectClass = Group::class) { - if (!$objectClass) { - $objectClass = 'SilverStripe\\Security\\Group'; - } - parent::__construct($objectClass); } diff --git a/src/Security/IdentityStore.php b/src/Security/IdentityStore.php new file mode 100644 index 000000000..6a07f1653 --- /dev/null +++ b/src/Security/IdentityStore.php @@ -0,0 +1,30 @@ +canEditMultiple($ids, Member::currentUser(), false); + $this->canEditMultiple($ids, Security::getCurrentUser(), false); break; case self::VIEW: - $this->canViewMultiple($ids, Member::currentUser(), false); + $this->canViewMultiple($ids, Security::getCurrentUser(), false); break; case self::DELETE: - $this->canDeleteMultiple($ids, Member::currentUser(), false); + $this->canDeleteMultiple($ids, Security::getCurrentUser(), false); break; default: throw new InvalidArgumentException("Invalid permission type $permission"); diff --git a/src/Security/LoginForm.php b/src/Security/LoginForm.php index 2bb6d60ad..c106a97c3 100644 --- a/src/Security/LoginForm.php +++ b/src/Security/LoginForm.php @@ -2,7 +2,6 @@ namespace SilverStripe\Security; -use SilverStripe\Core\Injector\Injector; use SilverStripe\Forms\FieldList; use SilverStripe\Forms\Form; diff --git a/src/Security/Member.php b/src/Security/Member.php index 6dba32a06..e7895b665 100644 --- a/src/Security/Member.php +++ b/src/Security/Member.php @@ -3,16 +3,16 @@ namespace SilverStripe\Security; use IntlDateFormatter; +use InvalidArgumentException; use SilverStripe\Admin\LeftAndMain; use SilverStripe\CMS\Controllers\CMSMain; -use SilverStripe\Control\Cookie; +use SilverStripe\Control\Controller; use SilverStripe\Control\Director; use SilverStripe\Control\Email\Email; use SilverStripe\Control\Email\Mailer; -use SilverStripe\Control\Session; use SilverStripe\Core\Convert; use SilverStripe\Core\Injector\Injector; -use SilverStripe\Dev\SapphireTest; +use SilverStripe\Dev\Deprecation; use SilverStripe\Dev\TestMailer; use SilverStripe\Forms\ConfirmedPasswordField; use SilverStripe\Forms\DropdownField; @@ -20,20 +20,17 @@ use SilverStripe\Forms\FieldList; use SilverStripe\Forms\HTMLEditor\HTMLEditorConfig; use SilverStripe\Forms\ListboxField; use SilverStripe\i18n\i18n; -use SilverStripe\MSSQL\MSSQLDatabase; use SilverStripe\ORM\ArrayList; +use SilverStripe\ORM\DataList; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DB; use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\ORM\HasManyList; use SilverStripe\ORM\ManyManyList; -use SilverStripe\ORM\SS_List; use SilverStripe\ORM\Map; +use SilverStripe\ORM\SS_List; use SilverStripe\ORM\ValidationException; use SilverStripe\ORM\ValidationResult; -use SilverStripe\View\SSViewer; -use SilverStripe\View\TemplateGlobalProvider; -use DateTime; /** * The member class which represents the users of the system @@ -56,30 +53,31 @@ use DateTime; * @property int $FailedLoginCount * @property string $DateFormat * @property string $TimeFormat + * @property string $SetPassword Pseudo-DB field for temp storage. Not emitted to DB */ -class Member extends DataObject implements TemplateGlobalProvider +class Member extends DataObject { private static $db = array( - 'FirstName' => 'Varchar', - 'Surname' => 'Varchar', - 'Email' => 'Varchar(254)', // See RFC 5321, Section 4.5.3.1.3. (256 minus the < and > character) - 'TempIDHash' => 'Varchar(160)', // Temporary id used for cms re-authentication - 'TempIDExpired' => 'Datetime', // Expiry of temp login - 'Password' => 'Varchar(160)', - 'AutoLoginHash' => 'Varchar(160)', // Used to auto-login the user on password reset - 'AutoLoginExpired' => 'Datetime', + 'FirstName' => 'Varchar', + 'Surname' => 'Varchar', + 'Email' => 'Varchar(254)', // See RFC 5321, Section 4.5.3.1.3. (256 minus the < and > character) + 'TempIDHash' => 'Varchar(160)', // Temporary id used for cms re-authentication + 'TempIDExpired' => 'Datetime', // Expiry of temp login + 'Password' => 'Varchar(160)', + 'AutoLoginHash' => 'Varchar(160)', // Used to auto-login the user on password reset + 'AutoLoginExpired' => 'Datetime', // This is an arbitrary code pointing to a PasswordEncryptor instance, // not an actual encryption algorithm. // Warning: Never change this field after its the first password hashing without // providing a new cleartext password as well. 'PasswordEncryption' => "Varchar(50)", - 'Salt' => 'Varchar(50)', - 'PasswordExpiry' => 'Date', - 'LockedOutUntil' => 'Datetime', - 'Locale' => 'Varchar(6)', + 'Salt' => 'Varchar(50)', + 'PasswordExpiry' => 'Date', + 'LockedOutUntil' => 'Datetime', + 'Locale' => 'Varchar(6)', // handled in registerFailedLogin(), only used if $lock_out_after_incorrect_logins is set - 'FailedLoginCount' => 'Int', + 'FailedLoginCount' => 'Int', ); private static $belongs_many_many = array( @@ -87,7 +85,7 @@ class Member extends DataObject implements TemplateGlobalProvider ); private static $has_many = array( - 'LoggedPasswords' => MemberPassword::class, + 'LoggedPasswords' => MemberPassword::class, 'RememberLoginHashes' => RememberLoginHash::class, ); @@ -191,6 +189,12 @@ class Member extends DataObject implements TemplateGlobalProvider */ private static $password_expiry_days = null; + /** + * @config + * @var bool enable or disable logging of previously used passwords. See {@link onAfterWrite} + */ + private static $password_logging_enabled = true; + /** * @config * @var Int Number of incorrect logins after which @@ -276,7 +280,7 @@ class Member extends DataObject implements TemplateGlobalProvider // Find member /** @skipUpgrade */ - $admin = Member::get() + $admin = static::get() ->filter('Email', Security::default_admin_username()) ->first(); if (!$admin) { @@ -284,7 +288,7 @@ class Member extends DataObject implements TemplateGlobalProvider // persistent logins in the database. See Security::setDefaultAdmin(). // Set 'Email' to identify this as the default admin $admin = Member::create(); - $admin->FirstName = _t(__CLASS__.'.DefaultAdminFirstname', 'Default Admin'); + $admin->FirstName = _t(__CLASS__ . '.DefaultAdminFirstname', 'Default Admin'); $admin->Email = Security::default_admin_username(); $admin->write(); } @@ -323,14 +327,15 @@ class Member extends DataObject implements TemplateGlobalProvider // Check a password is set on this member if (empty($this->Password) && $this->exists()) { - $result->addError(_t(__CLASS__.'.NoPassword', 'There is no password on this member.')); + $result->addError(_t(__CLASS__ . '.NoPassword', 'There is no password on this member.')); + return $result; } $e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption); if (!$e->check($this->Password, $password, $this->Salt, $this)) { $result->addError(_t( - __CLASS__.'.ERRORWRONGCRED', + __CLASS__ . '.ERRORWRONGCRED', 'The provided details don\'t seem to be correct. Please try again.' )); } @@ -364,16 +369,17 @@ class Member extends DataObject implements TemplateGlobalProvider if ($this->isLockedOut()) { $result->addError( _t( - __CLASS__.'.ERRORLOCKEDOUT2', + __CLASS__ . '.ERRORLOCKEDOUT2', 'Your account has been temporarily disabled because of too many failed attempts at ' . 'logging in. Please try again in {count} minutes.', null, - array('count' => $this->config()->lock_out_delay_mins) + array('count' => static::config()->get('lock_out_delay_mins')) ) ); } $this->extend('canLogIn', $result); + return $result; } @@ -387,36 +393,10 @@ class Member extends DataObject implements TemplateGlobalProvider if (!$this->LockedOutUntil) { return false; } + return DBDatetime::now()->getTimestamp() < $this->dbObject('LockedOutUntil')->getTimestamp(); } - /** - * Regenerate the session_id. - * This wrapper is here to make it easier to disable calls to session_regenerate_id(), should you need to. - * They have caused problems in certain - * quirky problems (such as using the Windmill 0.3.6 proxy). - */ - public static function session_regenerate_id() - { - if (!self::config()->session_regenerate_id) { - return; - } - - // This can be called via CLI during testing. - if (Director::is_cli()) { - return; - } - - $file = ''; - $line = ''; - - // @ is to supress win32 warnings/notices when session wasn't cleaned up properly - // There's nothing we can do about this, because it's an operating system function! - if (!headers_sent($file, $line)) { - @session_regenerate_id(true); - } - } - /** * Set a {@link PasswordValidator} object to use to validate member's passwords. * @@ -443,63 +423,48 @@ class Member extends DataObject implements TemplateGlobalProvider if (!$this->PasswordExpiry) { return false; } + return strtotime(date('Y-m-d')) >= strtotime($this->PasswordExpiry); } /** - * Logs this member in + * @deprecated 5.0.0 Use Security::setCurrentUser() or IdentityStore::logIn() * - * @param bool $remember If set to TRUE, the member will be logged in automatically the next time. */ - public function logIn($remember = false) + public function logIn() { + Deprecation::notice( + '5.0.0', + 'This method is deprecated and only logs in for the current request. Please use Security::setCurrentUser($user) or an IdentityStore' + ); + Security::setCurrentUser($this); + } + + /** + * Called before a member is logged in via session/cookie/etc + */ + public function beforeMemberLoggedIn() + { + // @todo Move to middleware on the AuthenticationRequestFilter IdentityStore $this->extend('beforeMemberLoggedIn'); + } - self::session_regenerate_id(); - - Session::set("loggedInAs", $this->ID); - // This lets apache rules detect whether the user has logged in - if (Member::config()->login_marker_cookie) { - Cookie::set(Member::config()->login_marker_cookie, 1, 0); - } - - if (Security::config()->autologin_enabled) { - // Cleans up any potential previous hash for this member on this device - if ($alcDevice = Cookie::get('alc_device')) { - RememberLoginHash::get()->filter('DeviceID', $alcDevice)->removeAll(); - } - if ($remember) { - $rememberLoginHash = RememberLoginHash::generate($this); - $tokenExpiryDays = RememberLoginHash::config()->uninherited('token_expiry_days'); - $deviceExpiryDays = RememberLoginHash::config()->uninherited('device_expiry_days'); - Cookie::set( - 'alc_enc', - $this->ID . ':' . $rememberLoginHash->getToken(), - $tokenExpiryDays, - null, - null, - null, - true - ); - Cookie::set('alc_device', $rememberLoginHash->DeviceID, $deviceExpiryDays, null, null, null, true); - } else { - Cookie::set('alc_enc', null); - Cookie::set('alc_device', null); - Cookie::force_expiry('alc_enc'); - Cookie::force_expiry('alc_device'); - } - } + /** + * Called after a member is logged in via session/cookie/etc + */ + public function afterMemberLoggedIn() + { // Clear the incorrect log-in count $this->registerSuccessfulLogin(); - $this->LockedOutUntil = null; + $this->LockedOutUntil = null; $this->regenerateTempID(); $this->write(); // Audit logging hook - $this->extend('memberLoggedIn'); + $this->extend('afterMemberLoggedIn'); } /** @@ -511,9 +476,10 @@ class Member extends DataObject implements TemplateGlobalProvider public function regenerateTempID() { $generator = new RandomGenerator(); + $lifetime = self::config()->get('temp_id_lifetime'); $this->TempIDHash = $generator->randomToken('sha1'); - $this->TempIDExpired = self::config()->temp_id_lifetime - ? date('Y-m-d H:i:s', strtotime(DBDatetime::now()->getValue()) + self::config()->temp_id_lifetime) + $this->TempIDExpired = $lifetime + ? date('Y-m-d H:i:s', strtotime(DBDatetime::now()->getValue()) + $lifetime) : null; $this->write(); } @@ -523,15 +489,20 @@ class Member extends DataObject implements TemplateGlobalProvider * has a database record of the same ID. If there is * no logged in user, FALSE is returned anyway. * + * @deprecated Not needed anymore, as it returns Security::getCurrentUser(); + * * @return boolean TRUE record found FALSE no record found */ public static function logged_in_session_exists() { - if ($id = Member::currentUserID()) { - if ($member = DataObject::get_by_id(Member::class, $id)) { - if ($member->exists()) { - return true; - } + Deprecation::notice( + '5.0.0', + 'This method is deprecated and now does not add value. Please use Security::getCurrentUser()' + ); + + if ($member = Security::getCurrentUser()) { + if ($member && $member->exists()) { + return true; } } @@ -539,125 +510,21 @@ class Member extends DataObject implements TemplateGlobalProvider } /** - * Log the user in if the "remember login" cookie is set - * - * The remember login token will be changed on every successful - * auto-login. - */ - public static function autoLogin() - { - // Don't bother trying this multiple times - if (!class_exists(SapphireTest::class, false) || !SapphireTest::is_running_test()) { - self::$_already_tried_to_auto_log_in = true; - } - - if (!Security::config()->autologin_enabled - || strpos(Cookie::get('alc_enc'), ':') === false - || Session::get("loggedInAs") - || !Security::database_is_ready() - ) { - return; - } - - if (strpos(Cookie::get('alc_enc'), ':') && Cookie::get('alc_device') && !Session::get("loggedInAs")) { - list($uid, $token) = explode(':', Cookie::get('alc_enc'), 2); - - if (!$uid || !$token) { - return; - } - - $deviceID = Cookie::get('alc_device'); - - /** @var Member $member */ - $member = Member::get()->byID($uid); - - /** @var RememberLoginHash $rememberLoginHash */ - $rememberLoginHash = null; - - // check if autologin token matches - if ($member) { - $hash = $member->encryptWithUserSettings($token); - $rememberLoginHash = RememberLoginHash::get() - ->filter(array( - 'MemberID' => $member->ID, - 'DeviceID' => $deviceID, - 'Hash' => $hash - ))->first(); - if (!$rememberLoginHash) { - $member = null; - } else { - // Check for expired token - $expiryDate = new DateTime($rememberLoginHash->ExpiryDate); - $now = DBDatetime::now(); - $now = new DateTime($now->Rfc2822()); - if ($now > $expiryDate) { - $member = null; - } - } - } - - if ($member) { - self::session_regenerate_id(); - Session::set("loggedInAs", $member->ID); - // This lets apache rules detect whether the user has logged in - if (Member::config()->login_marker_cookie) { - Cookie::set(Member::config()->login_marker_cookie, 1, 0, null, null, false, true); - } - - if ($rememberLoginHash) { - $rememberLoginHash->renew(); - $tokenExpiryDays = RememberLoginHash::config()->uninherited('token_expiry_days'); - Cookie::set( - 'alc_enc', - $member->ID . ':' . $rememberLoginHash->getToken(), - $tokenExpiryDays, - null, - null, - false, - true - ); - } - - $member->write(); - - // Audit logging hook - $member->extend('memberAutoLoggedIn'); - } - } - } - - /** + * @deprecated Use Security::setCurrentUser(null) or an IdentityStore * Logs this member out. */ public function logOut() { + Deprecation::notice( + '5.0.0', + 'This method is deprecated and now does not persist. Please use Security::setCurrentUser(null) or an IdenityStore' + ); + $this->extend('beforeMemberLoggedOut'); - Session::clear("loggedInAs"); - if (Member::config()->login_marker_cookie) { - Cookie::set(Member::config()->login_marker_cookie, null, 0); - } - - Session::destroy(); - - $this->extend('memberLoggedOut'); - - // Clears any potential previous hashes for this member - RememberLoginHash::clear($this, Cookie::get('alc_device')); - - Cookie::set('alc_enc', null); // // Clear the Remember Me cookie - Cookie::force_expiry('alc_enc'); - Cookie::set('alc_device', null); - Cookie::force_expiry('alc_device'); - - // Switch back to live in order to avoid infinite loops when - // redirecting to the login screen (if this login screen is versioned) - Session::clear('readingMode'); - - $this->write(); - + Injector::inst()->get(IdentityStore::class)->logOut(Controller::curr()->getRequest()); // Audit logging hook - $this->extend('memberLoggedOut'); + $this->extend('afterMemberLoggedOut'); } /** @@ -681,6 +548,7 @@ class Member extends DataObject implements TemplateGlobalProvider // We assume we have PasswordEncryption and Salt available here. $e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption); + return $e->encrypt($string, $this->Salt); } @@ -723,6 +591,7 @@ class Member extends DataObject implements TemplateGlobalProvider { $hash = $this->encryptWithUserSettings($autologinToken); $member = self::member_from_autologinhash($hash, false); + return (bool)$member; } @@ -738,13 +607,13 @@ class Member extends DataObject implements TemplateGlobalProvider public static function member_from_autologinhash($hash, $login = false) { /** @var Member $member */ - $member = Member::get()->filter([ - 'AutoLoginHash' => $hash, + $member = static::get()->filter([ + 'AutoLoginHash' => $hash, 'AutoLoginExpired:GreaterThan' => DBDatetime::now()->getValue(), ])->first(); if ($login && $member) { - $member->logIn(); + Injector::inst()->get(IdentityStore::class)->logIn($member); } return $member; @@ -758,11 +627,12 @@ class Member extends DataObject implements TemplateGlobalProvider */ public static function member_from_tempid($tempid) { - $members = Member::get() + $members = static::get() ->filter('TempIDHash', $tempid); // Exclude expired - if (static::config()->temp_id_lifetime) { + if (static::config()->get('temp_id_lifetime')) { + /** @var DataList|Member[] $members */ $members = $members->filter('TempIDExpired:GreaterThan', DBDatetime::now()->getValue()); } @@ -773,6 +643,8 @@ class Member extends DataObject implements TemplateGlobalProvider * Returns the fields for the member form - used in the registration/profile module. * It should return fields that are editable by the admin and the logged-in user. * + * @todo possibly move this to an extension + * * @return FieldList Returns a {@link FieldList} containing the fields for * the member form. */ @@ -788,11 +660,12 @@ class Member extends DataObject implements TemplateGlobalProvider i18n::getSources()->getKnownLocales() )); - $fields->removeByName(static::config()->hidden_fields); + $fields->removeByName(static::config()->get('hidden_fields')); $fields->removeByName('FailedLoginCount'); $this->extend('updateMemberFormFields', $fields); + return $fields; } @@ -805,7 +678,7 @@ class Member extends DataObject implements TemplateGlobalProvider { $editingPassword = $this->isInDB(); $label = $editingPassword - ? _t(__CLASS__.'.EDIT_PASSWORD', 'New Password') + ? _t(__CLASS__ . '.EDIT_PASSWORD', 'New Password') : $this->fieldLabel('Password'); /** @var ConfirmedPasswordField $password */ $password = ConfirmedPasswordField::create( @@ -817,12 +690,13 @@ class Member extends DataObject implements TemplateGlobalProvider ); // If editing own password, require confirmation of existing - if ($editingPassword && $this->ID == Member::currentUserID()) { + if ($editingPassword && $this->ID == Security::getCurrentUser()->ID) { $password->setRequireExistingPassword(true); } $password->setCanBeEmpty(true); $this->extend('updateMemberPasswordField', $password); + return $password; } @@ -850,24 +724,20 @@ class Member extends DataObject implements TemplateGlobalProvider /** * Returns the current logged in user * + * @deprecated 5.0.0 use Security::getCurrentUser() + * * @return Member */ public static function currentUser() { - $id = Member::currentUserID(); + Deprecation::notice( + '5.0.0', + 'This method is deprecated. Please use Security::getCurrentUser() or an IdentityStore' + ); - if ($id) { - return DataObject::get_by_id(Member::class, $id); - } + return Security::getCurrentUser(); } - /** - * Allow override of the current user ID - * - * @var int|null Set to null to fallback to session, or an explicit ID - */ - protected static $overrideID = null; - /** * Temporarily act as the specified user, limited to a $callback, but * without logging in as that user. @@ -881,49 +751,52 @@ class Member extends DataObject implements TemplateGlobalProvider * * @param Member|null|int $member Member or member ID to log in as. * Set to null or 0 to act as a logged out user. - * @param $callback + * @param callable $callback */ public static function actAs($member, $callback) { - $id = ($member instanceof Member ? $member->ID : $member) ?: 0; - $previousID = static::$overrideID; - static::$overrideID = $id; + $previousUser = Security::getCurrentUser(); + + // Transform ID to member + if (is_numeric($member)) { + $member = DataObject::get_by_id(Member::class, $member); + } + Security::setCurrentUser($member); + try { return $callback(); } finally { - static::$overrideID = $previousID; + Security::setCurrentUser($previousUser); } } /** * Get the ID of the current logged in user * + * @deprecated 5.0.0 use Security::getCurrentUser() + * * @return int Returns the ID of the current logged in user or 0. */ public static function currentUserID() { - if (isset(static::$overrideID)) { - return static::$overrideID; - } + Deprecation::notice( + '5.0.0', + 'This method is deprecated. Please use Security::getCurrentUser() or an IdentityStore' + ); - $id = Session::get("loggedInAs"); - if (!$id && !self::$_already_tried_to_auto_log_in) { - self::autoLogin(); - $id = Session::get("loggedInAs"); + if ($member = Security::getCurrentUser()) { + return $member->ID; + } else { + return 0; } - - return is_numeric($id) ? $id : 0; } - private static $_already_tried_to_auto_log_in = false; - - - /* - * Generate a random password, with randomiser to kick in if there's no words file on the - * filesystem. - * - * @return string Returns a random password. - */ + /** + * Generate a random password, with randomiser to kick in if there's no words file on the + * filesystem. + * + * @return string Returns a random password. + */ public static function create_new_password() { $words = Security::config()->uninherited('word_list'); @@ -932,16 +805,17 @@ class Member extends DataObject implements TemplateGlobalProvider $words = file($words); list($usec, $sec) = explode(' ', microtime()); - srand($sec + ((float) $usec * 100000)); + mt_srand($sec + ((float)$usec * 100000)); - $word = trim($words[rand(0, sizeof($words)-1)]); - $number = rand(10, 999); + $word = trim($words[random_int(0, count($words) - 1)]); + $number = random_int(10, 999); return $word . $number; } else { - $random = rand(); + $random = mt_rand(); $string = md5($random); $output = substr($string, 0, 8); + return $output; } } @@ -958,7 +832,7 @@ class Member extends DataObject implements TemplateGlobalProvider // If a member with the same "unique identifier" already exists with a different ID, don't allow merging. // Note: This does not a full replacement for safeguards in the controller layer (e.g. in a registration form), // but rather a last line of defense against data inconsistencies. - $identifierField = Member::config()->unique_identifier_field; + $identifierField = Member::config()->get('unique_identifier_field'); if ($this->$identifierField) { // Note: Same logic as Member_Validator class $filter = [ @@ -971,12 +845,12 @@ class Member extends DataObject implements TemplateGlobalProvider if ($existingRecord) { throw new ValidationException(_t( - __CLASS__.'.ValidationIdentifierFailed', + __CLASS__ . '.ValidationIdentifierFailed', 'Can\'t overwrite existing member #{id} with identical identifier ({name} = {value}))', 'Values in brackets show "fieldname = value", usually denoting an existing email address', array( - 'id' => $existingRecord->ID, - 'name' => $identifierField, + 'id' => $existingRecord->ID, + 'name' => $identifierField, 'value' => $this->$identifierField ) )); @@ -985,16 +859,17 @@ class Member extends DataObject implements TemplateGlobalProvider // We don't send emails out on dev/tests sites to prevent accidentally spamming users. // However, if TestMailer is in use this isn't a risk. + // @todo some developers use external tools, so emailing might be a good idea anyway if ((Director::isLive() || Injector::inst()->get(Mailer::class) instanceof TestMailer) && $this->isChanged('Password') && $this->record['Password'] - && $this->config()->notify_password_change + && static::config()->get('notify_password_change') ) { Email::create() ->setHTMLTemplate('SilverStripe\\Control\\Email\\ChangePasswordEmail') ->setData($this) ->setTo($this->Email) - ->setSubject(_t(__CLASS__.'.SUBJECTPASSWORDCHANGED', "Your password has been changed", 'Email subject')) + ->setSubject(_t(__CLASS__ . '.SUBJECTPASSWORDCHANGED', "Your password has been changed", 'Email subject')) ->send(); } @@ -1002,7 +877,7 @@ class Member extends DataObject implements TemplateGlobalProvider // Note that this only works with cleartext passwords, as we can't rehash // existing passwords. if ((!$this->ID && $this->Password) || $this->isChanged('Password')) { - //reset salt so that it gets regenerated - this will invalidate any persistant login cookies + //reset salt so that it gets regenerated - this will invalidate any persistent login cookies // or other information encrypted with this Member's settings (see self::encryptWithUserSettings) $this->Salt = ''; // Password was changed: encrypt the password according the settings @@ -1010,7 +885,7 @@ class Member extends DataObject implements TemplateGlobalProvider $this->Password, // this is assumed to be cleartext $this->Salt, ($this->PasswordEncryption) ? - $this->PasswordEncryption : Security::config()->password_encryption_algorithm, + $this->PasswordEncryption : Security::config()->get('password_encryption_algorithm'), $this ); @@ -1022,8 +897,8 @@ class Member extends DataObject implements TemplateGlobalProvider // If we haven't manually set a password expiry if (!$this->isChanged('PasswordExpiry')) { // then set it for us - if (self::config()->password_expiry_days) { - $this->PasswordExpiry = date('Y-m-d', time() + 86400 * self::config()->password_expiry_days); + if (static::config()->get('password_expiry_days')) { + $this->PasswordExpiry = date('Y-m-d', time() + 86400 * static::config()->get('password_expiry_days')); } else { $this->PasswordExpiry = null; } @@ -1044,7 +919,7 @@ class Member extends DataObject implements TemplateGlobalProvider Permission::reset(); - if ($this->isChanged('Password')) { + if ($this->isChanged('Password') && static::config()->get('password_logging_enabled')) { MemberPassword::log($this); } } @@ -1068,6 +943,7 @@ class Member extends DataObject implements TemplateGlobalProvider $password->delete(); $password->destroy(); } + return $this; } @@ -1086,9 +962,10 @@ class Member extends DataObject implements TemplateGlobalProvider } // If there are no admin groups in this set then it's ok - $adminGroups = Permission::get_groups_by_permission('ADMIN'); - $adminGroupIDs = ($adminGroups) ? $adminGroups->column('ID') : array(); - return count(array_intersect($ids, $adminGroupIDs)) == 0; + $adminGroups = Permission::get_groups_by_permission('ADMIN'); + $adminGroupIDs = ($adminGroups) ? $adminGroups->column('ID') : array(); + + return count(array_intersect($ids, $adminGroupIDs)) == 0; } @@ -1131,7 +1008,7 @@ class Member extends DataObject implements TemplateGlobalProvider } elseif ($group instanceof Group) { $groupCheckObj = $group; } else { - user_error('Member::inGroup(): Wrong format for $group parameter', E_USER_ERROR); + throw new InvalidArgumentException('Member::inGroup(): Wrong format for $group parameter'); } if (!$groupCheckObj) { @@ -1199,10 +1076,17 @@ class Member extends DataObject implements TemplateGlobalProvider */ public static function set_title_columns($columns, $sep = ' ') { + Deprecation::notice('5.0', 'Use Member.title_format config instead'); if (!is_array($columns)) { $columns = array($columns); } - self::config()->title_format = array('columns' => $columns, 'sep' => $sep); + self::config()->set( + 'title_format', + [ + 'columns' => $columns, + 'sep' => $sep + ] + ); } //------------------- HELPER METHODS -----------------------------------// @@ -1219,13 +1103,14 @@ class Member extends DataObject implements TemplateGlobalProvider */ public function getTitle() { - $format = $this->config()->title_format; + $format = static::config()->get('title_format'); if ($format) { $values = array(); foreach ($format['columns'] as $col) { $values[] = $this->getField($col); } - return join($format['sep'], $values); + + return implode($format['sep'], $values); } if ($this->getField('ID') === 0) { return $this->getField('Surname'); @@ -1250,25 +1135,24 @@ class Member extends DataObject implements TemplateGlobalProvider */ public static function get_title_sql() { - // This should be abstracted to SSDatabase concatOperator or similar. - $op = (DB::get_conn() instanceof MSSQLDatabase) ? " + " : " || "; // Get title_format with fallback to default - $format = static::config()->title_format; + $format = static::config()->get('title_format'); if (!$format) { $format = [ 'columns' => ['Surname', 'FirstName'], - 'sep' => ' ', + 'sep' => ' ', ]; } - $columnsWithTablename = array(); + $columnsWithTablename = array(); foreach ($format['columns'] as $column) { $columnsWithTablename[] = static::getSchema()->sqlColumnForField(__CLASS__, $column); } $sepSQL = Convert::raw2sql($format['sep'], true); - return "(".join(" $op $sepSQL $op ", $columnsWithTablename).")"; + $op = DB::get_conn()->concatOperator(); + return "(" . join(" $op $sepSQL $op ", $columnsWithTablename) . ")"; } @@ -1339,6 +1223,7 @@ class Member extends DataObject implements TemplateGlobalProvider if ($locale) { return $locale; } + return i18n::get_locale(); } @@ -1415,16 +1300,18 @@ class Member extends DataObject implements TemplateGlobalProvider // No groups, return all Members if (!$groupIDList) { - return Member::get()->sort(array('Surname'=>'ASC', 'FirstName'=>'ASC'))->map(); + return static::get()->sort(array('Surname' => 'ASC', 'FirstName' => 'ASC'))->map(); } $membersList = new ArrayList(); // This is a bit ineffective, but follow the ORM style + /** @var Group $group */ foreach (Group::get()->byIDs($groupIDList) as $group) { $membersList->merge($group->Members()); } $membersList->removeDuplicates('ID'); + return $membersList->map(); } @@ -1446,7 +1333,7 @@ class Member extends DataObject implements TemplateGlobalProvider return ArrayList::create()->map(); } - if (!$groups || $groups->Count() == 0) { + if (count($groups) == 0) { $perms = array('ADMIN', 'CMS_ACCESS_AssetAdmin'); if (class_exists(CMSMain::class)) { @@ -1479,7 +1366,7 @@ class Member extends DataObject implements TemplateGlobalProvider } /** @skipUpgrade */ - $members = Member::get() + $members = static::get() ->innerJoin("Group_Members", '"Group_Members"."MemberID" = "Member"."ID"') ->innerJoin("Group", '"Group"."ID" = "Group_Members"."GroupID"'); if ($groupIDList) { @@ -1539,12 +1426,12 @@ class Member extends DataObject implements TemplateGlobalProvider $mainFields->replaceField('Locale', new DropdownField( "Locale", - _t(__CLASS__.'.INTERFACELANG', "Interface Language", 'Language of the CMS'), + _t(__CLASS__ . '.INTERFACELANG', "Interface Language", 'Language of the CMS'), i18n::getSources()->getKnownLocales() )); - $mainFields->removeByName($this->config()->hidden_fields); + $mainFields->removeByName(static::config()->get('hidden_fields')); - if (! $this->config()->lock_out_after_incorrect_logins) { + if (!static::config()->get('lock_out_after_incorrect_logins')) { $mainFields->removeByName('FailedLoginCount'); } @@ -1570,7 +1457,7 @@ class Member extends DataObject implements TemplateGlobalProvider ->setSource($groupsMap) ->setAttribute( 'data-placeholder', - _t(__CLASS__.'.ADDGROUP', 'Add group', 'Placeholder text for a dropdown') + _t(__CLASS__ . '.ADDGROUP', 'Add group', 'Placeholder text for a dropdown') ) ); @@ -1609,21 +1496,22 @@ class Member extends DataObject implements TemplateGlobalProvider { $labels = parent::fieldLabels($includerelations); - $labels['FirstName'] = _t(__CLASS__.'.FIRSTNAME', 'First Name'); - $labels['Surname'] = _t(__CLASS__.'.SURNAME', 'Surname'); + $labels['FirstName'] = _t(__CLASS__ . '.FIRSTNAME', 'First Name'); + $labels['Surname'] = _t(__CLASS__ . '.SURNAME', 'Surname'); /** @skipUpgrade */ - $labels['Email'] = _t(__CLASS__.'.EMAIL', 'Email'); - $labels['Password'] = _t(__CLASS__.'.db_Password', 'Password'); - $labels['PasswordExpiry'] = _t(__CLASS__.'.db_PasswordExpiry', 'Password Expiry Date', 'Password expiry date'); - $labels['LockedOutUntil'] = _t(__CLASS__.'.db_LockedOutUntil', 'Locked out until', 'Security related date'); - $labels['Locale'] = _t(__CLASS__.'.db_Locale', 'Interface Locale'); + $labels['Email'] = _t(__CLASS__ . '.EMAIL', 'Email'); + $labels['Password'] = _t(__CLASS__ . '.db_Password', 'Password'); + $labels['PasswordExpiry'] = _t(__CLASS__ . '.db_PasswordExpiry', 'Password Expiry Date', 'Password expiry date'); + $labels['LockedOutUntil'] = _t(__CLASS__ . '.db_LockedOutUntil', 'Locked out until', 'Security related date'); + $labels['Locale'] = _t(__CLASS__ . '.db_Locale', 'Interface Locale'); if ($includerelations) { $labels['Groups'] = _t( - __CLASS__.'.belongs_many_many_Groups', + __CLASS__ . '.belongs_many_many_Groups', 'Groups', 'Security Groups this member belongs to' ); } + return $labels; } @@ -1639,7 +1527,7 @@ class Member extends DataObject implements TemplateGlobalProvider { //get member if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } //check for extensions, we do this first as they can overrule everything $extended = $this->extendedCan(__FUNCTION__, $member); @@ -1655,6 +1543,7 @@ class Member extends DataObject implements TemplateGlobalProvider if ($this->ID == $member->ID) { return true; } + //standard check return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin'); } @@ -1670,7 +1559,7 @@ class Member extends DataObject implements TemplateGlobalProvider { //get member if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } //check for extensions, we do this first as they can overrule everything $extended = $this->extendedCan(__FUNCTION__, $member); @@ -1691,9 +1580,11 @@ class Member extends DataObject implements TemplateGlobalProvider if ($this->ID == $member->ID) { return true; } + //standard check return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin'); } + /** * Users can edit their own record. * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions @@ -1704,7 +1595,7 @@ class Member extends DataObject implements TemplateGlobalProvider public function canDelete($member = null) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } //check for extensions, we do this first as they can overrule everything $extended = $this->extendedCan(__FUNCTION__, $member); @@ -1726,10 +1617,11 @@ class Member extends DataObject implements TemplateGlobalProvider // this is a hack because what this should do is to stop a user // deleting a member who has more privileges (e.g. a non-Admin deleting an Admin) if (Permission::checkMember($this, 'ADMIN')) { - if (! Permission::checkMember($member, 'ADMIN')) { + if (!Permission::checkMember($member, 'ADMIN')) { return false; } } + //standard check return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin'); } @@ -1782,13 +1674,14 @@ class Member extends DataObject implements TemplateGlobalProvider */ public function registerFailedLogin() { - if (self::config()->lock_out_after_incorrect_logins) { + $lockOutAfterCount = self::config()->get('lock_out_after_incorrect_logins'); + if ($lockOutAfterCount) { // Keep a tally of the number of failed log-ins so that we can lock people out $this->FailedLoginCount = $this->FailedLoginCount + 1; - if ($this->FailedLoginCount >= self::config()->lock_out_after_incorrect_logins) { - $lockoutMins = self::config()->lock_out_delay_mins; - $this->LockedOutUntil = date('Y-m-d H:i:s', DBDatetime::now()->getTimestamp() + $lockoutMins*60); + if ($this->FailedLoginCount >= $lockOutAfterCount) { + $lockoutMins = self::config()->get('lock_out_delay_mins'); + $this->LockedOutUntil = date('Y-m-d H:i:s', DBDatetime::now()->getTimestamp() + $lockoutMins * 60); $this->FailedLoginCount = 0; } } @@ -1801,7 +1694,7 @@ class Member extends DataObject implements TemplateGlobalProvider */ public function registerSuccessfulLogin() { - if (self::config()->lock_out_after_incorrect_logins) { + if (self::config()->get('lock_out_after_incorrect_logins')) { // Forgive all past login failures $this->FailedLoginCount = 0; $this->write(); @@ -1834,12 +1727,4 @@ class Member extends DataObject implements TemplateGlobalProvider // If can't find a suitable editor, just default to cms return $currentName ? $currentName : 'cms'; } - - public static function get_template_global_variables() - { - return array( - 'CurrentMember' => 'currentUser', - 'currentUser', - ); - } } diff --git a/src/Security/MemberAuthenticator.php b/src/Security/MemberAuthenticator.php deleted file mode 100644 index 85431bb78..000000000 --- a/src/Security/MemberAuthenticator.php +++ /dev/null @@ -1,219 +0,0 @@ - - */ -class MemberAuthenticator extends Authenticator -{ - - /** - * Contains encryption algorithm identifiers. - * If set, will migrate to new precision-safe password hashing - * upon login. See http://open.silverstripe.org/ticket/3004 - * - * @var array - */ - private static $migrate_legacy_hashes = array( - 'md5' => 'md5_v2.4', - 'sha1' => 'sha1_v2.4' - ); - - /** - * Attempt to find and authenticate member if possible from the given data - * - * @param array $data - * @param Form $form - * @param bool &$success Success flag - * @return Member Found member, regardless of successful login - */ - protected static function authenticate_member($data, $form, &$success) - { - // Default success to false - $success = false; - - // Attempt to identify by temporary ID - $member = null; - $email = null; - if (!empty($data['tempid'])) { - // Find user by tempid, in case they are re-validating an existing session - $member = Member::member_from_tempid($data['tempid']); - if ($member) { - $email = $member->Email; - } - } - - // Otherwise, get email from posted value instead - /** @skipUpgrade */ - if (!$member && !empty($data['Email'])) { - $email = $data['Email']; - } - - // Check default login (see Security::setDefaultAdmin()) - $asDefaultAdmin = $email === Security::default_admin_username(); - if ($asDefaultAdmin) { - // If logging is as default admin, ensure record is setup correctly - $member = Member::default_admin(); - $success = !$member->isLockedOut() && Security::check_default_admin($email, $data['Password']); - //protect against failed login - if ($success) { - return $member; - } - } - - // Attempt to identify user by email - if (!$member && $email) { - // Find user by email - $member = Member::get() - ->filter(Member::config()->unique_identifier_field, $email) - ->first(); - } - - // Validate against member if possible - if ($member && !$asDefaultAdmin) { - $result = $member->checkPassword($data['Password']); - $success = $result->isValid(); - } else { - $result = ValidationResult::create()->addError(_t( - 'SilverStripe\\Security\\Member.ERRORWRONGCRED', - 'The provided details don\'t seem to be correct. Please try again.' - )); - } - - // Emit failure to member and form (if available) - if (!$success) { - if ($member) { - $member->registerFailedLogin(); - } - if ($form) { - $form->setSessionValidationResult($result, true); - } - } else { - if ($member) { - $member->registerSuccessfulLogin(); - } - } - - return $member; - } - - /** - * Log login attempt - * TODO We could handle this with an extension - * - * @param array $data - * @param Member $member - * @param bool $success - */ - protected static function record_login_attempt($data, $member, $success) - { - if (!Security::config()->login_recording) { - return; - } - - // Check email is valid - /** @skipUpgrade */ - $email = isset($data['Email']) ? $data['Email'] : null; - if (is_array($email)) { - throw new InvalidArgumentException("Bad email passed to MemberAuthenticator::authenticate(): $email"); - } - - $attempt = new LoginAttempt(); - if ($success) { - // successful login (member is existing with matching password) - $attempt->MemberID = $member->ID; - $attempt->Status = 'Success'; - - // Audit logging hook - $member->extend('authenticated'); - } else { - // Failed login - we're trying to see if a user exists with this email (disregarding wrong passwords) - $attempt->Status = 'Failure'; - if ($member) { - // Audit logging hook - $attempt->MemberID = $member->ID; - $member->extend('authenticationFailed'); - } else { - // Audit logging hook - Member::singleton()->extend('authenticationFailedUnknownUser', $data); - } - } - - $attempt->Email = $email; - $attempt->IP = Controller::curr()->getRequest()->getIP(); - $attempt->write(); - } - - /** - * Method to authenticate an user - * - * @param array $data Raw data to authenticate the user - * @param Form $form Optional: If passed, better error messages can be - * produced by using - * {@link Form::sessionMessage()} - * @return bool|Member Returns FALSE if authentication fails, otherwise - * the member object - * @see Security::setDefaultAdmin() - */ - public static function authenticate($data, Form $form = null) - { - // Find authenticated member - $member = static::authenticate_member($data, $form, $success); - - // Optionally record every login attempt as a {@link LoginAttempt} object - static::record_login_attempt($data, $member, $success); - - // Legacy migration to precision-safe password hashes. - // A login-event with cleartext passwords is the only time - // when we can rehash passwords to a different hashing algorithm, - // bulk-migration doesn't work due to the nature of hashing. - // See PasswordEncryptor_LegacyPHPHash class. - if ($success && $member && isset(self::$migrate_legacy_hashes[$member->PasswordEncryption])) { - $member->Password = $data['Password']; - $member->PasswordEncryption = self::$migrate_legacy_hashes[$member->PasswordEncryption]; - $member->write(); - } - - if ($success) { - Session::clear('BackURL'); - } - - return $success ? $member : null; - } - - - /** - * Method that creates the login form for this authentication method - * - * @param Controller $controller The parent controller, necessary to create the - * appropriate form action tag - * @return Form Returns the login form to use with this authentication - * method - */ - public static function get_login_form(Controller $controller) - { - /** @skipUpgrade */ - return MemberLoginForm::create($controller, self::class, "LoginForm"); - } - - public static function get_cms_login_form(Controller $controller) - { - /** @skipUpgrade */ - return CMSMemberLoginForm::create($controller, self::class, "LoginForm"); - } - - public static function supports_cms() - { - // Don't automatically support subclasses of MemberAuthenticator - return get_called_class() === __CLASS__; - } -} diff --git a/src/Security/CMSMemberLoginHandler.php b/src/Security/MemberAuthenticator/CMSLoginHandler.php similarity index 79% rename from src/Security/CMSMemberLoginHandler.php rename to src/Security/MemberAuthenticator/CMSLoginHandler.php index cec76bc2a..bd588c231 100644 --- a/src/Security/CMSMemberLoginHandler.php +++ b/src/Security/MemberAuthenticator/CMSLoginHandler.php @@ -1,27 +1,28 @@ performLogin($data)) { - return $this->logInUserAndRedirect($data); - } + private static $allowed_actions = [ + 'LoginForm' + ]; - return $this->redirectBackToForm(); + /** + * Return the CMSMemberLoginForm form + */ + public function loginForm() + { + return CMSMemberLoginForm::create( + $this, + get_class($this->authenticator), + 'LoginForm' + ); } public function redirectBackToForm() @@ -75,13 +76,12 @@ PHP /** * Send user to the right location after login * - * @param array $data * @return HTTPResponse */ - protected function logInUserAndRedirect($data) + protected function redirectAfterSuccessfulLogin() { // Check password expiry - if (Member::currentUser()->isPasswordExpired()) { + if (Security::getCurrentUser()->isPasswordExpired()) { // Redirect the user to the external password change form if necessary return $this->redirectToChangePassword(); } diff --git a/src/Security/MemberAuthenticator/CMSMemberAuthenticator.php b/src/Security/MemberAuthenticator/CMSMemberAuthenticator.php new file mode 100644 index 000000000..5f3cd3403 --- /dev/null +++ b/src/Security/MemberAuthenticator/CMSMemberAuthenticator.php @@ -0,0 +1,45 @@ +Email; + } + } + + return parent::authenticateMember($data, $result, $member); + } + + /** + * @param string $link + * @return CMSLoginHandler + */ + public function getLoginHandler($link) + { + return CMSLoginHandler::create($link, $this); + } +} diff --git a/src/Security/MemberAuthenticator/ChangePasswordForm.php b/src/Security/MemberAuthenticator/ChangePasswordForm.php new file mode 100644 index 000000000..5969c1f46 --- /dev/null +++ b/src/Security/MemberAuthenticator/ChangePasswordForm.php @@ -0,0 +1,81 @@ +getBackURL() ?: Session::get('BackURL'); + + if (!$fields) { + $fields = $this->getFormFields(); + } + if (!$actions) { + $actions = $this->getFormActions(); + } + + if ($backURL) { + $fields->push(HiddenField::create('BackURL', false, $backURL)); + } + + parent::__construct($controller, $name, $fields, $actions); + } + + /** + * @return FieldList + */ + protected function getFormFields() + { + $fields = FieldList::create(); + + // Security/changepassword?h=XXX redirects to Security/changepassword + // without GET parameter to avoid potential HTTP referer leakage. + // In this case, a user is not logged in, and no 'old password' should be necessary. + if (Security::getCurrentUser()) { + $fields->push(PasswordField::create('OldPassword', _t('SilverStripe\\Security\\Member.YOUROLDPASSWORD', 'Your old password'))); + } + + $fields->push(PasswordField::create('NewPassword1', _t('SilverStripe\\Security\\Member.NEWPASSWORD', 'New Password'))); + $fields->push(PasswordField::create('NewPassword2', _t('SilverStripe\\Security\\Member.CONFIRMNEWPASSWORD', 'Confirm New Password'))); + + return $fields; + } + + /** + * @return FieldList + */ + protected function getFormActions() + { + $actions = FieldList::create( + FormAction::create( + 'doChangePassword', + _t('SilverStripe\\Security\\Member.BUTTONCHANGEPASSWORD', 'Change Password') + ) + ); + + return $actions; + } +} diff --git a/src/Security/MemberAuthenticator/ChangePasswordHandler.php b/src/Security/MemberAuthenticator/ChangePasswordHandler.php new file mode 100644 index 000000000..8023b0d2a --- /dev/null +++ b/src/Security/MemberAuthenticator/ChangePasswordHandler.php @@ -0,0 +1,308 @@ + 'changepassword', + ]; + + /** + * @param string $link The URL to recreate this request handler + * @param MemberAuthenticator $authenticator + */ + public function __construct($link, MemberAuthenticator $authenticator) + { + $this->link = $link; + $this->authenticator = $authenticator; + parent::__construct(); + } + + /** + * Handle the change password request + * @todo this could use some spring cleaning + * + * @return array|HTTPResponse + */ + public function changepassword() + { + $request = $this->getRequest(); + + // Extract the member from the URL. + /** @var Member $member */ + $member = null; + if ($request->getVar('m') !== null) { + $member = Member::get()->filter(['ID' => (int)$request->getVar('m')])->first(); + } + $token = $request->getVar('t'); + + // Check whether we are merely changin password, or resetting. + if ($token !== null && $member && $member->validateAutoLoginToken($token)) { + $this->setSessionToken($member, $token); + + // Redirect to myself, but without the hash in the URL + return $this->redirect($this->link); + } + + if (Session::get('AutoLoginHash')) { + $message = DBField::create_field( + 'HTMLFragment', + '' . _t( + 'SilverStripe\\Security\\Security.ENTERNEWPASSWORD', + 'Please enter a new password.' + ) . '
' + ); + + // Subsequent request after the "first load with hash" (see previous if clause). + return [ + 'Content' => $message, + 'Form' => $this->changePasswordForm() + ]; + } + + if (Security::getCurrentUser()) { + // Logged in user requested a password change form. + $message = DBField::create_field( + 'HTMLFragment', + '' . _t( + 'SilverStripe\\Security\\Security.CHANGEPASSWORDBELOW', + 'You can change your password below.' + ) . '
' + ); + + return [ + 'Content' => $message, + 'Form' => $this->changePasswordForm() + ]; + } + // Show a friendly message saying the login token has expired + if ($token !== null && $member && !$member->validateAutoLoginToken($token)) { + $message = [ + 'Content' => DBField::create_field( + 'HTMLFragment', + _t( + 'SilverStripe\\Security\\Security.NOTERESETLINKINVALID', + 'The password reset link is invalid or expired.
' + . 'You can request a new one here or change your password after' + . ' you logged in.
', + [ + 'link1' => $this->link('lostpassword'), + 'link2' => $this->link('login') + ] + ) + ) + ]; + + return [ + 'Content' => $message, + ]; + } + + // Someone attempted to go to changepassword without token or being logged in + return Security::permissionFailure( + Controller::curr(), + _t( + 'SilverStripe\\Security\\Security.ERRORPASSWORDPERMISSION', + 'You must be logged in in order to change your password!' + ) + ); + } + + + /** + * @param Member $member + * @param string $token + */ + protected function setSessionToken($member, $token) + { + // if there is a current member, they should be logged out + if ($curMember = Security::getCurrentUser()) { + /** @var LogoutHandler $handler */ + Injector::inst()->get(IdentityStore::class)->logOut(); + } + + // Store the hash for the change password form. Will be unset after reload within the ChangePasswordForm. + Session::set('AutoLoginHash', $member->encryptWithUserSettings($token)); + } + + /** + * Return a link to this request handler. + * The link returned is supplied in the constructor + * @param null $action + * @return string + */ + public function link($action = null) + { + if ($action) { + return Controller::join_links($this->link, $action); + } + + return $this->link; + } + + /** + * Factory method for the lost password form + * + * @skipUpgrade + * @return ChangePasswordForm Returns the lost password form + */ + public function changePasswordForm() + { + return ChangePasswordForm::create( + $this, + 'ChangePasswordForm' + ); + } + + /** + * Change the password + * + * @param array $data The user submitted data + * @param ChangePasswordForm $form + * @return HTTPResponse + */ + public function doChangePassword(array $data, $form) + { + $member = Security::getCurrentUser(); + // The user was logged in, check the current password + if ($member && ( + empty($data['OldPassword']) || + !$member->checkPassword($data['OldPassword'])->isValid() + ) + ) { + $form->sessionMessage( + _t( + 'SilverStripe\\Security\\Member.ERRORPASSWORDNOTMATCH', + 'Your current password does not match, please try again' + ), + 'bad' + ); + + // redirect back to the form, instead of using redirectBack() which could send the user elsewhere. + return $this->redirectBackToForm(); + } + + if (!$member) { + if (Session::get('AutoLoginHash')) { + $member = Member::member_from_autologinhash(Session::get('AutoLoginHash')); + } + + // The user is not logged in and no valid auto login hash is available + if (!$member) { + Session::clear('AutoLoginHash'); + + return $this->redirect($this->addBackURLParam(Security::singleton()->Link('login'))); + } + } + + // Check the new password + if (empty($data['NewPassword1'])) { + $form->sessionMessage( + _t( + 'SilverStripe\\Security\\Member.EMPTYNEWPASSWORD', + "The new password can't be empty, please try again" + ), + 'bad' + ); + + // redirect back to the form, instead of using redirectBack() which could send the user elsewhere. + return $this->redirectBackToForm(); + } + + // Fail if passwords do not match + if ($data['NewPassword1'] !== $data['NewPassword2']) { + $form->sessionMessage( + _t( + 'SilverStripe\\Security\\Member.ERRORNEWPASSWORD', + 'You have entered your new password differently, try again' + ), + 'bad' + ); + + // redirect back to the form, instead of using redirectBack() which could send the user elsewhere. + return $this->redirectBackToForm(); + } + + // Check if the new password is accepted + $validationResult = $member->changePassword($data['NewPassword1']); + if (!$validationResult->isValid()) { + $form->setSessionValidationResult($validationResult); + + return $this->redirectBackToForm(); + } + + // Clear locked out status + $member->LockedOutUntil = null; + $member->FailedLoginCount = null; + // Clear the members login hashes + $member->AutoLoginHash = null; + $member->AutoLoginExpired = DBDatetime::create()->now(); + $member->write(); + + if ($member->canLogIn()->isValid()) { + Injector::inst()->get(IdentityStore::class)->logIn($member, false, $this->getRequest()); + } + + // TODO Add confirmation message to login redirect + Session::clear('AutoLoginHash'); + + // Redirect to backurl + $backURL = $this->getBackURL(); + if ($backURL) { + return $this->redirect($backURL); + } + + // Redirect to default location - the login form saying "You are logged in as..." + $url = Security::singleton()->Link('login'); + + return $this->redirect($url); + } + + /** + * Something went wrong, go back to the changepassword + * + * @return HTTPResponse + */ + public function redirectBackToForm() + { + // Redirect back to form + $url = $this->addBackURLParam(Security::singleton()->Link('changepassword')); + + return $this->redirect($url); + } +} diff --git a/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php b/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php new file mode 100644 index 000000000..500b73e8d --- /dev/null +++ b/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php @@ -0,0 +1,245 @@ +deviceCookieName; + } + + /** + * Set the name of the cookie used to track this device + * + * @param string $deviceCookieName + * @return $this + */ + public function setDeviceCookieName($deviceCookieName) + { + $this->deviceCookieName = $deviceCookieName; + return $this; + } + + /** + * Get the name of the cookie used to store an login token + * + * @return string + */ + public function getTokenCookieName() + { + return $this->tokenCookieName; + } + + /** + * Set the name of the cookie used to store an login token + * + * @param string $tokenCookieName + * @return $this + */ + public function setTokenCookieName($tokenCookieName) + { + $this->tokenCookieName = $tokenCookieName; + return $this; + } + + /** + * Once a member is found by authenticateRequest() pass it to this identity store + * + * @return IdentityStore + */ + public function getCascadeLogInTo() + { + return $this->cascadeInTo; + } + + /** + * Set the name of the cookie used to store an login token + * + * @param IdentityStore $cascadeInTo + * @return $this + */ + public function setCascadeLogInTo(IdentityStore $cascadeInTo) + { + $this->cascadeInTo = $cascadeInTo; + return $this; + } + + /** + * @param HTTPRequest $request + * @return Member + */ + public function authenticateRequest(HTTPRequest $request) + { + $uidAndToken = Cookie::get($this->getTokenCookieName()); + $deviceID = Cookie::get($this->getDeviceCookieName()); + + // @todo Consider better placement of database_is_ready test + if ($deviceID === null || strpos($uidAndToken, ':') === false || !Security::database_is_ready()) { + return null; + } + + list($uid, $token) = explode(':', $uidAndToken, 2); + + if (!$uid || !$token) { + return null; + } + + // check if autologin token matches + /** @var Member $member */ + $member = Member::get()->byID($uid); + if (!$member) { + return null; + } + + $hash = $member->encryptWithUserSettings($token); + + /** @var RememberLoginHash $rememberLoginHash */ + $rememberLoginHash = RememberLoginHash::get() + ->filter(array( + 'MemberID' => $member->ID, + 'DeviceID' => $deviceID, + 'Hash' => $hash + ))->first(); + if (!$rememberLoginHash) { + return null; + } + + // Check for expired token + $expiryDate = new \DateTime($rememberLoginHash->ExpiryDate); + $now = DBDatetime::now(); + $now = new \DateTime($now->Rfc2822()); + if ($now > $expiryDate) { + return null; + } + + if ($this->cascadeInTo) { + // @todo look at how to block "regular login" triggers from happening here + // @todo deal with the fact that the Session::current_session() isn't correct here :-/ + $this->cascadeInTo->logIn($member, false, $request); + } + + // @todo Consider whether response should be part of logIn() as well + + // Renew the token + $rememberLoginHash->renew(); + $tokenExpiryDays = RememberLoginHash::config()->uninherited('token_expiry_days'); + Cookie::set( + $this->getTokenCookieName(), + $member->ID . ':' . $rememberLoginHash->getToken(), + $tokenExpiryDays, + null, + null, + false, + true + ); + + // Audit logging hook + $member->extend('memberAutoLoggedIn'); + + return $member; + } + + /** + * @param Member $member + * @param bool $persistent + * @param HTTPRequest $request + */ + public function logIn(Member $member, $persistent = false, HTTPRequest $request = null) + { + // Cleans up any potential previous hash for this member on this device + if ($alcDevice = Cookie::get($this->getDeviceCookieName())) { + RememberLoginHash::get()->filter('DeviceID', $alcDevice)->removeAll(); + } + + // Set a cookie for persistent log-ins + if ($persistent) { + $rememberLoginHash = RememberLoginHash::generate($member); + $tokenExpiryDays = RememberLoginHash::config()->uninherited('token_expiry_days'); + $deviceExpiryDays = RememberLoginHash::config()->uninherited('device_expiry_days'); + Cookie::set( + $this->getTokenCookieName(), + $member->ID . ':' . $rememberLoginHash->getToken(), + $tokenExpiryDays, + null, + null, + null, + true + ); + Cookie::set( + $this->getDeviceCookieName(), + $rememberLoginHash->DeviceID, + $deviceExpiryDays, + null, + null, + null, + true + ); + } else { + // Clear a cookie for non-persistent log-ins + $this->clearCookies(); + } + } + + /** + * @param HTTPRequest $request + */ + public function logOut(HTTPRequest $request = null) + { + $member = Security::getCurrentUser(); + if ($member) { + RememberLoginHash::clear($member, Cookie::get('alc_device')); + } + $this->clearCookies(); + + if ($this->cascadeInTo) { + $this->cascadeInTo->logOut($request); + } + + Security::setCurrentUser(null); + } + + /** + * Clear the cookies set for the user + */ + protected function clearCookies() + { + Cookie::set($this->getTokenCookieName(), null); + Cookie::set($this->getDeviceCookieName(), null); + Cookie::force_expiry($this->getTokenCookieName()); + Cookie::force_expiry($this->getDeviceCookieName()); + } +} diff --git a/src/Security/MemberAuthenticator/LoginHandler.php b/src/Security/MemberAuthenticator/LoginHandler.php new file mode 100644 index 000000000..74e8b1425 --- /dev/null +++ b/src/Security/MemberAuthenticator/LoginHandler.php @@ -0,0 +1,257 @@ + 'login', + ]; + + /** + * @var array + * @config + */ + private static $allowed_actions = [ + 'login', + 'LoginForm', + 'logout', + ]; + + /** + * @var string Called link on this handler + */ + private $link; + + /** + * @param string $link The URL to recreate this request handler + * @param MemberAuthenticator $authenticator The authenticator to use + */ + public function __construct($link, MemberAuthenticator $authenticator) + { + $this->link = $link; + $this->authenticator = $authenticator; + parent::__construct(); + } + + /** + * Return a link to this request handler. + * The link returned is supplied in the constructor + * @param null|string $action + * @return string + */ + public function link($action = null) + { + if ($action) { + return Controller::join_links($this->link, $action); + } + + return $this->link; + } + + /** + * URL handler for the log-in screen + * + * @return array + */ + public function login() + { + return [ + 'Form' => $this->loginForm(), + ]; + } + + /** + * Return the MemberLoginForm form + * + * @return MemberLoginForm + */ + public function loginForm() + { + return MemberLoginForm::create( + $this, + get_class($this->authenticator), + 'LoginForm' + ); + } + + /** + * Login form handler method + * + * This method is called when the user finishes the login flow + * + * @param array $data Submitted data + * @param MemberLoginForm $form + * @return HTTPResponse + */ + public function doLogin($data, $form) + { + $failureMessage = null; + + $this->extend('beforeLogin'); + // Successful login + if ($member = $this->checkLogin($data, $result)) { + $this->performLogin($member, $data, $form->getRequestHandler()->getRequest()); + // Allow operations on the member after successful login + $this->extend('afterLogin', $member); + + return $this->redirectAfterSuccessfulLogin(); + } + + $this->extend('failedLogin'); + + $message = implode("; ", array_map( + function ($message) { + return $message['message']; + }, + $result->getMessages() + )); + + $form->sessionMessage($message, 'bad'); + + // Failed login + + /** @skipUpgrade */ + if (array_key_exists('Email', $data)) { + $rememberMe = (isset($data['Remember']) && Security::config()->get('autologin_enabled') === true); + Session::set('SessionForms.MemberLoginForm.Email', $data['Email']); + Session::set('SessionForms.MemberLoginForm.Remember', $rememberMe); + } + + // Fail to login redirects back to form + return $form->getRequestHandler()->redirectBackToForm(); + } + + public function getReturnReferer() + { + return $this->link(); + } + + /** + * Login in the user and figure out where to redirect the browser. + * + * The $data has this format + * array( + * 'AuthenticationMethod' => 'MemberAuthenticator', + * 'Email' => 'sam@silverstripe.com', + * 'Password' => '1nitialPassword', + * 'BackURL' => 'test/link', + * [Optional: 'Remember' => 1 ] + * ) + * + * @return HTTPResponse + */ + protected function redirectAfterSuccessfulLogin() + { + Session::clear('SessionForms.MemberLoginForm.Email'); + Session::clear('SessionForms.MemberLoginForm.Remember'); + + $member = Security::getCurrentUser(); + if ($member->isPasswordExpired()) { + return $this->redirectToChangePassword(); + } + + // Absolute redirection URLs may cause spoofing + $backURL = $this->getBackURL(); + if ($backURL) { + return $this->redirect($backURL); + } + + // If a default login dest has been set, redirect to that. + $defaultLoginDest = Security::config()->get('default_login_dest'); + if ($defaultLoginDest) { + return $this->redirect($defaultLoginDest); + } + + // Redirect the user to the page where they came from + if ($member) { + // Welcome message + $message = _t( + 'SilverStripe\\Security\\Member.WELCOMEBACK', + 'Welcome Back, {firstname}', + ['firstname' => $member->FirstName] + ); + Security::singleton()->setLoginMessage($message, ValidationResult::TYPE_GOOD); + } + + // Redirect back + return $this->redirectBack(); + } + + /** + * Try to authenticate the user + * + * @param array $data Submitted data + * @param ValidationResult $result + * @return Member Returns the member object on successful authentication + * or NULL on failure. + */ + public function checkLogin($data, &$result) + { + $member = $this->authenticator->authenticate($data, $result); + if ($member instanceof Member) { + return $member; + } + + return null; + } + + /** + * Try to authenticate the user + * + * @param Member $member + * @param array $data Submitted data + * @param HTTPRequest $request + * @return Member Returns the member object on successful authentication + * or NULL on failure. + */ + public function performLogin($member, $data, $request) + { + /** IdentityStore */ + $rememberMe = (isset($data['Remember']) && Security::config()->get('autologin_enabled')); + Injector::inst()->get(IdentityStore::class)->logIn($member, $rememberMe, $request); + + return $member; + } + + /** + * Invoked if password is expired and must be changed + * + * @skipUpgrade + * @return HTTPResponse + */ + protected function redirectToChangePassword() + { + $cp = ChangePasswordForm::create($this, 'ChangePasswordForm'); + $cp->sessionMessage( + _t('SilverStripe\\Security\\Member.PASSWORDEXPIRED', 'Your password has expired. Please choose a new one.'), + 'good' + ); + $changedPasswordLink = Security::singleton()->Link('changepassword'); + + return $this->redirect($this->addBackURLParam($changedPasswordLink)); + } +} diff --git a/src/Security/MemberAuthenticator/LogoutHandler.php b/src/Security/MemberAuthenticator/LogoutHandler.php new file mode 100644 index 000000000..21b2a4381 --- /dev/null +++ b/src/Security/MemberAuthenticator/LogoutHandler.php @@ -0,0 +1,64 @@ + 'logout' + ]; + + /** + * @var array + */ + private static $allowed_actions = [ + 'logout' + ]; + + + /** + * Log out form handler method + * + * This method is called when the user clicks on "logout" on the form + * created when the parameter $checkCurrentUser of the + * {@link __construct constructor} was set to TRUE and the user was + * currently logged in. + * + * @return bool|Member + */ + public function logout() + { + $member = Security::getCurrentUser(); + + return $this->doLogOut($member); + } + + /** + * + * @param Member $member + * @return bool|Member Return a member if something goes wrong + */ + public function doLogOut($member) + { + if ($member instanceof Member) { + Injector::inst()->get(IdentityStore::class)->logOut($this->getRequest()); + } + + return true; + } +} diff --git a/src/Security/MemberAuthenticator/LostPasswordForm.php b/src/Security/MemberAuthenticator/LostPasswordForm.php new file mode 100644 index 000000000..395f93b73 --- /dev/null +++ b/src/Security/MemberAuthenticator/LostPasswordForm.php @@ -0,0 +1,45 @@ + 'passwordsent', + '' => 'lostpassword', + ]; + + /** + * Since the logout and dologin actions may be conditionally removed, it's necessary to ensure these + * remain valid actions regardless of the member login state. + * + * @var array + * @config + */ + private static $allowed_actions = [ + 'lostpassword', + 'LostPasswordForm', + 'passwordsent', + ]; + + private $link = null; + + /** + * @param string $link The URL to recreate this request handler + */ + public function __construct($link) + { + $this->link = $link; + parent::__construct(); + } + + /** + * Return a link to this request handler. + * The link returned is supplied in the constructor + * + * @param string $action + * @return string + */ + public function link($action = null) + { + if ($action) { + return Controller::join_links($this->link, $action); + } + + return $this->link; + } + + /** + * URL handler for the initial lost-password screen + * + * @return array + */ + public function lostpassword() + { + + $message = _t( + 'SilverStripe\\Security\\Security.NOTERESETPASSWORD', + 'Enter your e-mail address and we will send you a link with which you can reset your password' + ); + + return [ + 'Content' => DBField::create_field('HTMLFragment', "$message
"), + 'Form' => $this->lostPasswordForm(), + ]; + } + + /** + * Show the "password sent" page, after a user has requested + * to reset their password. + * + * @return array + */ + public function passwordsent() + { + $request = $this->getRequest(); + $email = Convert::raw2xml(rawurldecode($request->param('EmailAddress')) . '.' . $request->getExtension()); + + $message = _t( + 'SilverStripe\\Security\\Security.PASSWORDSENTTEXT', + "Thank you! A reset link has been sent to '{email}', provided an account exists for this email" + . " address.", + ['email' => Convert::raw2xml($email)] + ); + + return [ + 'Title' => _t( + 'SilverStripe\\Security\\Security.PASSWORDSENTHEADER', + "Password reset link sent to '{email}'", + array('email' => $email) + ), + 'Content' => DBField::create_field('HTMLFragment', "$message
"), + 'Email' => $email + ]; + } + + + /** + * Factory method for the lost password form + * + * @skipUpgrade + * @return Form Returns the lost password form + */ + public function lostPasswordForm() + { + return LostPasswordForm::create( + $this, + $this->authenticatorClass, + 'lostPasswordForm', + null, + null, + false + ); + } + + /** + * Redirect to password recovery form + * + * @return HTTPResponse + */ + public function redirectToLostPassword() + { + $lostPasswordLink = Security::singleton()->Link('lostpassword'); + + return $this->redirect($this->addBackURLParam($lostPasswordLink)); + } + + /** + * Forgot password form handler method. + * Called when the user clicks on "I've lost my password". + * Extensions can use the 'forgotPassword' method to veto executing + * the logic, by returning FALSE. In this case, the user will be redirected back + * to the form without further action. It is recommended to set a message + * in the form detailing why the action was denied. + * + * @skipUpgrade + * @param array $data Submitted data + * @param LostPasswordForm $form + * @return HTTPResponse + */ + public function forgotPassword($data, $form) + { + // Ensure password is given + if (empty($data['Email'])) { + $form->sessionMessage( + _t( + 'SilverStripe\\Security\\Member.ENTEREMAIL', + 'Please enter an email address to get a password reset link.' + ), + 'bad' + ); + + return $this->redirectToLostPassword(); + } + + // Find existing member + $field = Member::config()->get('unique_identifier_field'); + /** @var Member $member */ + $member = Member::get()->filter([$field => $data['Email']])->first(); + + // Allow vetoing forgot password requests + $results = $this->extend('forgotPassword', $member); + if ($results && is_array($results) && in_array(false, $results, true)) { + return $this->redirectToLostPassword(); + } + + if ($member) { + $token = $member->generateAutologinTokenAndStoreHash(); + + $this->sendEmail($member, $token); + } + + // Avoid information disclosure by displaying the same status, + // regardless wether the email address actually exists + $link = Controller::join_links( + $this->link('passwordsent'), + rawurlencode($data['Email']), + '/' + ); + + return $this->redirect($this->addBackURLParam($link)); + } + + /** + * Send the email to the member that requested a reset link + * @param Member $member + * @param string $token + * @return bool + */ + protected function sendEmail($member, $token) + { + /** @var Email $email */ + $email = Email::create() + ->setHTMLTemplate('SilverStripe\\Control\\Email\\ForgotPasswordEmail') + ->setData($member) + ->setSubject(_t( + 'SilverStripe\\Security\\Member.SUBJECTPASSWORDRESET', + "Your password reset link", + 'Email subject' + )) + ->addData('PasswordResetLink', Security::getPasswordResetLink($member, $token)) + ->setTo($member->Email); + return $email->send(); + } +} diff --git a/src/Security/MemberAuthenticator/MemberAuthenticator.php b/src/Security/MemberAuthenticator/MemberAuthenticator.php new file mode 100644 index 000000000..d40f0b37c --- /dev/null +++ b/src/Security/MemberAuthenticator/MemberAuthenticator.php @@ -0,0 +1,199 @@ + + * @author Simon Erkelens'
@@ -177,7 +195,7 @@ class MemberLoginForm extends LoginForm
parent::restoreFormState();
$forceMessage = Session::get('MemberLoginForm.force_message');
- if (($member = Member::currentUser()) && !$forceMessage) {
+ if (($member = Security::getCurrentUser()) && !$forceMessage) {
$message = _t(
'SilverStripe\\Security\\Member.LOGGEDINAS',
"You're logged in as {name}.",
@@ -194,14 +212,6 @@ class MemberLoginForm extends LoginForm
return $this;
}
- /**
- * @return MemberLoginHandler
- */
- protected function buildRequestHandler()
- {
- return MemberLoginHandler::create($this);
- }
-
/**
* The name of this login form, to display in the frontend
* Replaces Authenticator::get_name()
diff --git a/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php b/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php
new file mode 100644
index 000000000..e88c68489
--- /dev/null
+++ b/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php
@@ -0,0 +1,107 @@
+sessionVariable;
+ }
+
+ /**
+ * Set the session variable name used to track member ID
+ *
+ * @param string $sessionVariable
+ */
+ public function setSessionVariable($sessionVariable)
+ {
+ $this->sessionVariable = $sessionVariable;
+ }
+
+ /**
+ * @param HTTPRequest $request
+ * @return Member
+ */
+ public function authenticateRequest(HTTPRequest $request)
+ {
+ // If ID is a bad ID it will be treated as if the user is not logged in, rather than throwing a
+ // ValidationException
+ $id = Session::get($this->getSessionVariable());
+ if (!$id) {
+ return null;
+ }
+ /** @var Member $member */
+ $member = Member::get()->byID($id);
+ return $member;
+ }
+
+ /**
+ * @param Member $member
+ * @param bool $persistent
+ * @param HTTPRequest $request
+ */
+ public function logIn(Member $member, $persistent = false, HTTPRequest $request = null)
+ {
+ static::regenerateSessionId();
+ Session::set($this->getSessionVariable(), $member->ID);
+
+ // This lets apache rules detect whether the user has logged in
+ // @todo make this a setting on the authentication handler
+ if (Member::config()->get('login_marker_cookie')) {
+ Cookie::set(Member::config()->get('login_marker_cookie'), 1, 0);
+ }
+ }
+
+ /**
+ * Regenerate the session_id.
+ */
+ protected static function regenerateSessionId()
+ {
+ if (!Member::config()->get('session_regenerate_id')) {
+ return;
+ }
+
+ // This can be called via CLI during testing.
+ if (Director::is_cli()) {
+ return;
+ }
+
+ $file = '';
+ $line = '';
+
+ // @ is to supress win32 warnings/notices when session wasn't cleaned up properly
+ // There's nothing we can do about this, because it's an operating system function!
+ if (!headers_sent($file, $line)) {
+ @session_regenerate_id(true);
+ }
+ }
+
+ /**
+ * @param HTTPRequest $request
+ */
+ public function logOut(HTTPRequest $request = null)
+ {
+ Session::clear($this->getSessionVariable());
+ }
+}
diff --git a/src/Security/MemberLoginHandler.php b/src/Security/MemberLoginHandler.php
deleted file mode 100644
index 0a4d2e241..000000000
--- a/src/Security/MemberLoginHandler.php
+++ /dev/null
@@ -1,240 +0,0 @@
-performLogin($data)) {
- return $this->logInUserAndRedirect($data);
- }
-
- /** @skipUpgrade */
- if (array_key_exists('Email', $data)) {
- Session::set('SessionForms.MemberLoginForm.Email', $data['Email']);
- Session::set('SessionForms.MemberLoginForm.Remember', isset($data['Remember']));
- }
-
- // Fail to login redirects back to form
- return $this->redirectBackToForm();
- }
-
- /**
- * Redirect to password recovery form
- *
- * @return HTTPResponse
- */
- public function redirectToLostPassword()
- {
- $lostPasswordLink = Security::singleton()->Link('lostpassword');
- return $this->redirect($this->addBackURLParam($lostPasswordLink));
- }
-
- public function getReturnReferer()
- {
- // Home of login form is always this url
- return Security::singleton()->Link('login');
- }
-
- /**
- * Login in the user and figure out where to redirect the browser.
- *
- * The $data has this format
- * array(
- * 'AuthenticationMethod' => 'MemberAuthenticator',
- * 'Email' => 'sam@silverstripe.com',
- * 'Password' => '1nitialPassword',
- * 'BackURL' => 'test/link',
- * [Optional: 'Remember' => 1 ]
- * )
- *
- * @param array $data
- * @return HTTPResponse
- */
- protected function logInUserAndRedirect($data)
- {
- Session::clear('SessionForms.MemberLoginForm.Email');
- Session::clear('SessionForms.MemberLoginForm.Remember');
-
- $member = Member::currentUser();
- if ($member->isPasswordExpired()) {
- return $this->redirectToChangePassword();
- }
-
- // Absolute redirection URLs may cause spoofing
- $backURL = $this->getBackURL();
- if ($backURL) {
- return $this->redirect($backURL);
- }
-
- // If a default login dest has been set, redirect to that.
- $defaultLoginDest = Security::config()->get('default_login_dest');
- if ($defaultLoginDest) {
- return $this->redirect($defaultLoginDest);
- }
-
- // Redirect the user to the page where they came from
- if ($member) {
- if (!empty($data['Remember'])) {
- Session::set('SessionForms.MemberLoginForm.Remember', '1');
- $member->logIn(true);
- } else {
- $member->logIn();
- }
-
- // Welcome message
- $message = _t(
- 'SilverStripe\\Security\\Member.WELCOMEBACK',
- "Welcome Back, {firstname}",
- ['firstname' => $member->FirstName]
- );
- Security::setLoginMessage($message, ValidationResult::TYPE_GOOD);
- }
-
- // Redirect back
- return $this->redirectBack();
- }
-
- /**
- * Log out form handler method
- *
- * This method is called when the user clicks on "logout" on the form
- * created when the parameter $checkCurrentUser of the
- * {@link __construct constructor} was set to TRUE and the user was
- * currently logged in.
- *
- * @return HTTPResponse
- */
- public function logout()
- {
- return Security::singleton()->logout();
- }
-
- /**
- * Try to authenticate the user
- *
- * @param array $data Submitted data
- * @return Member Returns the member object on successful authentication
- * or NULL on failure.
- */
- public function performLogin($data)
- {
- $member = call_user_func_array(
- [$this->authenticator_class, 'authenticate'],
- [$data, $this->form]
- );
- if ($member) {
- $member->LogIn(isset($data['Remember']));
- return $member;
- }
-
- // No member, can't login
- $this->extend('authenticationFailed', $data);
- return null;
- }
-
- /**
- * Forgot password form handler method.
- * Called when the user clicks on "I've lost my password".
- * Extensions can use the 'forgotPassword' method to veto executing
- * the logic, by returning FALSE. In this case, the user will be redirected back
- * to the form without further action. It is recommended to set a message
- * in the form detailing why the action was denied.
- *
- * @skipUpgrade
- * @param array $data Submitted data
- * @return HTTPResponse
- */
- public function forgotPassword($data)
- {
- // Ensure password is given
- if (empty($data['Email'])) {
- $this->form->sessionMessage(
- _t('SilverStripe\\Security\\Member.ENTEREMAIL', 'Please enter an email address to get a password reset link.'),
- 'bad'
- );
- return $this->redirectToLostPassword();
- }
-
- // Find existing member
- /** @var Member $member */
- $member = Member::get()->filter("Email", $data['Email'])->first();
-
- // Allow vetoing forgot password requests
- $results = $this->extend('forgotPassword', $member);
- if ($results && is_array($results) && in_array(false, $results, true)) {
- return $this->redirectToLostPassword();
- }
-
- if ($member) {
- $token = $member->generateAutologinTokenAndStoreHash();
-
- Email::create()
- ->setHTMLTemplate('SilverStripe\\Control\\Email\\ForgotPasswordEmail')
- ->setData($member)
- ->setSubject(_t('SilverStripe\\Security\\Member.SUBJECTPASSWORDRESET', "Your password reset link", 'Email subject'))
- ->addData('PasswordResetLink', Security::getPasswordResetLink($member, $token))
- ->setTo($member->Email)
- ->send();
- }
-
- // Avoid information disclosure by displaying the same status,
- // regardless wether the email address actually exists
- $link = Controller::join_links(
- Security::singleton()->Link('passwordsent'),
- rawurlencode($data['Email']),
- '/'
- );
- return $this->redirect($this->addBackURLParam($link));
- }
-
- /**
- * Invoked if password is expired and must be changed
- *
- * @skipUpgrade
- * @return HTTPResponse
- */
- protected function redirectToChangePassword()
- {
- $cp = ChangePasswordForm::create($this->form->getController(), 'ChangePasswordForm');
- $cp->sessionMessage(
- _t('SilverStripe\\Security\\Member.PASSWORDEXPIRED', 'Your password has expired. Please choose a new one.'),
- 'good'
- );
- $changedPasswordLink = Security::singleton()->Link('changepassword');
- return $this->redirect($this->addBackURLParam($changedPasswordLink));
- }
-}
diff --git a/src/Security/Member_GroupSet.php b/src/Security/Member_GroupSet.php
index 4b1161b92..582cd2816 100644
--- a/src/Security/Member_GroupSet.php
+++ b/src/Security/Member_GroupSet.php
@@ -109,7 +109,7 @@ class Member_GroupSet extends ManyManyList
{
$id = $this->getForeignID();
if ($id) {
- return DataObject::get_by_id('SilverStripe\\Security\\Member', $id);
+ return DataObject::get_by_id(Member::class, $id);
}
}
}
diff --git a/src/Security/Member_Validator.php b/src/Security/Member_Validator.php
index 1db3d5b27..aa8244573 100644
--- a/src/Security/Member_Validator.php
+++ b/src/Security/Member_Validator.php
@@ -2,8 +2,8 @@
namespace SilverStripe\Security;
-use SilverStripe\Forms\RequiredFields;
use SilverStripe\Forms\GridField\GridFieldDetailForm_ItemRequest;
+use SilverStripe\Forms\RequiredFields;
/**
* Member Validator
diff --git a/src/Security/PasswordEncryptor.php b/src/Security/PasswordEncryptor.php
index 79b4999e0..dc2bffe51 100644
--- a/src/Security/PasswordEncryptor.php
+++ b/src/Security/PasswordEncryptor.php
@@ -2,8 +2,8 @@
namespace SilverStripe\Security;
-use SilverStripe\Core\Config\Config;
use ReflectionClass;
+use SilverStripe\Core\Config\Config;
/**
* Allows pluggable password encryption.
diff --git a/src/Security/Permission.php b/src/Security/Permission.php
index 8d5a663d9..bb6d8d381 100644
--- a/src/Security/Permission.php
+++ b/src/Security/Permission.php
@@ -6,9 +6,9 @@ use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Resettable;
use SilverStripe\Dev\TestOnly;
use SilverStripe\i18n\i18nEntityProvider;
-use SilverStripe\ORM\DB;
-use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\ArrayList;
+use SilverStripe\ORM\DataObject;
+use SilverStripe\ORM\DB;
use SilverStripe\ORM\SS_List;
use SilverStripe\View\TemplateGlobalProvider;
@@ -131,10 +131,10 @@ class Permission extends DataObject implements TemplateGlobalProvider, Resettabl
public static function check($code, $arg = "any", $member = null, $strict = true)
{
if (!$member) {
- if (!Member::currentUserID()) {
+ if (!Security::getCurrentUser()) {
return false;
}
- $member = Member::currentUserID();
+ $member = Security::getCurrentUser();
}
return self::checkMember($member, $code, $arg, $strict);
@@ -171,10 +171,9 @@ class Permission extends DataObject implements TemplateGlobalProvider, Resettabl
public static function checkMember($member, $code, $arg = "any", $strict = true)
{
if (!$member) {
- $memberID = $member = Member::currentUserID();
- } else {
- $memberID = (is_object($member)) ? $member->ID : $member;
+ $member = Security::getCurrentUser();
}
+ $memberID = ($member instanceof Member) ? $member->ID : $member;
if (!$memberID) {
return false;
@@ -347,7 +346,7 @@ class Permission extends DataObject implements TemplateGlobalProvider, Resettabl
{
// Default to current member, with session-caching
if (!$memberID) {
- $member = Member::currentUser();
+ $member = Security::getCurrentUser();
if ($member && isset($_SESSION['Permission_groupList'][$member->ID])) {
return $_SESSION['Permission_groupList'][$member->ID];
}
@@ -459,7 +458,7 @@ class Permission extends DataObject implements TemplateGlobalProvider, Resettabl
/**
* Returns all members for a specific permission.
*
- * @param $code String|array Either a single permission code, or a list of permission codes
+ * @param string|array $code Either a single permission code, or a list of permission codes
* @return SS_List Returns a set of member that have the specified
* permission.
*/
diff --git a/src/Security/PermissionCheckboxSetField.php b/src/Security/PermissionCheckboxSetField.php
index 0303f34d7..e7d17f45a 100644
--- a/src/Security/PermissionCheckboxSetField.php
+++ b/src/Security/PermissionCheckboxSetField.php
@@ -2,14 +2,13 @@
namespace SilverStripe\Security;
+use InvalidArgumentException;
use SilverStripe\Core\Config\Config;
use SilverStripe\Forms\FormField;
-use SilverStripe\ORM\DataObject;
-use SilverStripe\ORM\SS_List;
use SilverStripe\ORM\ArrayList;
+use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DataObjectInterface;
-use SilverStripe\View\Requirements;
-use InvalidArgumentException;
+use SilverStripe\ORM\SS_List;
/**
* Shows a categorized list of available permissions (through {@link Permission::get_codes()}).
diff --git a/src/Security/RememberLoginHash.php b/src/Security/RememberLoginHash.php
index e98deb71c..9fda0df9a 100644
--- a/src/Security/RememberLoginHash.php
+++ b/src/Security/RememberLoginHash.php
@@ -2,10 +2,10 @@
namespace SilverStripe\Security;
-use SilverStripe\ORM\FieldType\DBDatetime;
-use SilverStripe\ORM\DataObject;
-use DateTime;
use DateInterval;
+use DateTime;
+use SilverStripe\ORM\DataObject;
+use SilverStripe\ORM\FieldType\DBDatetime;
/**
* Persists a token associated with a device for users who opted for the "Remember Me"
@@ -16,7 +16,8 @@ use DateInterval;
* is discarded as well.
*
* @property string $DeviceID
- * @property string $RememberLoginHash
+ * @property string $ExpiryDate
+ * @property string $Hash
* @method Member Member()
*/
class RememberLoginHash extends DataObject
diff --git a/src/Security/RequestAuthenticationHandler.php b/src/Security/RequestAuthenticationHandler.php
new file mode 100644
index 000000000..ab2f7af72
--- /dev/null
+++ b/src/Security/RequestAuthenticationHandler.php
@@ -0,0 +1,83 @@
+handlers;
+ }
+
+ /**
+ * Set an associative array of handlers
+ *
+ * @param AuthenticationHandler[] $handlers
+ * @return $this
+ */
+ public function setHandlers(array $handlers)
+ {
+ $this->handlers = $handlers;
+ return $this;
+ }
+
+ public function authenticateRequest(HTTPRequest $request)
+ {
+ /** @var AuthenticationHandler $handler */
+ foreach ($this->getHandlers() as $name => $handler) {
+ // in order to add cookies, etc
+ $member = $handler->authenticateRequest($request);
+ if ($member) {
+ Security::setCurrentUser($member);
+ return;
+ }
+ }
+ }
+ /**
+ * Log into the identity-store handlers attached to this request filter
+ *
+ * @param Member $member
+ * @param bool $persistent
+ * @param HTTPRequest $request
+ */
+ public function logIn(Member $member, $persistent = false, HTTPRequest $request = null)
+ {
+ $member->beforeMemberLoggedIn();
+
+ foreach ($this->getHandlers() as $handler) {
+ $handler->logIn($member, $persistent, $request);
+ }
+
+ Security::setCurrentUser($member);
+ $member->afterMemberLoggedIn();
+ }
+
+ /**
+ * Log out of all the identity-store handlers attached to this request filter
+ *
+ * @param HTTPRequest $request
+ */
+ public function logOut(HTTPRequest $request = null)
+ {
+ foreach ($this->getHandlers() as $handler) {
+ $handler->logOut($request);
+ }
+
+ Security::setCurrentUser(null);
+ }
+}
diff --git a/src/Security/Security.php b/src/Security/Security.php
index 13b4fe9af..16ae137e8 100644
--- a/src/Security/Security.php
+++ b/src/Security/Security.php
@@ -2,33 +2,32 @@
namespace SilverStripe\Security;
-use Page;
use LogicException;
-use SilverStripe\CMS\Controllers\ContentController;
+use Page;
+use SilverStripe\CMS\Controllers\ModelAsController;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
+use SilverStripe\Control\HTTPResponse_Exception;
+use SilverStripe\Control\RequestHandler;
use SilverStripe\Control\Session;
use SilverStripe\Core\ClassInfo;
-use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Convert;
+use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\Deprecation;
use SilverStripe\Dev\TestOnly;
-use SilverStripe\Forms\EmailField;
-use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\Form;
-use SilverStripe\Forms\FormAction;
use SilverStripe\ORM\ArrayList;
-use SilverStripe\ORM\DB;
+use SilverStripe\ORM\DataModel;
use SilverStripe\ORM\DataObject;
+use SilverStripe\ORM\DB;
use SilverStripe\ORM\FieldType\DBField;
+use SilverStripe\ORM\FieldType\DBHTMLText;
use SilverStripe\ORM\ValidationResult;
use SilverStripe\View\ArrayData;
use SilverStripe\View\SSViewer;
use SilverStripe\View\TemplateGlobalProvider;
-use Exception;
-use SilverStripe\View\ViewableData_Customised;
use Subsite;
/**
@@ -46,13 +45,10 @@ class Security extends Controller implements TemplateGlobalProvider
'passwordsent',
'changepassword',
'ping',
- 'LoginForm',
- 'ChangePasswordForm',
- 'LostPasswordForm',
);
/**
- * Default user name. Only used in dev-mode by {@link setDefaultAdmin()}
+ * Default user name. {@link setDefaultAdmin()}
*
* @var string
* @see setDefaultAdmin()
@@ -60,7 +56,7 @@ class Security extends Controller implements TemplateGlobalProvider
protected static $default_username;
/**
- * Default password. Only used in dev-mode by {@link setDefaultAdmin()}
+ * Default password. {@link setDefaultAdmin()}
*
* @var string
* @see setDefaultAdmin()
@@ -74,7 +70,7 @@ class Security extends Controller implements TemplateGlobalProvider
* @config
* @var bool
*/
- protected static $strict_path_checking = false;
+ private static $strict_path_checking = false;
/**
* The password encryption algorithm to use by default.
@@ -96,7 +92,7 @@ class Security extends Controller implements TemplateGlobalProvider
/**
* Determine if login username may be remembered between login sessions
- * If set to false this will disable autocomplete and prevent username persisting in the session
+ * If set to false this will disable auto-complete and prevent username persisting in the session
*
* @config
* @var bool
@@ -118,7 +114,7 @@ class Security extends Controller implements TemplateGlobalProvider
private static $template = 'BlankPage';
/**
- * Template thats used to render the pages.
+ * Template that is used to render the pages.
*
* @var string
* @config
@@ -157,7 +153,7 @@ class Security extends Controller implements TemplateGlobalProvider
*
* @var string
*/
- private static $login_url = "Security/login";
+ private static $login_url = 'Security/login';
/**
* The default logout URL
@@ -166,7 +162,7 @@ class Security extends Controller implements TemplateGlobalProvider
*
* @var string
*/
- private static $logout_url = "Security/logout";
+ private static $logout_url = 'Security/logout';
/**
* The default lost password URL
@@ -175,7 +171,7 @@ class Security extends Controller implements TemplateGlobalProvider
*
* @var string
*/
- private static $lost_password_url = "Security/lostpassword";
+ private static $lost_password_url = 'Security/lostpassword';
/**
* Value of X-Frame-Options header
@@ -206,7 +202,7 @@ class Security extends Controller implements TemplateGlobalProvider
* @var boolean If set to TRUE or FALSE, {@link database_is_ready()}
* will always return FALSE. Used for unit testing.
*/
- static $force_database_is_ready = null;
+ protected static $force_database_is_ready;
/**
* When the database has once been verified as ready, it will not do the
@@ -214,7 +210,107 @@ class Security extends Controller implements TemplateGlobalProvider
*
* @var bool
*/
- static $database_is_ready = false;
+ protected static $database_is_ready = false;
+
+ /**
+ * @var Authenticator[] available authenticators
+ */
+ private $authenticators = [];
+
+ /**
+ * @var Member Currently logged in user (if available)
+ */
+ protected static $currentUser;
+
+ /**
+ * @return Authenticator[]
+ */
+ public function getAuthenticators()
+ {
+ return $this->authenticators;
+ }
+
+ /**
+ * @param Authenticator[] $authenticators
+ */
+ public function setAuthenticators(array $authenticators)
+ {
+ $this->authenticators = $authenticators;
+ }
+
+ protected function init()
+ {
+ parent::init();
+
+ // Prevent clickjacking, see https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options
+ $frameOptions = static::config()->get('frame_options');
+ if ($frameOptions) {
+ $this->getResponse()->addHeader('X-Frame-Options', $frameOptions);
+ }
+
+ // Prevent search engines from indexing the login page
+ $robotsTag = static::config()->get('robots_tag');
+ if ($robotsTag) {
+ $this->getResponse()->addHeader('X-Robots-Tag', $robotsTag);
+ }
+ }
+
+ public function index()
+ {
+ return $this->httpError(404); // no-op
+ }
+
+ /**
+ * Get the selected authenticator for this request
+ *
+ * @param string $name The identifier of the authenticator in your config
+ * @return Authenticator Class name of Authenticator
+ * @throws LogicException
+ */
+ protected function getAuthenticator($name = 'default')
+ {
+ $authenticators = $this->authenticators;
+
+ if (isset($authenticators[$name])) {
+ return $authenticators[$name];
+ }
+
+ throw new LogicException('No valid authenticator found');
+ }
+
+ /**
+ * Get all registered authenticators
+ *
+ * @param int $service The type of service that is requested
+ * @return Authenticator[] Return an array of Authenticator objects
+ */
+ public function getApplicableAuthenticators($service = Authenticator::LOGIN)
+ {
+ $authenticators = $this->authenticators;
+
+ /** @var Authenticator $authenticator */
+ foreach ($authenticators as $name => $authenticator) {
+ if (!($authenticator->supportedServices() & $service)) {
+ unset($authenticators[$name]);
+ }
+ }
+
+ return $authenticators;
+ }
+
+ /**
+ * Check if a given authenticator is registered
+ *
+ * @param string $authenticator The configured identifier of the authenicator
+ * @return bool Returns TRUE if the authenticator is registered, FALSE
+ * otherwise.
+ */
+ public function hasAuthenticator($authenticator)
+ {
+ $authenticators = $this->authenticators;
+
+ return !empty($authenticators[$authenticator]);
+ }
/**
* Register that we've had a permission failure trying to view the given page
@@ -252,14 +348,19 @@ class Security extends Controller implements TemplateGlobalProvider
if (Director::is_ajax()) {
$response = ($controller) ? $controller->getResponse() : new HTTPResponse();
$response->setStatusCode(403);
- if (!Member::currentUser()) {
- $response->setBody(_t('SilverStripe\\CMS\\Controllers\\ContentController.NOTLOGGEDIN', 'Not logged in'));
- $response->setStatusDescription(_t('SilverStripe\\CMS\\Controllers\\ContentController.NOTLOGGEDIN', 'Not logged in'));
- // Tell the CMS to allow re-aunthentication
+ if (!static::getCurrentUser()) {
+ $response->setBody(
+ _t('SilverStripe\\CMS\\Controllers\\ContentController.NOTLOGGEDIN', 'Not logged in')
+ );
+ $response->setStatusDescription(
+ _t('SilverStripe\\CMS\\Controllers\\ContentController.NOTLOGGEDIN', 'Not logged in')
+ );
+ // Tell the CMS to allow re-authentication
if (CMSSecurity::enabled()) {
$response->addHeader('X-Reauthenticate', '1');
}
}
+
return $response;
}
@@ -269,15 +370,15 @@ class Security extends Controller implements TemplateGlobalProvider
$messageSet = $configMessageSet;
} else {
$messageSet = array(
- 'default' => _t(
+ 'default' => _t(
'SilverStripe\\Security\\Security.NOTEPAGESECURED',
"That page is secured. Enter your credentials below and we will send "
- . "you right along."
+ . "you right along."
),
'alreadyLoggedIn' => _t(
'SilverStripe\\Security\\Security.ALREADYLOGGEDIN',
"You don't have access to this page. If you have another account that "
- . "can access that page, you can log in again below.",
+ . "can access that page, you can log in again below.",
"%s will be replaced with a link to log in."
)
);
@@ -288,7 +389,7 @@ class Security extends Controller implements TemplateGlobalProvider
$messageSet = array('default' => $messageSet);
}
- $member = Member::currentUser();
+ $member = static::getCurrentUser();
// Work out the right message to show
if ($member && $member->exists()) {
@@ -303,12 +404,8 @@ class Security extends Controller implements TemplateGlobalProvider
$message = $messageSet['default'];
}
- // Somewhat hackish way to render a login form with an error message.
- $me = new Security();
- $form = $me->LoginForm();
- $form->sessionMessage($message, ValidationResult::TYPE_WARNING);
- Session::set('MemberLoginForm.force_message', 1);
- $loginResponse = $me->login();
+ static::singleton()->setLoginMessage($message, ValidationResult::TYPE_WARNING);
+ $loginResponse = static::singleton()->login();
if ($loginResponse instanceof HTTPResponse) {
return $loginResponse;
}
@@ -322,7 +419,7 @@ class Security extends Controller implements TemplateGlobalProvider
$message = $messageSet['default'];
}
- static::setLoginMessage($message, ValidationResult::TYPE_WARNING);
+ static::singleton()->setLoginMessage($message, ValidationResult::TYPE_WARNING);
Session::set("BackURL", $_SERVER['REQUEST_URI']);
@@ -336,79 +433,43 @@ class Security extends Controller implements TemplateGlobalProvider
));
}
- protected function init()
+ /**
+ * @param null|Member $currentUser
+ */
+ public static function setCurrentUser($currentUser = null)
{
- parent::init();
-
- // Prevent clickjacking, see https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options
- $frameOptions = $this->config()->get('frame_options');
- if ($frameOptions) {
- $this->getResponse()->addHeader('X-Frame-Options', $frameOptions);
- }
-
- // Prevent search engines from indexing the login page
- $robotsTag = $this->config()->get('robots_tag');
- if ($robotsTag) {
- $this->getResponse()->addHeader('X-Robots-Tag', $robotsTag);
- }
- }
-
- public function index()
- {
- return $this->httpError(404); // no-op
+ self::$currentUser = $currentUser;
}
/**
- * Get the selected authenticator for this request
- *
- * @return string Class name of Authenticator
- * @throws LogicException
+ * @return null|Member
*/
- protected function getAuthenticator()
+ public static function getCurrentUser()
{
- $authenticator = $this->getRequest()->requestVar('AuthenticationMethod');
- if ($authenticator && Authenticator::is_registered($authenticator)) {
- return $authenticator;
- } elseif ($authenticator !== '' && Authenticator::is_registered(Authenticator::get_default_authenticator())) {
- return Authenticator::get_default_authenticator();
- }
-
- throw new LogicException('No valid authenticator found');
- }
-
- /**
- * Get the login form to process according to the submitted data
- *
- * @return Form
- * @throws Exception
- */
- public function LoginForm()
- {
- $authenticator = $this->getAuthenticator();
- if ($authenticator) {
- return $authenticator::get_login_form($this);
- }
- throw new Exception('Passed invalid authentication method');
+ return self::$currentUser;
}
/**
* Get the login forms for all available authentication methods
*
+ * @deprecated 5.0.0 Now handled by {@link static::delegateToMultipleHandlers}
+ *
* @return array Returns an array of available login forms (array of Form
* objects).
*
- * @todo Check how to activate/deactivate authentication methods
*/
- public function GetLoginForms()
+ public function getLoginForms()
{
- $forms = array();
+ Deprecation::notice('5.0.0', 'Now handled by delegateToMultipleHandlers');
- $authenticators = Authenticator::get_authenticators();
- foreach ($authenticators as $authenticator) {
- $forms[] = $authenticator::get_login_form($this);
- }
-
- return $forms;
+ return array_map(
+ function (Authenticator $authenticator) {
+ return [
+ $authenticator->getLoginHandler($this->Link())->loginForm()
+ ];
+ },
+ $this->getApplicableAuthenticators()
+ );
}
@@ -436,6 +497,12 @@ class Security extends Controller implements TemplateGlobalProvider
/**
* Log the currently logged in user out
*
+ * Logging out without ID-parameter in the URL, will log the user out of all applicable Authenticators.
+ *
+ * Adding an ID will only log the user out of that Authentication method.
+ *
+ * Logging out of Default will always completely log out the user.
+ *
* @param bool $redirect Redirect the user back to where they came.
* - If it's false, the code calling logout() is
* responsible for sending the user where-ever
@@ -444,14 +511,34 @@ class Security extends Controller implements TemplateGlobalProvider
*/
public function logout($redirect = true)
{
- $member = Member::currentUser();
- if ($member) {
- $member->logOut();
+ $this->extend('beforeMemberLoggedOut');
+ $member = static::getCurrentUser();
+
+ if ($member) { // If we don't have a member, there's not much to log out.
+ /** @var array|Authenticator[] $authenticators */
+ $authenticators = $this->getApplicableAuthenticators(Authenticator::LOGOUT);
+
+ /** @var Authenticator[] $authenticator */
+ foreach ($authenticators as $name => $authenticator) {
+ $handler = $authenticator->getLogOutHandler(Controller::join_links($this->Link(), 'logout'));
+ $this->delegateToHandler($handler, $name);
+ }
+ // In the rare case, but plausible with e.g. an external IdentityStore, the user is not logged out.
+ if (static::getCurrentUser() !== null) {
+ $this->extend('failureMemberLoggedOut', $authenticator);
+
+ return $this->redirectBack();
+ }
+ $this->extend('successMemberLoggedOut', $authenticator);
+ // Member is successfully logged out. Write possible changes to the database.
+ $member->write();
}
+ $this->extend('afterMemberLoggedOut');
if ($redirect && (!$this->getResponse()->isFinished())) {
return $this->redirectBack();
}
+
return null;
}
@@ -484,10 +571,10 @@ class Security extends Controller implements TemplateGlobalProvider
// This step is necessary in cases such as automatic redirection where a user is authenticated
// upon landing on an SSL secured site and is automatically logged in, or some other case
// where the user has permissions to continue but is not given the option.
- if ($this->getRequest()->requestVar('BackURL')
- && !$this->getLoginMessage()
- && ($member = Member::currentUser())
+ if (!$this->getLoginMessage()
+ && ($member = static::getCurrentUser())
&& $member->exists()
+ && $this->getRequest()->requestVar('BackURL')
) {
return $this->redirectBack();
}
@@ -511,25 +598,24 @@ class Security extends Controller implements TemplateGlobalProvider
// Create new instance of page holder
/** @var Page $holderPage */
- $holderPage = new $pageClass;
+ $holderPage = Injector::inst()->create($pageClass);
$holderPage->Title = $title;
/** @skipUpgrade */
$holderPage->URLSegment = 'Security';
// Disable ID-based caching of the log-in page by making it a random number
- $holderPage->ID = -1 * rand(1, 10000000);
+ $holderPage->ID = -1 * random_int(1, 10000000);
- $controllerClass = $holderPage->getControllerName();
- /** @var ContentController $controller */
- $controller = $controllerClass::create($holderPage);
+ $controller = ModelAsController::controller_for($holderPage);
$controller->setDataModel($this->model);
$controller->doInit();
+
return $controller;
}
/**
* Combine the given forms into a formset with a tabbed interface
*
- * @param array $forms List of LoginForm instances
+ * @param array|Form[] $forms
* @return string
*/
protected function generateLoginFormSet($forms)
@@ -537,6 +623,7 @@ class Security extends Controller implements TemplateGlobalProvider
$viewData = new ArrayData(array(
'Forms' => new ArrayList($forms),
));
+
return $viewData->renderWith(
$this->getTemplatesFor('MultiAuthenticatorLogin')
);
@@ -561,6 +648,7 @@ class Security extends Controller implements TemplateGlobalProvider
if ($messageCast !== ValidationResult::CAST_HTML) {
$message = Convert::raw2xml($message);
}
+
return sprintf(' $message $message ' . _t('SilverStripe\\Security\\Security.ENTERNEWPASSWORD', 'Please enter a new password.') . ' ' . _t('SilverStripe\\Security\\Security.CHANGEPASSWORDBELOW', 'You can change your password below.') . ' The password reset link is invalid or expired. You can request a new one here or change your password after'
- . ' you logged in.