From e226b67d06523291865cdfec3edb52e64c436d8e Mon Sep 17 00:00:00 2001 From: Sam Minnee Date: Sat, 22 Apr 2017 16:30:10 +1200 Subject: [PATCH 1/9] Refactoring of authenticators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Further down the line, I'm only returning the `Member` on the doLogin, so it's possible for the Handler or Extending Handler to move to a second step. Also cleaned up some minor typos I ran in to. Nothing major. This solution works and is manually tested for now. Supports multiple login forms that end up in the correct handler. I haven't gotten past the handler yet, as I've yet to refactor my Yubiauth implementation. FIX: Corrections to the multi-login-form support. Importantly, the system provide a URL-space for each handler, e.g. “Security/login/default” and “Security/login/other”. This is much cleaner than identifying the active authenticator by a get parameter, and means that the tabbed interface is only needed on the very first view. Note that you can test this without a module simply by loading the default authenticator twice: SilverStripe\Security\Security: authenticators: default: SilverStripe\Security\MemberAuthenticator\Authenticator other: SilverStripe\Security\MemberAuthenticator\Authenticator FIX: Refactor delegateToHandler / delegateToHandlers to have less duplicated code. --- _config/security.yml | 9 +- src/Core/Config/Config_ForClass.php | 2 +- src/Forms/FormRequestHandler.php | 2 +- src/Security/Authenticator.php | 147 +++----- src/Security/BasicAuth.php | 9 +- .../Authenticator.php} | 115 +++--- .../CMSLoginHandler.php} | 8 +- .../ChangePasswordForm.php | 3 +- .../ChangePasswordHandler.php | 5 +- .../LoginForm.php} | 18 +- .../LoginHandler.php} | 169 +++++---- .../LostPasswordHandler.php | 259 +++++++++++++ src/Security/Security.php | 352 +++++++++++------- src/View/ViewableData.php | 2 +- tests/php/Security/SecurityTest.php | 8 +- 15 files changed, 700 insertions(+), 408 deletions(-) rename src/Security/{MemberAuthenticator.php => MemberAuthenticator/Authenticator.php} (61%) rename src/Security/{CMSMemberLoginHandler.php => MemberAuthenticator/CMSLoginHandler.php} (92%) rename src/Security/{ => MemberAuthenticator}/ChangePasswordForm.php (96%) rename src/Security/{ => MemberAuthenticator}/ChangePasswordHandler.php (96%) rename src/Security/{MemberLoginForm.php => MemberAuthenticator/LoginForm.php} (95%) rename src/Security/{MemberLoginHandler.php => MemberAuthenticator/LoginHandler.php} (58%) create mode 100644 src/Security/MemberAuthenticator/LostPasswordHandler.php diff --git a/_config/security.yml b/_config/security.yml index 10051c588..49e90763d 100644 --- a/_config/security.yml +++ b/_config/security.yml @@ -1,4 +1,9 @@ -SilverStripe\Security\MemberLoginForm: +SilverStripe\Security\MemberAuthenticator\LoginForm: required_fields: - Email - - Password \ No newline at end of file + - Password + +SilverStripe\Security\Security: + default_authenticator: SilverStripe\Security\MemberAuthenticator\Authenticator + authenticators: + - SilverStripe\Security\MemberAuthenticator\Authenticator diff --git a/src/Core/Config/Config_ForClass.php b/src/Core/Config/Config_ForClass.php index b89e5007b..448bd9443 100644 --- a/src/Core/Config/Config_ForClass.php +++ b/src/Core/Config/Config_ForClass.php @@ -9,7 +9,7 @@ class Config_ForClass /** * @var string $class */ - protected $class; + public $class; /** * @param string|object $class diff --git a/src/Forms/FormRequestHandler.php b/src/Forms/FormRequestHandler.php index a2115e8e0..526f488bb 100644 --- a/src/Forms/FormRequestHandler.php +++ b/src/Forms/FormRequestHandler.php @@ -228,7 +228,7 @@ class FormRequestHandler extends RequestHandler // First, try a handler method on the controller (has been checked for allowed_actions above already) $controller = $this->form->getController(); if ($controller && $controller->hasMethod($funcName)) { - return $controller->$funcName($vars, $this->form, $request); + return $controller->$funcName($vars, $this->form, $request, $this); } // Otherwise, try a handler method on the form request handler. diff --git a/src/Security/Authenticator.php b/src/Security/Authenticator.php index 66bbc7843..beee5158c 100644 --- a/src/Security/Authenticator.php +++ b/src/Security/Authenticator.php @@ -16,125 +16,74 @@ use SilverStripe\Forms\Form; * * @author Markus Lanthaler */ -abstract class Authenticator +interface Authenticator { - use Injectable; - use Configurable; - use Extensible; - public function __construct() - { - $this->constructExtensions(); - } + const LOGIN = 1; + const LOGOUT = 2; + const CHANGE_PASSWORD = 4; + const RESET_PASSWORD = 8; + const CMS_LOGIN = 16; /** - * This variable holds all authenticators that should be used + * Returns the services supported by this authenticator * - * @var array - */ - private static $authenticators = []; - - /** - * Used to influence the order of authenticators on the login-screen - * (default shows first). + * The number should be a bitwise-OR of 1 or more of the following constants: + * Authenticator::LOGIN, Authenticator::LOGOUT, Authenticator::CHANGE_PASSWORD, + * Authenticator::RESET_PASSWORD, or Authenticator::CMS_LOGIN * - * @var string + * @return int */ - private static $default_authenticator = MemberAuthenticator::class; - + public function supportedServices(); /** - * Method to authenticate an user + * Return RequestHandler to manage the log-in process. * - * @param array $RAW_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 - */ - public static function authenticate($RAW_data, Form $form = null) - { - } - - /** - * Method that creates the login form for this authentication method + * The default URL of the RequetHandler should return the initial log-in form, any other + * URL may be added for other steps & processing. * - * @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) - { - } - - /** - * Method that creates the re-authentication form for the in-CMS view + * URL-handling methods may return an array [ "Form" => (form-object) ] which can then + * be merged into a default controller. * - * @param Controller $controller + * @param $link The base link to use for this RequestHnadler */ - public static function get_cms_login_form(Controller $controller) - { - } + public function getLoginHandler($link); /** - * Determine if this authenticator supports in-cms reauthentication + * @todo + */ + public function getCMSLoginHandler($link); + + /** + * Return RequestHandler to manage the change-password process. * - * @return bool - */ - public static function supports_cms() - { - return false; - } - - /** - * Check if a given authenticator is registered + * The default URL of the RequetHandler should return the initial change-password form, + * any other URL may be added for other steps & processing. * - * @param string $authenticator Name of the authenticator class to check - * @return bool Returns TRUE if the authenticator is registered, FALSE - * otherwise. - */ - public static function is_registered($authenticator) - { - $authenticators = self::config()->get('authenticators'); - if (count($authenticators) === 0) { - $authenticators = [self::config()->get('default_authenticator')]; - } - - return in_array($authenticator, $authenticators, true); - } - - - /** - * Get all registered authenticators + * URL-handling methods may return an array [ "Form" => (form-object) ] which can then + * be merged into a default controller. * - * @return array Returns an array with the class names of all registered - * authenticators. + * @param $link The base link to use for this RequestHnadler */ - public static function get_authenticators() - { - $authenticators = self::config()->get('authenticators'); - $default = self::config()->get('default_authenticator'); - - if (count($authenticators) === 0) { - $authenticators = [$default]; - } - // put default authenticator first (mainly for tab-order on loginform) - // But only if there's no other authenticator - if (($key = array_search($default, $authenticators, true)) && count($authenticators) > 1) { - unset($authenticators[$key]); - array_unshift($authenticators, $default); - } - - return $authenticators; - } + public function getChangePasswordHandler($link); /** - * @return string + * @todo */ - public static function get_default_authenticator() - { - return self::config()->get('default_authenticator'); - } + public function getLostPasswordHandler($link); + + /** + * Method to authenticate an user. + * + * @param array $data Raw data to authenticate the user. + * @param string $message A variable to return an error message if authentication fails + * @return Member The matched member, or null if the authentication fails + */ + public function authenticate($data, &$message); + + /** + * Return the keys that should be passed to authenticate() + * @return array + */ +// public function getAuthenticateFields(); } diff --git a/src/Security/BasicAuth.php b/src/Security/BasicAuth.php index 1645f0909..cf4c5d8b3 100644 --- a/src/Security/BasicAuth.php +++ b/src/Security/BasicAuth.php @@ -8,6 +8,9 @@ use SilverStripe\Control\HTTPResponse_Exception; use SilverStripe\Core\Config\Config; use SilverStripe\Core\Config\Configurable; use SilverStripe\Dev\SapphireTest; +use SilverStripe\Core\Injector\Injector; + +use SilverStripe\Security\MemberAuthenticator\Authenticator; /** * Provides an interface to HTTP basic authentication. @@ -82,10 +85,12 @@ class BasicAuth $member = null; if (isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) { - $member = MemberAuthenticator::authenticate(array( + $authenticator = Injector::inst()->get(Authenticator::class); + + $member = $authenticator->authenticate([ 'Email' => $_SERVER['PHP_AUTH_USER'], 'Password' => $_SERVER['PHP_AUTH_PW'], - ), null); + ], $dummy); } if (!$member && $tryUsingSessionLogin) { diff --git a/src/Security/MemberAuthenticator.php b/src/Security/MemberAuthenticator/Authenticator.php similarity index 61% rename from src/Security/MemberAuthenticator.php rename to src/Security/MemberAuthenticator/Authenticator.php index 85431bb78..527ed36e2 100644 --- a/src/Security/MemberAuthenticator.php +++ b/src/Security/MemberAuthenticator/Authenticator.php @@ -1,32 +1,50 @@ */ -class MemberAuthenticator extends Authenticator +class Authenticator implements BaseAuthenticator { + public function supportedServices() + { + // Bitwise-OR of all the supported services, to make a bitmask + return BaseAuthenticator::LOGIN | BaseAuthenticator::LOGOUT | BaseAuthenticator::CHANGE_PASSWORD + | BaseAuthenticator::RESET_PASSWORD | BaseAuthenticator::CMS_LOGIN; + } + /** - * 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 + * @inherit */ - private static $migrate_legacy_hashes = array( - 'md5' => 'md5_v2.4', - 'sha1' => 'sha1_v2.4' - ); + public function authenticate($data, &$message) + { + $success = null; + + // Find authenticated member + $member = $this->authenticateMember($data, $message, $success); + + // Optionally record every login attempt as a {@link LoginAttempt} object + $this->recordLoginAttempt($data, $member, $success); + + if ($member) { + Session::clear('BackURL'); + } + + return $success ? $member : null; + } /** * Attempt to find and authenticate member if possible from the given data @@ -36,7 +54,7 @@ class MemberAuthenticator extends Authenticator * @param bool &$success Success flag * @return Member Found member, regardless of successful login */ - protected static function authenticate_member($data, $form, &$success) + protected function authenticateMember($data, &$message, &$success) { // Default success to false $success = false; @@ -94,9 +112,12 @@ class MemberAuthenticator extends Authenticator if ($member) { $member->registerFailedLogin(); } - if ($form) { - $form->setSessionValidationResult($result, true); - } + $message = implode("; ", array_map( + function ($message) { + return $message['message']; + }, + $result->getMessages() + )); } else { if ($member) { $member->registerSuccessfulLogin(); @@ -112,9 +133,8 @@ class MemberAuthenticator extends Authenticator * * @param array $data * @param Member $member - * @param bool $success */ - protected static function record_login_attempt($data, $member, $success) + protected function recordLoginAttempt($data, $member) { if (!Security::config()->login_recording) { return; @@ -154,66 +174,31 @@ class MemberAuthenticator extends Authenticator } /** - * 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() + * @inherit */ - public static function authenticate($data, Form $form = null) + public function getLostPasswordHandler($link) { - // 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; + return LostPasswordHandler::create($link, $this); } - /** - * 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 + * @inherit */ - public static function get_login_form(Controller $controller) + public function getChangePasswordHandler($link) { - /** @skipUpgrade */ - return MemberLoginForm::create($controller, self::class, "LoginForm"); + return ChangePasswordHandler::create($link, $this); } - public static function get_cms_login_form(Controller $controller) + /** + * @inherit + */ + public function getLoginHandler($link) { - /** @skipUpgrade */ - return CMSMemberLoginForm::create($controller, self::class, "LoginForm"); + return LoginHandler::create($link, $this); } - public static function supports_cms() + public function getCMSLoginHandler($link) { - // Don't automatically support subclasses of MemberAuthenticator - return get_called_class() === __CLASS__; + return CMSMemberLoginHandler::create($controller, self::class, "LoginForm"); } } diff --git a/src/Security/CMSMemberLoginHandler.php b/src/Security/MemberAuthenticator/CMSLoginHandler.php similarity index 92% rename from src/Security/CMSMemberLoginHandler.php rename to src/Security/MemberAuthenticator/CMSLoginHandler.php index cec76bc2a..ca469e23e 100644 --- a/src/Security/CMSMemberLoginHandler.php +++ b/src/Security/MemberAuthenticator/CMSLoginHandler.php @@ -1,11 +1,11 @@ performLogin($data)) { return $this->logInUserAndRedirect($data); @@ -78,7 +78,7 @@ PHP * @param array $data * @return HTTPResponse */ - protected function logInUserAndRedirect($data) + protected function logInUserAndRedirect($data, $formHandler) { // Check password expiry if (Member::currentUser()->isPasswordExpired()) { diff --git a/src/Security/ChangePasswordForm.php b/src/Security/MemberAuthenticator/ChangePasswordForm.php similarity index 96% rename from src/Security/ChangePasswordForm.php rename to src/Security/MemberAuthenticator/ChangePasswordForm.php index e2db15e14..740f7f51d 100644 --- a/src/Security/ChangePasswordForm.php +++ b/src/Security/MemberAuthenticator/ChangePasswordForm.php @@ -1,6 +1,6 @@ ' @@ -194,14 +198,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/MemberLoginHandler.php b/src/Security/MemberAuthenticator/LoginHandler.php similarity index 58% rename from src/Security/MemberLoginHandler.php rename to src/Security/MemberAuthenticator/LoginHandler.php index 0a4d2e241..f11f55cb0 100644 --- a/src/Security/MemberLoginHandler.php +++ b/src/Security/MemberAuthenticator/LoginHandler.php @@ -1,20 +1,26 @@ 'login', + ]; /** * Since the logout and dologin actions may be conditionally removed, it's necessary to ensure these @@ -24,22 +30,74 @@ class MemberLoginHandler extends FormRequestHandler * @config */ private static $allowed_actions = [ + 'login', + 'LoginForm', 'dologin', 'logout', ]; + private $link = null; + + /** + * @param string $link The URL to recreate this request handler + * @param Authenticator $authenticator The + */ + public function __construct($link, Authenticator $authenticator) + { + $this->link = $link; + $this->authenticator = $authenticator; + parent::__construct($link, $this); + } + + /** + * Return a link to this request handler. + * The link returned is supplied in the constructor + * @return string + */ + public function link($action = null) + { + if ($action) { + return Controller::join_links($this->link, $action); + } else { + return $this->link; + } + } + + /** + * URL handler for the log-in screen + */ + public function login() + { + return [ + 'Form' => $this->loginForm(), + ]; + } + + /** + * Return the MemberLoginForm form + */ + public function loginForm() + { + return LoginForm::create( + $this, + get_class($this->authenticator), + 'LoginForm' + ); + } + /** * Login form handler method * * This method is called when the user clicks on "Log in" * * @param array $data Submitted data + * @param LoginHandler $formHandler * @return HTTPResponse */ - public function dologin($data) + public function doLogin($data, $formHandler) { if ($this->performLogin($data)) { - return $this->logInUserAndRedirect($data); + return $this->logInUserAndRedirect($data, $formHandler); } /** @skipUpgrade */ @@ -48,25 +106,15 @@ class MemberLoginHandler extends FormRequestHandler Session::set('SessionForms.MemberLoginForm.Remember', isset($data['Remember'])); } + return $this->redirectBack(); // Fail to login redirects back to form - return $this->redirectBackToForm(); + return $formHandler->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'); + return $this->link(); } /** @@ -84,7 +132,7 @@ class MemberLoginHandler extends FormRequestHandler * @param array $data * @return HTTPResponse */ - protected function logInUserAndRedirect($data) + protected function logInUserAndRedirect($data, $formHandler) { Session::clear('SessionForms.MemberLoginForm.Email'); Session::clear('SessionForms.MemberLoginForm.Remember'); @@ -152,13 +200,13 @@ class MemberLoginHandler extends FormRequestHandler */ public function performLogin($data) { - $member = call_user_func_array( - [$this->authenticator_class, 'authenticate'], - [$data, $this->form] - ); + $message = null; + $member = $this->authenticator->authenticate($data, $message); if ($member) { $member->LogIn(isset($data['Remember'])); return $member; + } else { + Security::setLoginMessage($message, ValidationResult::TYPE_ERROR); } // No member, can't login @@ -166,61 +214,6 @@ class MemberLoginHandler extends FormRequestHandler 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 * @@ -229,7 +222,7 @@ class MemberLoginHandler extends FormRequestHandler */ protected function redirectToChangePassword() { - $cp = ChangePasswordForm::create($this->form->getController(), 'ChangePasswordForm'); + $cp = ChangePasswordForm::create($this, 'ChangePasswordForm'); $cp->sessionMessage( _t('SilverStripe\\Security\\Member.PASSWORDEXPIRED', 'Your password has expired. Please choose a new one.'), 'good' @@ -237,4 +230,18 @@ class MemberLoginHandler extends FormRequestHandler $changedPasswordLink = Security::singleton()->Link('changepassword'); return $this->redirect($this->addBackURLParam($changedPasswordLink)); } + + + + /** + * @todo copypaste from FormRequestHandler - refactor + */ + protected function addBackURLParam($link) + { + $backURL = $this->getBackURL(); + if ($backURL) { + return Controller::join_links($link, '?BackURL=' . urlencode($backURL)); + } + return $link; + } } diff --git a/src/Security/MemberAuthenticator/LostPasswordHandler.php b/src/Security/MemberAuthenticator/LostPasswordHandler.php new file mode 100644 index 000000000..5deb6d5c1 --- /dev/null +++ b/src/Security/MemberAuthenticator/LostPasswordHandler.php @@ -0,0 +1,259 @@ + '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 $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 + * @return string + */ + public function link($action = null) + { + if ($action) { + return Controller::join_links($this->link, $action); + } else { + return $this->link; + } + } + + /** + * URL handler for the initial lost-password screen + */ + public function lostpassword() + { + + $message = _t( + '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. + */ + public function passwordsent() + { + $request = $this->getRequest(); + $email = Convert::raw2xml(rawurldecode($request->param('EmailAddress')) . '.' . $request->getExtension()); + + $message = _t( + '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( + '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 LoginForm::create( + $this, + $this->authenticatorClass, + 'LostPasswordForm', + new FieldList( + new EmailField('Email', _t('Member.EMAIL', 'Email')) + ), + new FieldList( + new FormAction( + 'forgotPassword', + _t('Security.BUTTONSEND', 'Send me the password reset link') + ) + ), + false + ); + } + + /** + * Redirect to password recovery form + * + * @return HTTPResponse + */ + public function redirectToLostPassword() + { + $lostPasswordLink = Security::singleton()->Link('lostpassword'); + return $this->redirect($this->addBackURLParam($lostPasswordLink)); + } + + public function getReturnReferer() + { + return $this->link(); + } + + /** + * 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('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('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( + $this->link('passwordsent'), + rawurlencode($data['Email']), + '/' + ); + return $this->redirect($this->addBackURLParam($link)); + } + + /** + * @todo copypaste from FormRequestHandler - refactor + */ + protected function addBackURLParam($link) + { + $backURL = $this->getBackURL(); + if ($backURL) { + return Controller::join_links($link, '?BackURL=' . urlencode($backURL)); + } + return $link; + } +} diff --git a/src/Security/Security.php b/src/Security/Security.php index 13b4fe9af..06f1b311b 100644 --- a/src/Security/Security.php +++ b/src/Security/Security.php @@ -9,7 +9,9 @@ use SilverStripe\Control\Controller; use SilverStripe\Control\Director; use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPResponse; +use SilverStripe\Control\HTTPResponse_Exception; use SilverStripe\Control\Session; +use SilverStripe\Control\RequestHandler; use SilverStripe\Core\ClassInfo; use SilverStripe\Core\Config\Config; use SilverStripe\Core\Convert; @@ -20,6 +22,7 @@ use SilverStripe\Forms\FieldList; use SilverStripe\Forms\Form; use SilverStripe\Forms\FormAction; use SilverStripe\ORM\ArrayList; +use SilverStripe\ORM\DataModel; use SilverStripe\ORM\DB; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\FieldType\DBField; @@ -30,6 +33,7 @@ use SilverStripe\View\TemplateGlobalProvider; use Exception; use SilverStripe\View\ViewableData_Customised; use Subsite; +use SilverStripe\Core\Injector\Injector; /** * Implements a basic security model @@ -206,7 +210,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 = null; /** * When the database has once been verified as ready, it will not do the @@ -214,7 +218,58 @@ class Security extends Controller implements TemplateGlobalProvider * * @var bool */ - static $database_is_ready = false; + protected static $database_is_ready = false; + + protected static $authenticators = []; + + protected static $default_authenticator = MemberAuthenticator\Authenticator::class; + + /** + * Get all registered authenticators + * + * @return array Return an array of Authenticator objects + */ + public static function getAuthenticators() + { + $authenticatorClasses = self::config()->authenticators; + $default = self::config()->default_authenticator; + + if (!$authenticatorClasses) { + if ($default) { + $authenticatorClasses = [$default]; + } else { + return []; + } + } + + // put default authenticator first (mainly for tab-order on loginform) + // But only if there's no other authenticator + if (($key = array_search($default, $authenticatorClasses, true)) && count($$authenticatorClasses) > 1) { + unset($authenticatorClasses[$key]); + array_unshift($authenticatorClasses, $default); + } + + return array_map(function ($class) { + return Injector::inst()->get($class); + }, $authenticatorClasses); + } + + /** + * Check if a given authenticator is registered + * + * @param string $authenticator Name of the authenticator class to check + * @return bool Returns TRUE if the authenticator is registered, FALSE + * otherwise. + */ + public static function hasAuthenticator($authenticator) + { + $authenticators = self::config()->get('authenticators'); + if (count($authenticators) === 0) { + $authenticators = [self::config()->get('default_authenticator')]; + } + + return in_array($authenticator, $authenticators, true); + } /** * Register that we've had a permission failure trying to view the given page @@ -304,10 +359,10 @@ class Security extends Controller implements TemplateGlobalProvider } // 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); +// $me = new Security(); +// $form = $me->LoginForm(); +// $form->sessionMessage($message, ValidationResult::TYPE_WARNING); +// Session::set('MemberLoginForm.force_message', 1); $loginResponse = $me->login(); if ($loginResponse instanceof HTTPResponse) { return $loginResponse; @@ -367,10 +422,14 @@ class Security extends Controller implements TemplateGlobalProvider protected function getAuthenticator() { $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(); + if ($authenticator && self::hasAuthenticator($authenticator)) { + return Injector::inst()->get($authenticator); + + } elseif ($authenticator !== '') { + $authenticators = self::getAuthenticators(); + if (count($authenticators) > 0) { + return $authenticators[0]; + } } throw new LogicException('No valid authenticator found'); @@ -386,7 +445,8 @@ class Security extends Controller implements TemplateGlobalProvider { $authenticator = $this->getAuthenticator(); if ($authenticator) { - return $authenticator::get_login_form($this); + $handler = $authenticator->getLoginHandler($this->Link()); + return $handler->handleRequest($this->request, DataModel::inst()); } throw new Exception('Passed invalid authentication method'); } @@ -399,16 +459,14 @@ class Security extends Controller implements TemplateGlobalProvider * * @todo Check how to activate/deactivate authentication methods */ - public function GetLoginForms() + public function getLoginForms() { - $forms = array(); - - $authenticators = Authenticator::get_authenticators(); - foreach ($authenticators as $authenticator) { - $forms[] = $authenticator::get_login_form($this); - } - - return $forms; + return array_map( + function ($authenticator) { + return $authenticator->getLoginHandler($this->Link())->handleRequest($this->getRequest(), DataModel::inst()); + }, + Security::getAuthenticators() + ); } @@ -529,7 +587,7 @@ class Security extends Controller implements TemplateGlobalProvider /** * Combine the given forms into a formset with a tabbed interface * - * @param array $forms List of LoginForm instances + * @param array $authenticators List of Authenticator instances * @return string */ protected function generateLoginFormSet($forms) @@ -598,29 +656,141 @@ class Security extends Controller implements TemplateGlobalProvider * * @return string|HTTPResponse Returns the "login" page as HTML code. */ - public function login() + public function login($request) { // Check pre-login process if ($response = $this->preLogin()) { return $response; } - // Get response handler - $controller = $this->getResponseController(_t('SilverStripe\\Security\\Security.LOGIN', 'Log in')); + $link = $this->link("login"); + + // Delegate to a single handler - Security/login//... + if ($authenticatorName = $request->param('ID')) { + $request->shift(); + + $authenticator = $this->getAuthenticator($authenticatorName); + if (!$authenticator) { + throw new HTTPResponse_Exception(404, 'No authenticator "' . $authenticatorName . '"'); + } + + $handler = $authenticator->getLoginHandler(Controller::join_links($link, $authenticatorName)); + + return $this->delegateToHandler( + $handler, + _t('Security.LOGIN', 'Log in'), + $this->getTemplatesFor('login') + ); + + // Delegate to all of them, building a tabbed view - Security/login + } else { + $handlers = $this->getAuthenticators(); + array_walk( + $handlers, + function (&$auth, $name) use ($link) { + $auth = $auth->getLoginHandler(Controller::join_links($link, $name)); + } + ); + + if (count($handlers) === 1) { + return $this->delegateToHandler( + array_values($handlers)[0], + _t('Security.LOGIN', 'Log in'), + $this->getTemplatesFor('login') + ); + + } else { + return $this->delegateToFormSet( + $handlers, + _t('Security.LOGIN', 'Log in'), + $this->getTemplatesFor('login') + ); + } + } + + } + + /** + * Delegate to an number of handlers, extracting their forms and rendering a tabbed form-set. + * This is used to built the log-in page where there are multiple authenticators active. + * + * @param string $title The title of the form + * @param array $templates + * @return array|HTTPResponse|RequestHandler|\SilverStripe\ORM\FieldType\DBHTMLText|string + */ + protected function delegateToFormSet(array $handlers, $title, array $templates) + { + + // Process each of the handlers + $results = array_map( + function ($handler) { + return $handler->handleRequest($this->getRequest(), \SilverStripe\ORM\DataModel::inst()); + }, + $handlers + ); + + // Aggregate all their forms, assuming they all return + $forms = []; + foreach ($results as $authName => $singleResult) { + // The result *must* be an array with a Form key + if (!is_array($singleResult) || !isset($singleResult['Form'])) { + user_error('Authenticator "' . $authName . '" doesn\'t support a tabbed login', E_USER_WARNING); + continue; + } + + $forms[] = $singleResult['Form']; + } + + if (!$forms) { + throw new \LogicException("No authenticators found compatible with a tabbed login"); + } + + return $this->renderWrappedController( + $title, + [ + 'Form' => $this->generateLoginFormSet($forms), + ], + $templates + ); + + } + + /** + * Delegate to another RequestHandler, rendering any fragment arrays into an appropriate. + * controller. + * + * @param string $title The title of the form + * @param array $templates + * @return array|HTTPResponse|RequestHandler|\SilverStripe\ORM\FieldType\DBHTMLText|string + */ + protected function delegateToHandler(RequestHandler $handler, $title, array $templates) + { + $result = $handler->handleRequest($this->getRequest(), \SilverStripe\ORM\DataModel::inst()); + + // Return the customised controller - used to render in a Form + // Post requests are expected to be login posts, so they'll be handled downstairs + if (is_array($result)) { + $result = $this->renderWrappedController($title, $result, $templates); + } + + return $result; + } + + /** + * Render the given fragments into a security page controller with the given title. + * @param $title string The title to give the security page + * @param $fragments A map of objects to render into the page, e.g. "Form" + * @param $templates An array of templates to use for the render + */ + protected function renderWrappedController($title, array $fragments, array $templates) + { + $controller = $this->getResponseController($title); // if the controller calls Director::redirect(), this will break early if (($response = $controller->getResponse()) && $response->isFinished()) { return $response; } - $forms = $this->GetLoginForms(); - if (!count($forms)) { - user_error( - 'No login-forms found, please use Authenticator::register_authenticator() to add one', - E_USER_ERROR - ); - } - // Handle any form messages from validation, etc. $messageType = ''; $message = $this->getLoginMessage($messageType); @@ -628,26 +798,16 @@ class Security extends Controller implements TemplateGlobalProvider // We've displayed the message in the form output, so reset it for the next run. static::clearLoginMessage(); - // only display tabs when more than one authenticator is provided - // to save bandwidth and reduce the amount of custom styling needed - if (count($forms) > 1) { - $content = $this->generateLoginFormSet($forms); - } else { - $content = $forms[0]->forTemplate(); + if ($message) { + $messageResult = [ + 'Content' => DBField::create_field('HTMLFragment', $message), + 'Message' => DBField::create_field('HTMLFragment', $message), + 'MessageType' => $messageType + ]; + $result = array_merge($fragments, $messageResult); } - // Finally, customise the controller to add any form messages and the form. - $customisedController = $controller->customise(array( - "Content" => DBField::create_field('HTMLFragment', $message), - "Message" => DBField::create_field('HTMLFragment', $message), - "MessageType" => $messageType, - "Form" => $content, - )); - - // Return the customised controller - return $customisedController->renderWith( - $this->getTemplatesFor('login') - ); + return $controller->customise($fragments)->renderWith($templates); } public function basicauthlogin() @@ -663,95 +823,17 @@ class Security extends Controller implements TemplateGlobalProvider */ public function lostpassword() { - $controller = $this->getResponseController(_t('SilverStripe\\Security\\Security.LOSTPASSWORDHEADER', 'Lost Password')); - - // if the controller calls Director::redirect(), this will break early - if (($response = $controller->getResponse()) && $response->isFinished()) { - return $response; - } - - $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' + $handler = $this->getAuthenticator()->getLostPasswordHandler( + Controller::join_links($this->link(), 'lostpassword') ); - /** @var ViewableData_Customised $customisedController */ - $customisedController = $controller->customise(array( - 'Content' => DBField::create_field('HTMLFragment', "

$message

"), - 'Form' => $this->LostPasswordForm(), - )); - //Controller::$currentController = $controller; - $result = $customisedController->renderWith($this->getTemplatesFor('lostpassword')); - - return $result; - } - - - /** - * Factory method for the lost password form - * - * @skipUpgrade - * @return Form Returns the lost password form - */ - public function LostPasswordForm() - { - return MemberLoginForm::create( - $this, - Config::inst()->get('Authenticator', 'default_authenticator'), - 'LostPasswordForm', - new FieldList( - new EmailField('Email', _t('SilverStripe\\Security\\Member.EMAIL', 'Email')) - ), - new FieldList( - new FormAction( - 'forgotPassword', - _t(__CLASS__.'.BUTTONSEND', 'Send me the password reset link') - ) - ), - false + return $this->delegateToHandler( + $handler, + _t('SilverStripe\\Security\\Security.LOSTPASSWORDHEADER', 'Lost Password'), + $this->getTemplatesFor('lostpassword') ); } - - /** - * Show the "password sent" page, after a user has requested - * to reset their password. - * - * @param HTTPRequest $request The HTTPRequest for this action. - * @return string Returns the "password sent" page as HTML code. - */ - public function passwordsent($request) - { - $controller = $this->getResponseController(_t('SilverStripe\\Security\\Security.LOSTPASSWORDHEADER', 'Lost Password')); - - // if the controller calls Director::redirect(), this will break early - if (($response = $controller->getResponse()) && $response->isFinished()) { - return $response; - } - - $email = Convert::raw2xml(rawurldecode($request->param('ID')) . '.' . $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.", - array('email' => Convert::raw2xml($email)) - ); - $customisedController = $controller->customise(array( - 'Title' => _t( - 'SilverStripe\\Security\\Security.PASSWORDSENTHEADER', - "Password reset link sent to '{email}'", - array('email' => $email) - ), - 'Content' => DBField::create_field('HTMLFragment', "

$message

"), - 'Email' => $email - )); - - //Controller::$currentController = $controller; - return $customisedController->renderWith($this->getTemplatesFor('passwordsent')); - } - - /** * Create a link to the password reset form. * @@ -867,7 +949,7 @@ class Security extends Controller implements TemplateGlobalProvider */ public function ChangePasswordForm() { - return ChangePasswordForm::create($this, 'ChangePasswordForm'); + return MemberAuthenticator\ChangePasswordForm::create($this, 'ChangePasswordForm'); } /** diff --git a/src/View/ViewableData.php b/src/View/ViewableData.php index 9d3209d57..b681fd530 100644 --- a/src/View/ViewableData.php +++ b/src/View/ViewableData.php @@ -329,7 +329,7 @@ class ViewableData implements IteratorAggregate } // Fall back to default_cast - $default = $this->config()->get('default_cast'); + $default = self::config()->get('default_cast'); if (empty($default)) { throw new Exception("No default_cast"); } diff --git a/tests/php/Security/SecurityTest.php b/tests/php/Security/SecurityTest.php index a4d941f4c..c7ebb8abe 100644 --- a/tests/php/Security/SecurityTest.php +++ b/tests/php/Security/SecurityTest.php @@ -49,8 +49,8 @@ class SecurityTest extends FunctionalTest protected function setUp() { // This test assumes that MemberAuthenticator is present and the default - $this->priorAuthenticators = Authenticator::get_authenticators(); - $this->priorDefaultAuthenticator = Authenticator::get_default_authenticator(); + // $this->priorAuthenticators = Authenticator::get_authenticators(); + // $this->priorDefaultAuthenticator = Authenticator::get_default_authenticator(); // Set to an empty array of authenticators to enable the default Config::modify()->set(Authenticator::class, 'authenticators', []); @@ -74,8 +74,8 @@ class SecurityTest extends FunctionalTest // Restore selected authenticator // MemberAuthenticator might not actually be present - Config::modify()->set(Authenticator::class, 'authenticators', $this->priorAuthenticators); - Config::modify()->set(Authenticator::class, 'default_authenticator', $this->priorDefaultAuthenticator); + // Config::modify()->set(Authenticator::class, 'authenticators', $this->priorAuthenticators); + // Config::modify()->set(Authenticator::class, 'default_authenticator', $this->priorDefaultAuthenticator); // Restore unique identifier field Member::config()->unique_identifier_field = $this->priorUniqueIdentifierField; From 7af7e6719e82385a1a60813141fd291df5a16667 Mon Sep 17 00:00:00 2001 From: Sam Minnee Date: Sun, 23 Apr 2017 15:30:33 +1200 Subject: [PATCH 2/9] API: Security.authenticators is now a map, not an array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authenticators is now a map of keys -> service names. The key is used in things such as URL segments. The “default_authenticator” value has been replaced with the key “default” in this map, although in time a default authenticator may not be needed. IX: Refactor login() to avoid code duplication on single/multiple handlers IX: Refactor LoginHandler to be more amenable to extension IX: Fixed permissionFailure hack his LoginHandler is expected to be the starting point for other custom authenticators so it should be easier to repurpose components `of it. IX: Fix database-is-ready checks in tests. IX: Fixed MemberAuthenticatorTest to match the new API IX: Update security URLs in MemberTest --- _config/security.yml | 3 +- src/Dev/SapphireTest.php | 2 +- src/Security/CMSSecurity.php | 2 +- .../MemberAuthenticator/Authenticator.php | 3 +- .../MemberAuthenticator/LoginHandler.php | 56 ++-- src/Security/Security.php | 290 ++++++++++-------- tests/php/Security/BasicAuthTest.php | 2 +- .../php/Security/MemberAuthenticatorTest.php | 138 +++------ tests/php/Security/MemberTest.php | 9 +- tests/php/Security/SecurityTest.php | 35 +-- 10 files changed, 256 insertions(+), 284 deletions(-) diff --git a/_config/security.yml b/_config/security.yml index 49e90763d..02f960fa0 100644 --- a/_config/security.yml +++ b/_config/security.yml @@ -4,6 +4,5 @@ SilverStripe\Security\MemberAuthenticator\LoginForm: - Password SilverStripe\Security\Security: - default_authenticator: SilverStripe\Security\MemberAuthenticator\Authenticator authenticators: - - SilverStripe\Security\MemberAuthenticator\Authenticator + default: SilverStripe\Security\MemberAuthenticator\Authenticator diff --git a/src/Dev/SapphireTest.php b/src/Dev/SapphireTest.php index 6485836cd..20b49f020 100644 --- a/src/Dev/SapphireTest.php +++ b/src/Dev/SapphireTest.php @@ -276,7 +276,7 @@ class SapphireTest extends PHPUnit_Framework_TestCase if (Controller::has_curr()) { Controller::curr()->setSession(Session::create(array())); } - Security::$database_is_ready = null; + Security::clear_database_is_ready(); // Set up test routes $this->setUpRoutes(); diff --git a/src/Security/CMSSecurity.php b/src/Security/CMSSecurity.php index 48b6ff31d..38d9cd5f5 100644 --- a/src/Security/CMSSecurity.php +++ b/src/Security/CMSSecurity.php @@ -180,7 +180,7 @@ PHP public function LoginForm() { - $authenticator = $this->getAuthenticator(); + $authenticator = $this->getAuthenticator('default'); if ($authenticator && $authenticator::supports_cms()) { return $authenticator::get_cms_login_form($this); } diff --git a/src/Security/MemberAuthenticator/Authenticator.php b/src/Security/MemberAuthenticator/Authenticator.php index 527ed36e2..c86cabe04 100644 --- a/src/Security/MemberAuthenticator/Authenticator.php +++ b/src/Security/MemberAuthenticator/Authenticator.php @@ -10,6 +10,7 @@ use InvalidArgumentException; use SilverStripe\Security\Authenticator as BaseAuthenticator; use SilverStripe\Security\Security; use SilverStripe\Security\Member; +use SilverStripe\Security\LoginAttempt; /** * Authenticator for the default "member" method @@ -134,7 +135,7 @@ class Authenticator implements BaseAuthenticator * @param array $data * @param Member $member */ - protected function recordLoginAttempt($data, $member) + protected function recordLoginAttempt($data, $member, $success) { if (!Security::config()->login_recording) { return; diff --git a/src/Security/MemberAuthenticator/LoginHandler.php b/src/Security/MemberAuthenticator/LoginHandler.php index f11f55cb0..59e55e43c 100644 --- a/src/Security/MemberAuthenticator/LoginHandler.php +++ b/src/Security/MemberAuthenticator/LoginHandler.php @@ -94,21 +94,28 @@ class LoginHandler extends RequestHandler * @param LoginHandler $formHandler * @return HTTPResponse */ - public function doLogin($data, $formHandler) + public function doLogin($data, $form) { - if ($this->performLogin($data)) { - return $this->logInUserAndRedirect($data, $formHandler); + $failureMessage = null; + + // Successful login + if ($member = $this->checkLogin($data, $failureMessage)) { + $this->performLogin($member, $data); + return $this->redirectAfterSuccessfulLogin(); } + $form->sessionMessage($failureMessage, 'bad'); + + // Failed login + /** @skipUpgrade */ if (array_key_exists('Email', $data)) { Session::set('SessionForms.MemberLoginForm.Email', $data['Email']); Session::set('SessionForms.MemberLoginForm.Remember', isset($data['Remember'])); } - return $this->redirectBack(); // Fail to login redirects back to form - return $formHandler->redirectBackToForm(); + return $form->getRequestHandler()->redirectBackToForm(); } @@ -132,7 +139,7 @@ class LoginHandler extends RequestHandler * @param array $data * @return HTTPResponse */ - protected function logInUserAndRedirect($data, $formHandler) + protected function redirectAfterSuccessfulLogin() { Session::clear('SessionForms.MemberLoginForm.Email'); Session::clear('SessionForms.MemberLoginForm.Remember'); @@ -156,13 +163,6 @@ class LoginHandler extends RequestHandler // 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', @@ -188,7 +188,8 @@ class LoginHandler extends RequestHandler */ public function logout() { - return Security::singleton()->logout(); + Security::singleton()->logout(); + return $this->redirectBack(); } /** @@ -198,22 +199,33 @@ class LoginHandler extends RequestHandler * @return Member Returns the member object on successful authentication * or NULL on failure. */ - public function performLogin($data) + public function checkLogin($data, &$message) { $message = null; $member = $this->authenticator->authenticate($data, $message); if ($member) { - $member->LogIn(isset($data['Remember'])); return $member; - } else { - Security::setLoginMessage($message, ValidationResult::TYPE_ERROR); - } - // No member, can't login - $this->extend('authenticationFailed', $data); - return null; + } else { + // No member, can't login + $this->extend('authenticationFailed', $data); + return null; + + } } + /** + * 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($member, $data) + { + $member->LogIn(isset($data['Remember'])); + return $member; + } /** * Invoked if password is expired and must be changed * diff --git a/src/Security/Security.php b/src/Security/Security.php index 06f1b311b..dff7e5e42 100644 --- a/src/Security/Security.php +++ b/src/Security/Security.php @@ -220,10 +220,64 @@ class Security extends Controller implements TemplateGlobalProvider */ protected static $database_is_ready = false; + /** + * @var array available authenticators + */ protected static $authenticators = []; + /** + * @var string Default authenticator + */ protected static $default_authenticator = MemberAuthenticator\Authenticator::class; + /** + * @inheritdoc + */ + protected function init() + { + 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); + } + } + + /** + * @inheritdoc + */ + public function index() + { + return $this->httpError(404); // no-op + } + + /** + * Get the selected authenticator for this request + * + * @param $name string The identifier of the authenticator in your config + * @return string Class name of Authenticator + * @throws LogicException + */ + protected function getAuthenticator($name) + { + $authenticators = self::config()->authenticators; + + if (!$name) $name = 'default'; + + if (isset($authenticators[$name])) { + return Injector::inst()->get($authenticators[$name]); + } + + throw new LogicException('No valid authenticator found'); + } + /** * Get all registered authenticators * @@ -231,44 +285,24 @@ class Security extends Controller implements TemplateGlobalProvider */ public static function getAuthenticators() { - $authenticatorClasses = self::config()->authenticators; - $default = self::config()->default_authenticator; - - if (!$authenticatorClasses) { - if ($default) { - $authenticatorClasses = [$default]; - } else { - return []; - } - } - - // put default authenticator first (mainly for tab-order on loginform) - // But only if there's no other authenticator - if (($key = array_search($default, $authenticatorClasses, true)) && count($$authenticatorClasses) > 1) { - unset($authenticatorClasses[$key]); - array_unshift($authenticatorClasses, $default); - } + $authenticators = self::config()->authenticators; return array_map(function ($class) { return Injector::inst()->get($class); - }, $authenticatorClasses); + }, $authenticators); } /** * Check if a given authenticator is registered * - * @param string $authenticator Name of the authenticator class to check + * @param string $authenticator The configured identifier of the authenicator * @return bool Returns TRUE if the authenticator is registered, FALSE * otherwise. */ public static function hasAuthenticator($authenticator) { $authenticators = self::config()->get('authenticators'); - if (count($authenticators) === 0) { - $authenticators = [self::config()->get('default_authenticator')]; - } - - return in_array($authenticator, $authenticators, true); + return !empty($authenticators[$authenticator]); } /** @@ -358,12 +392,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(); + Security::setLoginMessage($message, ValidationResult::TYPE_WARNING); + $loginResponse = (new Security())->login(new HTTPRequest('GET', '/')); if ($loginResponse instanceof HTTPResponse) { return $loginResponse; } @@ -391,50 +421,6 @@ class Security extends Controller implements TemplateGlobalProvider )); } - protected function init() - { - 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 - } - - /** - * Get the selected authenticator for this request - * - * @return string Class name of Authenticator - * @throws LogicException - */ - protected function getAuthenticator() - { - $authenticator = $this->getRequest()->requestVar('AuthenticationMethod'); - if ($authenticator && self::hasAuthenticator($authenticator)) { - return Injector::inst()->get($authenticator); - - } elseif ($authenticator !== '') { - $authenticators = self::getAuthenticators(); - if (count($authenticators) > 0) { - return $authenticators[0]; - } - } - - throw new LogicException('No valid authenticator found'); - } - /** * Get the login form to process according to the submitted data * @@ -443,7 +429,7 @@ class Security extends Controller implements TemplateGlobalProvider */ public function LoginForm() { - $authenticator = $this->getAuthenticator(); + $authenticator = $this->getAuthenticator('default'); if ($authenticator) { $handler = $authenticator->getLoginHandler($this->Link()); return $handler->handleRequest($this->request, DataModel::inst()); @@ -654,7 +640,9 @@ class Security extends Controller implements TemplateGlobalProvider * For multiple authenticators, Security_MultiAuthenticatorLogin is used. * See getTemplatesFor and getIncludeTemplate for how to override template logic * - * @return string|HTTPResponse Returns the "login" page as HTML code. + * @param $request + * @return HTTPResponse|string Returns the "login" page as HTML code. + * @throws HTTPResponse_Exception */ public function login($request) { @@ -666,61 +654,54 @@ class Security extends Controller implements TemplateGlobalProvider $link = $this->link("login"); // Delegate to a single handler - Security/login//... - if ($authenticatorName = $request->param('ID')) { + if ($name = $request->param('ID')) { $request->shift(); - $authenticator = $this->getAuthenticator($authenticatorName); + $authenticator = $this->getAuthenticator($name); if (!$authenticator) { - throw new HTTPResponse_Exception(404, 'No authenticator "' . $authenticatorName . '"'); + throw new HTTPResponse_Exception(404, 'No authenticator "' . $name . '"'); } - $handler = $authenticator->getLoginHandler(Controller::join_links($link, $authenticatorName)); - - return $this->delegateToHandler( - $handler, - _t('Security.LOGIN', 'Log in'), - $this->getTemplatesFor('login') - ); + $authenticators = [ $name => $authenticator ]; // Delegate to all of them, building a tabbed view - Security/login } else { - $handlers = $this->getAuthenticators(); - array_walk( - $handlers, - function (&$auth, $name) use ($link) { - $auth = $auth->getLoginHandler(Controller::join_links($link, $name)); - } - ); - - if (count($handlers) === 1) { - return $this->delegateToHandler( - array_values($handlers)[0], - _t('Security.LOGIN', 'Log in'), - $this->getTemplatesFor('login') - ); - - } else { - return $this->delegateToFormSet( - $handlers, - _t('Security.LOGIN', 'Log in'), - $this->getTemplatesFor('login') - ); - } + $authenticators = $this->getAuthenticators(); } + $handlers = $authenticators; + array_walk( + $handlers, + function (&$auth, $name) use ($link) { + $auth = $auth->getLoginHandler(Controller::join_links($link, $name)); + } + ); + + return $this->delegateToMultipleHandlers( + $handlers, + _t('Security.LOGIN', 'Log in'), + $this->getTemplatesFor('login') + ); } /** * Delegate to an number of handlers, extracting their forms and rendering a tabbed form-set. * This is used to built the log-in page where there are multiple authenticators active. * + * If a single handler is passed, delegateToHandler() will be called instead + * * @param string $title The title of the form * @param array $templates * @return array|HTTPResponse|RequestHandler|\SilverStripe\ORM\FieldType\DBHTMLText|string */ - protected function delegateToFormSet(array $handlers, $title, array $templates) + protected function delegateToMultipleHandlers(array $handlers, $title, array $templates) { + // Simpler case for a single authenticator + if (count($handlers) === 1) { + return $this->delegateToHandler(array_values($handlers)[0], $title, $templates); + } + // Process each of the handlers $results = array_map( function ($handler) { @@ -778,9 +759,10 @@ class Security extends Controller implements TemplateGlobalProvider /** * Render the given fragments into a security page controller with the given title. - * @param $title string The title to give the security page - * @param $fragments A map of objects to render into the page, e.g. "Form" - * @param $templates An array of templates to use for the render + * @param string $title string The title to give the security page + * @param array $fragments A map of objects to render into the page, e.g. "Form" + * @param array $templates An array of templates to use for the render + * @return HTTPResponse|\SilverStripe\ORM\FieldType\DBHTMLText */ protected function renderWrappedController($title, array $fragments, array $templates) { @@ -804,7 +786,7 @@ class Security extends Controller implements TemplateGlobalProvider 'Message' => DBField::create_field('HTMLFragment', $message), 'MessageType' => $messageType ]; - $result = array_merge($fragments, $messageResult); + $fragments = array_merge($fragments, $messageResult); } return $controller->customise($fragments)->renderWith($templates); @@ -823,7 +805,7 @@ class Security extends Controller implements TemplateGlobalProvider */ public function lostpassword() { - $handler = $this->getAuthenticator()->getLostPasswordHandler( + $handler = $this->getAuthenticator('default')->getLostPasswordHandler( Controller::join_links($this->link(), 'lostpassword') ); @@ -834,26 +816,6 @@ class Security extends Controller implements TemplateGlobalProvider ); } - /** - * Create a link to the password reset form. - * - * GET parameters used: - * - m: member ID - * - t: plaintext token - * - * @param Member $member Member object associated with this link. - * @param string $autologinToken The auto login token. - * @return string - */ - public static function getPasswordResetLink($member, $autologinToken) - { - $autologinToken = urldecode($autologinToken); - $selfControllerClass = __CLASS__; - /** @var static $selfController */ - $selfController = new $selfControllerClass(); - return $selfController->Link('changepassword') . "?m={$member->ID}&t=$autologinToken"; - } - /** * Show the "change password" page. * This page can either be called directly by logged-in users @@ -941,6 +903,26 @@ class Security extends Controller implements TemplateGlobalProvider return $customisedController->renderWith($this->getTemplatesFor('changepassword')); } + /** + * Create a link to the password reset form. + * + * GET parameters used: + * - m: member ID + * - t: plaintext token + * + * @param Member $member Member object associated with this link. + * @param string $autologinToken The auto login token. + * @return string + */ + public static function getPasswordResetLink($member, $autologinToken) + { + $autologinToken = urldecode($autologinToken); + $selfControllerClass = __CLASS__; + /** @var static $selfController */ + $selfController = new $selfControllerClass(); + return $selfController->Link('changepassword') . "?m={$member->ID}&t=$autologinToken"; + } + /** * Factory method for the lost password form * @@ -1219,6 +1201,46 @@ class Security extends Controller implements TemplateGlobalProvider return true; } + /** + * Resets the database_is_ready cache + */ + public static function clear_database_is_ready() + { + self::$database_is_ready = null; + self::$force_database_is_ready = null; + } + + /** + * For the database_is_ready call to return a certain value - used for testing + */ + public static function force_database_is_ready($isReady) + { + self::$force_database_is_ready = $isReady; + } + + /** + * Enable or disable recording of login attempts + * through the {@link LoginRecord} object. + * + * @deprecated 4.0 Use the "Security.login_recording" config setting instead + * @param boolean $bool + */ + public static function set_login_recording($bool) + { + Deprecation::notice('4.0', 'Use the "Security.login_recording" config setting instead'); + self::$login_recording = (bool)$bool; + } + + /** + * @deprecated 4.0 Use the "Security.login_recording" config setting instead + * @return boolean + */ + public static function login_recording() + { + Deprecation::notice('4.0', 'Use the "Security.login_recording" config setting instead'); + return self::$login_recording; + } + /** * @config * @var string Set the default login dest diff --git a/tests/php/Security/BasicAuthTest.php b/tests/php/Security/BasicAuthTest.php index 4d2f37c33..5fca445c1 100644 --- a/tests/php/Security/BasicAuthTest.php +++ b/tests/php/Security/BasicAuthTest.php @@ -30,7 +30,7 @@ class BasicAuthTest extends FunctionalTest // Fixtures assume Email is the field used to identify the log in identity Member::config()->unique_identifier_field = 'Email'; - Security::$force_database_is_ready = true; // Prevents Member test subclasses breaking ready test + Security::force_database_is_ready(true); // Prevents Member test subclasses breaking ready test Member::config()->lock_out_after_incorrect_logins = 10; } diff --git a/tests/php/Security/MemberAuthenticatorTest.php b/tests/php/Security/MemberAuthenticatorTest.php index c00733b0f..af9bbba1d 100644 --- a/tests/php/Security/MemberAuthenticatorTest.php +++ b/tests/php/Security/MemberAuthenticatorTest.php @@ -9,13 +9,14 @@ use SilverStripe\Security\PasswordEncryptor; use SilverStripe\Security\PasswordEncryptor_PHPHash; use SilverStripe\Security\Security; use SilverStripe\Security\Member; -use SilverStripe\Security\MemberAuthenticator; -use SilverStripe\Security\MemberLoginForm; +use SilverStripe\Security\MemberAuthenticator\Authenticator; +use SilverStripe\Security\MemberAuthenticator\LoginForm; use SilverStripe\Security\CMSMemberLoginForm; use SilverStripe\Core\Config\Config; use SilverStripe\Dev\SapphireTest; use SilverStripe\Forms\FieldList; use SilverStripe\Forms\Form; +use SilverStripe\Control\HTTPRequest; class MemberAuthenticatorTest extends SapphireTest { @@ -41,59 +42,6 @@ class MemberAuthenticatorTest extends SapphireTest parent::tearDown(); } - public function testLegacyPasswordHashMigrationUponLogin() - { - $member = new Member(); - - $field=Member::config()->unique_identifier_field; - - $member->$field = 'test1@test.com'; - $member->PasswordEncryption = "sha1"; - $member->Password = "mypassword"; - $member->write(); - - $data = array( - 'Email' => $member->$field, - 'Password' => 'mypassword' - ); - MemberAuthenticator::authenticate($data); - - /** - * @var Member $member -*/ - $member = DataObject::get_by_id(Member::class, $member->ID); - $this->assertEquals($member->PasswordEncryption, "sha1_v2.4"); - $result = $member->checkPassword('mypassword'); - $this->assertTrue($result->isValid()); - } - - public function testNoLegacyPasswordHashMigrationOnIncompatibleAlgorithm() - { - Config::inst()->update( - PasswordEncryptor::class, - 'encryptors', - array('crc32' => array(PasswordEncryptor_PHPHash::class => 'crc32')) - ); - $field=Member::config()->unique_identifier_field; - - $member = new Member(); - $member->$field = 'test2@test.com'; - $member->PasswordEncryption = "crc32"; - $member->Password = "mypassword"; - $member->write(); - - $data = array( - 'Email' => $member->$field, - 'Password' => 'mypassword' - ); - MemberAuthenticator::authenticate($data); - - $member = DataObject::get_by_id(Member::class, $member->ID); - $this->assertEquals($member->PasswordEncryption, "crc32"); - $result = $member->checkPassword('mypassword'); - $this->assertTrue($result->isValid()); - } - public function testCustomIdentifierField() { @@ -109,36 +57,46 @@ class MemberAuthenticatorTest extends SapphireTest public function testGenerateLoginForm() { + $authenticator = new Authenticator(); + $controller = new Security(); // Create basic login form - $frontendForm = MemberAuthenticator::get_login_form($controller); - $this->assertTrue($frontendForm instanceof MemberLoginForm); + $frontendResponse = $authenticator + ->getLoginHandler($controller->link()) + ->handleRequest(new HTTPRequest('get', '/'), \SilverStripe\ORM\DataModel::inst()); + + $this->assertTrue(is_array($frontendResponse)); + $this->assertTrue(isset($frontendResponse['Form'])); + $this->assertTrue($frontendResponse['Form'] instanceof LoginForm); + } + + /* TO DO - reenable + public function testGenerateCMSLoginForm() + { + $authenticator = new Authenticator(); // Supports cms login form $this->assertTrue(MemberAuthenticator::supports_cms()); $cmsForm = MemberAuthenticator::get_cms_login_form($controller); $this->assertTrue($cmsForm instanceof CMSMemberLoginForm); } + */ + /** * Test that a member can be authenticated via their temp id */ public function testAuthenticateByTempID() { + $authenticator = new Authenticator(); + $member = new Member(); $member->Email = 'test1@test.com'; $member->PasswordEncryption = "sha1"; $member->Password = "mypassword"; $member->write(); - // Make form - $controller = new Security(); - /** - * @skipUpgrade -*/ - $form = new Form($controller, 'Form', new FieldList(), new FieldList()); - // If the user has never logged in, then the tempid should be empty $tempID = $member->TempIDHash; $this->assertEmpty($tempID); @@ -149,35 +107,32 @@ class MemberAuthenticatorTest extends SapphireTest $this->assertNotEmpty($tempID); // Test correct login - $result = MemberAuthenticator::authenticate( + $result = $authenticator->authenticate( array( 'tempid' => $tempID, 'Password' => 'mypassword' ), - $form + $message ); - $form->restoreFormState(); + $this->assertNotEmpty($result); $this->assertEquals($result->ID, $member->ID); - $this->assertEmpty($form->getMessage()); + $this->assertEmpty($message); // Test incorrect login - $form->clearMessage(); - $result = MemberAuthenticator::authenticate( + $result = $authenticator->authenticate( array( 'tempid' => $tempID, 'Password' => 'notmypassword' ), - $form + $message ); - $form->restoreFormState(); + $this->assertEmpty($result); $this->assertEquals( _t('SilverStripe\\Security\\Member.ERRORWRONGCRED', 'The provided details don\'t seem to be correct. Please try again.'), - $form->getMessage() + $message ); - $this->assertEquals(ValidationResult::TYPE_ERROR, $form->getMessageType()); - $this->assertEquals(ValidationResult::CAST_TEXT, $form->getMessageCast()); } /** @@ -185,61 +140,50 @@ class MemberAuthenticatorTest extends SapphireTest */ public function testDefaultAdmin() { - // Make form - $controller = new Security(); - /** - * @skipUpgrade -*/ - $form = new Form($controller, 'Form', new FieldList(), new FieldList()); + $authenticator = new Authenticator(); // Test correct login - $result = MemberAuthenticator::authenticate( + $result = $authenticator->authenticate( array( 'Email' => 'admin', 'Password' => 'password' ), - $form + $message ); - $form->restoreFormState(); $this->assertNotEmpty($result); $this->assertEquals($result->Email, Security::default_admin_username()); - $this->assertEmpty($form->getMessage()); + $this->assertEmpty($message); // Test incorrect login - $form->clearMessage(); - $result = MemberAuthenticator::authenticate( + $result = $authenticator->authenticate( array( 'Email' => 'admin', 'Password' => 'notmypassword' ), - $form + $message ); - $form->restoreFormState(); $this->assertEmpty($result); $this->assertEquals( 'The provided details don\'t seem to be correct. Please try again.', - $form->getMessage() + $message ); - $this->assertEquals(ValidationResult::TYPE_ERROR, $form->getMessageType()); - $this->assertEquals(ValidationResult::CAST_TEXT, $form->getMessageCast()); } public function testDefaultAdminLockOut() { + $authenticator = new Authenticator(); + Config::inst()->update(Member::class, 'lock_out_after_incorrect_logins', 1); Config::inst()->update(Member::class, 'lock_out_delay_mins', 10); DBDatetime::set_mock_now('2016-04-18 00:00:00'); - $controller = new Security(); - /** @skipUpgrade */ - $form = new Form($controller, 'Form', new FieldList(), new FieldList()); // Test correct login - MemberAuthenticator::authenticate( + $authenticator->authenticate( [ 'Email' => 'admin', 'Password' => 'wrongpassword' ], - $form + $dummy ); $this->assertTrue(Member::default_admin()->isLockedOut()); diff --git a/tests/php/Security/MemberTest.php b/tests/php/Security/MemberTest.php index e03f77b6a..4b33939cf 100644 --- a/tests/php/Security/MemberTest.php +++ b/tests/php/Security/MemberTest.php @@ -237,13 +237,13 @@ class MemberTest extends FunctionalTest $this->assertNotNull($member); // Initiate a password-reset - $response = $this->post('Security/LostPasswordForm', array('Email' => $member->Email)); + $response = $this->post('Security/lostpassword/LostPasswordForm', array('Email' => $member->Email)); $this->assertEquals($response->getStatusCode(), 302); // We should get redirected to Security/passwordsent $this->assertContains( - 'Security/passwordsent/testuser@example.com', + 'Security/lostpassword/passwordsent/testuser@example.com', urldecode($response->getHeader('Location')) ); @@ -942,12 +942,11 @@ class MemberTest extends FunctionalTest // Re-logging (ie 'alc_enc' has expired), and not checking the "Remember Me" option // should remove all previous hashes for this device $response = $this->post( - 'Security/LoginForm', + 'Security/login/default/LoginForm', array( 'Email' => $m1->Email, 'Password' => '1nitialPassword', - 'AuthenticationMethod' => MemberAuthenticator::class, - 'action_dologin' => 'action_dologin' + 'action_doLogin' => 'action_doLogin' ), null, $this->session(), diff --git a/tests/php/Security/SecurityTest.php b/tests/php/Security/SecurityTest.php index c7ebb8abe..019d6160f 100644 --- a/tests/php/Security/SecurityTest.php +++ b/tests/php/Security/SecurityTest.php @@ -187,14 +187,14 @@ class SecurityTest extends FunctionalTest } $response = $this->getRecursive('SecurityTest_SecuredController'); $this->assertContains(Convert::raw2xml("That page is secured."), $response->getBody()); - $this->assertContains('getBody()); + $this->assertContains('getBody()); // Non-logged in user should not be redirected, but instead shown the login form // No message/context is available as the user has not attempted to view the secured controller $response = $this->getRecursive('Security/login?BackURL=SecurityTest_SecuredController/'); $this->assertNotContains(Convert::raw2xml("That page is secured."), $response->getBody()); $this->assertNotContains(Convert::raw2xml("You don't have access to this page"), $response->getBody()); - $this->assertContains('getBody()); + $this->assertContains('getBody()); // BackURL with permission error (wrong permissions) should not redirect $this->logInAs('grouplessmember'); @@ -233,7 +233,7 @@ class SecurityTest extends FunctionalTest /* View the Security/login page */ $response = $this->get(Config::inst()->get(Security::class, 'login_url')); - $items = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm input.action'); + $items = $this->cssParser()->getBySelector('#LoginForm_LoginForm input.action'); /* We have only 1 input, one to allow the user to log in as someone else */ $this->assertEquals(count($items), 1, 'There is 1 input, allowing the user to log in as someone else.'); @@ -242,11 +242,10 @@ class SecurityTest extends FunctionalTest /* Submit the form, using only the logout action and a hidden field for the authenticator */ $response = $this->submitForm( - 'MemberLoginForm_LoginForm', + 'LoginForm_LoginForm', null, array( - 'AuthenticationMethod' => MemberAuthenticator::class, - 'action_dologout' => 1, + 'action_logout' => 1, ) ); @@ -268,7 +267,7 @@ class SecurityTest extends FunctionalTest /* Attempt to get into the admin section */ $response = $this->get(Config::inst()->get(Security::class, 'login_url')); - $items = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm input.text'); + $items = $this->cssParser()->getBySelector('#LoginForm_LoginForm input.text'); /* We have 2 text inputs - one for email, and another for the password */ $this->assertEquals(count($items), 2, 'There are 2 inputs - one for email, another for password'); @@ -287,11 +286,11 @@ class SecurityTest extends FunctionalTest $this->get(Config::inst()->get(Security::class, 'login_url')); $items = $this ->cssParser() - ->getBySelector('#MemberLoginForm_LoginForm #MemberLoginForm_LoginForm_Email'); + ->getBySelector('#LoginForm_LoginForm #LoginForm_LoginForm_Email'); $this->assertEquals(1, count($items)); $this->assertEmpty((string)$items[0]->attributes()->value); $this->assertEquals('off', (string)$items[0]->attributes()->autocomplete); - $form = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm'); + $form = $this->cssParser()->getBySelector('#LoginForm_LoginForm'); $this->assertEquals(1, count($form)); $this->assertEquals('off', (string)$form[0]->attributes()->autocomplete); @@ -301,11 +300,11 @@ class SecurityTest extends FunctionalTest $this->get(Config::inst()->get(Security::class, 'login_url')); $items = $this ->cssParser() - ->getBySelector('#MemberLoginForm_LoginForm #MemberLoginForm_LoginForm_Email'); + ->getBySelector('#LoginForm_LoginForm #LoginForm_LoginForm_Email'); $this->assertEquals(1, count($items)); $this->assertEquals('myuser@silverstripe.com', (string)$items[0]->attributes()->value); $this->assertNotEquals('off', (string)$items[0]->attributes()->autocomplete); - $form = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm'); + $form = $this->cssParser()->getBySelector('#LoginForm_LoginForm'); $this->assertEquals(1, count($form)); $this->assertNotEquals('off', (string)$form[0]->attributes()->autocomplete); } @@ -436,7 +435,7 @@ class SecurityTest extends FunctionalTest // Request new password by email $response = $this->get('Security/lostpassword'); - $response = $this->post('Security/LostPasswordForm', array('Email' => 'testuser@example.com')); + $response = $this->post('Security/lostpassword/LostPasswordForm', array('Email' => 'testuser@example.com')); $this->assertEmailSent('testuser@example.com'); @@ -648,9 +647,7 @@ class SecurityTest extends FunctionalTest public function testDatabaseIsReadyWithInsufficientMemberColumns() { - $old = Security::$force_database_is_ready; - Security::$force_database_is_ready = null; - Security::$database_is_ready = false; + Security::clear_database_is_ready(); DBClassName::clear_classname_cache(); // Assumption: The database has been built correctly by the test runner, @@ -666,8 +663,6 @@ class SecurityTest extends FunctionalTest // Rebuild the database (which re-adds the Email column), and try again static::resetDBSchema(true); $this->assertTrue(Security::database_is_ready()); - - Security::$force_database_is_ready = $old; } public function testSecurityControllerSendsRobotsTagHeader() @@ -697,13 +692,13 @@ class SecurityTest extends FunctionalTest $this->get(Config::inst()->get(Security::class, 'login_url')); return $this->submitForm( - "MemberLoginForm_LoginForm", + "LoginForm_LoginForm", null, array( 'Email' => $email, 'Password' => $password, 'AuthenticationMethod' => MemberAuthenticator::class, - 'action_dologin' => 1, + 'action_doLogin' => 1, ) ); } @@ -751,7 +746,7 @@ class SecurityTest extends FunctionalTest */ protected function getValidationResult() { - $result = $this->session()->inst_get('FormInfo.MemberLoginForm_LoginForm.result'); + $result = $this->session()->inst_get('FormInfo.LoginForm_LoginForm.result'); if ($result) { return unserialize($result); } From c4194f0ed27a8e6b6b60796beed83e711c670898 Mon Sep 17 00:00:00 2001 From: Simon Erkelens Date: Sun, 30 Apr 2017 15:17:26 +1200 Subject: [PATCH 3/9] CMS Login Handling Move to canLogin in the authentication check. Protected isLockedOut Enable login to be called with a different login service (CMSLogin), enabling CMS Log in. Seems the styling and/or output is still broken. logOut could be managed from the Authenticator instead of the member --- _config/security.yml | 4 + src/Security/Authenticator.php | 18 ++-- src/Security/BasicAuth.php | 9 +- src/Security/CMSMemberLoginForm.php | 42 ++++---- src/Security/CMSSecurity.php | 60 ++++++------ src/Security/Member.php | 54 ++--------- .../MemberAuthenticator/Authenticator.php | 78 +++++++++------ .../MemberAuthenticator/CMSAuthenticator.php | 41 ++++++++ .../MemberAuthenticator/CMSLoginHandler.php | 33 ++++--- .../MemberAuthenticator/LoginForm.php | 3 +- .../MemberAuthenticator/LoginHandler.php | 5 +- src/Security/Security.php | 97 ++++++++++++------- .../php/Security/MemberAuthenticatorTest.php | 5 +- tests/php/Security/MemberTest.php | 4 +- 14 files changed, 258 insertions(+), 195 deletions(-) create mode 100644 src/Security/MemberAuthenticator/CMSAuthenticator.php diff --git a/_config/security.yml b/_config/security.yml index 02f960fa0..039e089b1 100644 --- a/_config/security.yml +++ b/_config/security.yml @@ -6,3 +6,7 @@ SilverStripe\Security\MemberAuthenticator\LoginForm: SilverStripe\Security\Security: authenticators: default: SilverStripe\Security\MemberAuthenticator\Authenticator + cms: SilverStripe\Security\MemberAuthenticator\CMSAuthenticator + +SilverStripe\Security\MemberAuthenticator\CMSSecurity: + reauth_enabled: true \ No newline at end of file diff --git a/src/Security/Authenticator.php b/src/Security/Authenticator.php index beee5158c..884893624 100644 --- a/src/Security/Authenticator.php +++ b/src/Security/Authenticator.php @@ -45,15 +45,10 @@ interface Authenticator * URL-handling methods may return an array [ "Form" => (form-object) ] which can then * be merged into a default controller. * - * @param $link The base link to use for this RequestHnadler + * @param string $link The base link to use for this RequestHnadler */ public function getLoginHandler($link); - /** - * @todo - */ - public function getCMSLoginHandler($link); - /** * Return RequestHandler to manage the change-password process. * @@ -63,7 +58,7 @@ interface Authenticator * URL-handling methods may return an array [ "Form" => (form-object) ] which can then * be merged into a default controller. * - * @param $link The base link to use for this RequestHnadler + * @param string $link The base link to use for this RequestHnadler */ public function getChangePasswordHandler($link); @@ -86,4 +81,13 @@ interface Authenticator * @return array */ // public function getAuthenticateFields(); + + /** + * Log the member out of this Authentication method. + * + * @param Member $member by reference, to allow for multiple actions on the member with a single write + * @return boolean|Member if logout was unsuccessfull, return true, otherwise, the member is returned + */ + public function doLogOut(&$member); + } diff --git a/src/Security/BasicAuth.php b/src/Security/BasicAuth.php index cf4c5d8b3..82fbab687 100644 --- a/src/Security/BasicAuth.php +++ b/src/Security/BasicAuth.php @@ -85,6 +85,7 @@ class BasicAuth $member = null; if (isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) { + /** @var Authenticator $authenticator */ $authenticator = Injector::inst()->get(Authenticator::class); $member = $authenticator->authenticate([ @@ -151,9 +152,9 @@ class BasicAuth */ public static function protect_entire_site($protect = true, $code = 'ADMIN', $message = null) { - Config::inst()->update('SilverStripe\\Security\\BasicAuth', 'entire_site_protected', $protect); - Config::inst()->update('SilverStripe\\Security\\BasicAuth', 'entire_site_protected_code', $code); - Config::inst()->update('SilverStripe\\Security\\BasicAuth', 'entire_site_protected_message', $message); + Config::inst()->update(self::class, 'entire_site_protected', $protect); + Config::inst()->update(self::class, 'entire_site_protected_code', $code); + Config::inst()->update(self::class, 'entire_site_protected_message', $message); } /** @@ -165,7 +166,7 @@ class BasicAuth */ public static function protect_site_if_necessary() { - $config = Config::forClass('SilverStripe\\Security\\BasicAuth'); + $config = Config::forClass(BasicAuth::class); if ($config->entire_site_protected) { self::requireLogin($config->entire_site_protected_message, $config->entire_site_protected_code, false); } diff --git a/src/Security/CMSMemberLoginForm.php b/src/Security/CMSMemberLoginForm.php index 4f1c602a1..e71865b50 100644 --- a/src/Security/CMSMemberLoginForm.php +++ b/src/Security/CMSMemberLoginForm.php @@ -1,38 +1,30 @@ Link($action); - } /** * CMSMemberLoginForm constructor. - * @param Controller $controller + * @param RequestHandler $controller * @param string $authenticatorClass * @param FieldList $name */ - public function __construct(Controller $controller, $authenticatorClass, $name) + public function __construct(RequestHandler $controller, $authenticatorClass, $name) { $this->controller = $controller; @@ -42,7 +34,7 @@ class CMSMemberLoginForm extends LoginForm $actions = $this->getFormActions(); - parent::__construct($controller, $name, $fields, $actions); + parent::__construct($controller, $authenticatorClass, $name, $fields, $actions); } /** @@ -51,7 +43,7 @@ class CMSMemberLoginForm extends LoginForm public function getFormFields() { // Set default fields - $fields = new FieldList( + $fields = FieldList::create([ HiddenField::create("AuthenticationMethod", null, $this->authenticator_class, $this), HiddenField::create('tempid', null, $this->controller->getRequest()->requestVar('tempid')), PasswordField::create("Password", _t('SilverStripe\\Security\\Member.PASSWORD', 'Password')), @@ -63,9 +55,9 @@ class CMSMemberLoginForm extends LoginForm _t('SilverStripe\\Security\\CMSMemberLoginForm.BUTTONFORGOTPASSWORD', "Forgot password?") ) ) - ); + ]); - if (Security::config()->autologin_enabled) { + if (Security::config()->get('autologin_enabled')) { $fields->push(CheckboxField::create( "Remember", _t('SilverStripe\\Security\\Member.REMEMBERME', "Remember me next time?") @@ -88,8 +80,8 @@ class CMSMemberLoginForm extends LoginForm } // Make actions - $actions = new FieldList( - FormAction::create('dologin', _t('SilverStripe\\Security\\CMSMemberLoginForm.BUTTONLOGIN', "Log back in")), + $actions = FieldList::create([ + FormAction::create('doLogin', _t('SilverStripe\\Security\\CMSMemberLoginForm.BUTTONLOGIN', "Log back in")), LiteralField::create( 'doLogout', sprintf( @@ -98,14 +90,20 @@ class CMSMemberLoginForm extends LoginForm _t('SilverStripe\\Security\\CMSMemberLoginForm.BUTTONLOGOUT', "Log out") ) ) - ); + ]); return $actions; } - protected function buildRequestHandler() + /** + * Get link to use for external security actions + * + * @param string $action Action + * @return string + */ + public function getExternalLink($action = null) { - return CMSMemberLoginHandler::create($this); + return Security::singleton()->Link($action); } /** diff --git a/src/Security/CMSSecurity.php b/src/Security/CMSSecurity.php index 38d9cd5f5..1a89f1c9e 100644 --- a/src/Security/CMSSecurity.php +++ b/src/Security/CMSSecurity.php @@ -4,11 +4,13 @@ namespace SilverStripe\Security; use SilverStripe\Admin\AdminRootController; use SilverStripe\Control\HTTPResponse; +use SilverStripe\Control\Session; use SilverStripe\Core\Convert; use SilverStripe\Control\Director; use SilverStripe\Control\Controller; -use SilverStripe\Control\Session; +use SilverStripe\Core\Injector\Injector; use SilverStripe\ORM\FieldType\DBField; +use SilverStripe\Security\MemberAuthenticator\CMSAuthenticator; use SilverStripe\View\Requirements; /** @@ -22,6 +24,7 @@ class CMSSecurity extends Security ); private static $allowed_actions = array( + 'login', 'LoginForm', 'success' ); @@ -41,12 +44,27 @@ class CMSSecurity extends Security Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/client/dist/js/vendor.js'); } + public function login($request, $service = Authenticator::CMS_LOGIN) + { + return parent::login($request, Authenticator::CMS_LOGIN); + } + public function Link($action = null) { /** @skipUpgrade */ return Controller::join_links(Director::baseURL(), "CMSSecurity", $action); } + protected function getAuthenticator($name = 'cms') + { + return parent::getAuthenticator($name); + } + + public static function getAuthenticators($service = Authenticator::CMS_LOGIN) + { + return parent::getAuthenticators($service); + } + /** * Get known logged out member * @@ -57,6 +75,7 @@ class CMSSecurity extends Security if ($tempid = $this->getRequest()->requestVar('tempid')) { return Member::member_from_tempid($tempid); } + return null; } @@ -129,6 +148,7 @@ setTimeout(function(){top.location.href = "$loginURLJS";}, 0); PHP ); $this->setResponse($response); + return $response; } @@ -142,19 +162,6 @@ PHP return parent::preLogin(); } - public function GetLoginForms() - { - $forms = array(); - $authenticators = Authenticator::get_authenticators(); - foreach ($authenticators as $authenticator) { - // Get only CMS-supporting authenticators - if ($authenticator::supports_cms()) { - $forms[] = $authenticator::get_cms_login_form($this); - } - } - return $forms; - } - /** * Determine if CMSSecurity is enabled * @@ -163,28 +170,23 @@ PHP public static function enabled() { // Disable shortcut - if (!static::config()->reauth_enabled) { + if (!static::config()->get('reauth_enabled')) { return false; } - // Count all cms-supported methods - $authenticators = Authenticator::get_authenticators(); - foreach ($authenticators as $authenticator) { + /** @var [] $authenticators */ + $authenticators = Security::config()->get('authenticators'); + foreach ($authenticators as $name => $authenticator) { // Supported if at least one authenticator is supported - if ($authenticator::supports_cms()) { + $authenticator = Injector::inst()->get($authenticator); + if (($authenticator->supportedServices() & Authenticator::CMS_LOGIN) + && Security::hasAuthenticator($name) + ) { return true; } } - return false; - } - public function LoginForm() - { - $authenticator = $this->getAuthenticator('default'); - if ($authenticator && $authenticator::supports_cms()) { - return $authenticator::get_cms_login_form($this); - } - user_error('Passed invalid authentication method', E_USER_ERROR); + return false; } /** @@ -217,7 +219,7 @@ PHP $controller = $controller->customise(array( 'Content' => _t( 'SilverStripe\\Security\\CMSSecurity.SUCCESSCONTENT', - '

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/Member.php b/src/Security/Member.php index 6dba32a06..fa26fdabe 100644 --- a/src/Security/Member.php +++ b/src/Security/Member.php @@ -368,7 +368,7 @@ class Member extends DataObject implements TemplateGlobalProvider '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')) ) ); } @@ -382,7 +382,7 @@ class Member extends DataObject implements TemplateGlobalProvider * * @return bool */ - public function isLockedOut() + protected function isLockedOut() { if (!$this->LockedOutUntil) { return false; @@ -499,7 +499,7 @@ class Member extends DataObject implements TemplateGlobalProvider $this->write(); // Audit logging hook - $this->extend('memberLoggedIn'); + $this->extend('afterMemberLoggedIn'); } /** @@ -626,40 +626,6 @@ class Member extends DataObject implements TemplateGlobalProvider } } - /** - * Logs this member out. - */ - public function logOut() - { - $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(); - - // Audit logging hook - $this->extend('memberLoggedOut'); - } - /** * Utility for generating secure password hashes for this member. * @@ -762,7 +728,7 @@ class Member extends DataObject implements TemplateGlobalProvider ->filter('TempIDHash', $tempid); // Exclude expired - if (static::config()->temp_id_lifetime) { + if (static::config()->get('temp_id_lifetime')) { $members = $members->filter('TempIDExpired:GreaterThan', DBDatetime::now()->getValue()); } @@ -788,7 +754,7 @@ 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'); @@ -988,7 +954,7 @@ class Member extends DataObject implements TemplateGlobalProvider 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') @@ -1219,7 +1185,7 @@ 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) { @@ -1254,7 +1220,7 @@ class Member extends DataObject implements TemplateGlobalProvider $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'], @@ -1542,9 +1508,9 @@ class Member extends DataObject implements TemplateGlobalProvider _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'); } diff --git a/src/Security/MemberAuthenticator/Authenticator.php b/src/Security/MemberAuthenticator/Authenticator.php index c86cabe04..4e9c836e1 100644 --- a/src/Security/MemberAuthenticator/Authenticator.php +++ b/src/Security/MemberAuthenticator/Authenticator.php @@ -3,11 +3,12 @@ namespace SilverStripe\Security\MemberAuthenticator; use SilverStripe\Control\Controller; +use SilverStripe\Control\Cookie; use SilverStripe\Control\Session; -use SilverStripe\Forms\Form; use SilverStripe\ORM\ValidationResult; use InvalidArgumentException; use SilverStripe\Security\Authenticator as BaseAuthenticator; +use SilverStripe\Security\RememberLoginHash; use SilverStripe\Security\Security; use SilverStripe\Security\Member; use SilverStripe\Security\LoginAttempt; @@ -24,7 +25,7 @@ class Authenticator implements BaseAuthenticator { // Bitwise-OR of all the supported services, to make a bitmask return BaseAuthenticator::LOGIN | BaseAuthenticator::LOGOUT | BaseAuthenticator::CHANGE_PASSWORD - | BaseAuthenticator::RESET_PASSWORD | BaseAuthenticator::CMS_LOGIN; + | BaseAuthenticator::RESET_PASSWORD; } /** @@ -50,39 +51,24 @@ class Authenticator implements BaseAuthenticator /** * Attempt to find and authenticate member if possible from the given data * - * @param array $data - * @param Form $form + * @param array $data Form submitted data + * @param $message * @param bool &$success Success flag + * @param null|Member $member If the parent method already identified the member, it can be passed in * @return Member Found member, regardless of successful login */ - protected function authenticateMember($data, &$message, &$success) + protected function authenticateMember($data, &$message, &$success, $member = null) { // 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']; - } - + $email = !empty($data['Email']) ? $data['Email'] : null ; + // 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']); + $success = $member->canLogin()->isValid() && Security::check_default_admin($email, $data['Password']); //protect against failed login if ($success) { return $member; @@ -92,8 +78,9 @@ class Authenticator implements BaseAuthenticator // Attempt to identify user by email if (!$member && $email) { // Find user by email + /** @var Member $member */ $member = Member::get() - ->filter(Member::config()->unique_identifier_field, $email) + ->filter([Member::config()->get('unique_identifier_field') => $email]) ->first(); } @@ -120,7 +107,7 @@ class Authenticator implements BaseAuthenticator $result->getMessages() )); } else { - if ($member) { + if ($member) { // How can success be true and member false? $member->registerSuccessfulLogin(); } } @@ -137,7 +124,7 @@ class Authenticator implements BaseAuthenticator */ protected function recordLoginAttempt($data, $member, $success) { - if (!Security::config()->login_recording) { + if (!Security::config()->get('login_recording')) { return; } @@ -148,7 +135,7 @@ class Authenticator implements BaseAuthenticator throw new InvalidArgumentException("Bad email passed to MemberAuthenticator::authenticate(): $email"); } - $attempt = new LoginAttempt(); + $attempt = LoginAttempt::create(); if ($success) { // successful login (member is existing with matching password) $attempt->MemberID = $member->ID; @@ -198,8 +185,39 @@ class Authenticator implements BaseAuthenticator return LoginHandler::create($link, $this); } - public function getCMSLoginHandler($link) + /** + * + * @param Member $member + * @return bool|Member + */ + public function doLogOut(&$member) { - return CMSMemberLoginHandler::create($controller, self::class, "LoginForm"); + if($member instanceof Member) { + Session::clear("loggedInAs"); + if (Member::config()->login_marker_cookie) { + Cookie::set(Member::config()->login_marker_cookie, null, 0); + } + + Session::destroy(); + + // Clears any potential previous hashes for this member + RememberLoginHash::clear($member, 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'); + + // Log out unsuccessful. Useful for 3rd-party logins that return failure. Shouldn't happen + // on the default authenticator though. + if(Member::currentUserID()) { + return Member::currentUser(); + } + } + return true; } } diff --git a/src/Security/MemberAuthenticator/CMSAuthenticator.php b/src/Security/MemberAuthenticator/CMSAuthenticator.php new file mode 100644 index 000000000..43946398f --- /dev/null +++ b/src/Security/MemberAuthenticator/CMSAuthenticator.php @@ -0,0 +1,41 @@ +Email; + } + } + + return parent::authenticateMember($data, $message, $success, $member); + } + + public function getLoginHandler($link) + { + return CMSLoginHandler::create($link, $this); + } + +} \ No newline at end of file diff --git a/src/Security/MemberAuthenticator/CMSLoginHandler.php b/src/Security/MemberAuthenticator/CMSLoginHandler.php index ca469e23e..7cf751d52 100644 --- a/src/Security/MemberAuthenticator/CMSLoginHandler.php +++ b/src/Security/MemberAuthenticator/CMSLoginHandler.php @@ -4,24 +4,26 @@ namespace SilverStripe\Security\MemberAuthenticator; use SilverStripe\Control\HTTPResponse; use SilverStripe\Core\Convert; +use SilverStripe\Security\CMSSecurity; +use SilverStripe\Security\Member; +use SilverStripe\Security\Security; class CMSLoginHandler extends LoginHandler { - /** - * Login form handler method - * - * This method is called when the user clicks on "Log in" - * - * @param array $data Submitted data - * @return HTTPResponse - */ - public function dologin($data, $formHandler) - { - if ($this->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,10 +77,9 @@ PHP /** * Send user to the right location after login * - * @param array $data * @return HTTPResponse */ - protected function logInUserAndRedirect($data, $formHandler) + protected function redirectAfterSuccessfulLogin() { // Check password expiry if (Member::currentUser()->isPasswordExpired()) { diff --git a/src/Security/MemberAuthenticator/LoginForm.php b/src/Security/MemberAuthenticator/LoginForm.php index ff1a69f77..e59bc9d98 100644 --- a/src/Security/MemberAuthenticator/LoginForm.php +++ b/src/Security/MemberAuthenticator/LoginForm.php @@ -3,6 +3,7 @@ namespace SilverStripe\Security\MemberAuthenticator; use SilverStripe\Control\Director; +use SilverStripe\Control\RequestHandler; use SilverStripe\Control\Session; use SilverStripe\Control\Controller; use SilverStripe\Forms\HiddenField; @@ -49,7 +50,7 @@ class LoginForm extends BaseLoginForm * Constructor * * @skipUpgrade - * @param Controller $controller The parent controller, necessary to + * @param RequestHandler $controller The parent controller, necessary to * create the appropriate form action tag. * @param string $authenticatorClass Authenticator for this LoginForm * @param string $name The method on the controller that will return this diff --git a/src/Security/MemberAuthenticator/LoginHandler.php b/src/Security/MemberAuthenticator/LoginHandler.php index 59e55e43c..c09b61fd6 100644 --- a/src/Security/MemberAuthenticator/LoginHandler.php +++ b/src/Security/MemberAuthenticator/LoginHandler.php @@ -7,7 +7,6 @@ use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\Session; use SilverStripe\Control\RequestHandler; use SilverStripe\ORM\ValidationResult; -use SilverStripe\Security\MemberAuthenticator\Authenticator; use SilverStripe\Security\Security; use SilverStripe\Security\Member; @@ -91,7 +90,7 @@ class LoginHandler extends RequestHandler * This method is called when the user clicks on "Log in" * * @param array $data Submitted data - * @param LoginHandler $formHandler + * @param LoginForm $form * @return HTTPResponse */ public function doLogin($data, $form) @@ -223,7 +222,7 @@ class LoginHandler extends RequestHandler */ public function performLogin($member, $data) { - $member->LogIn(isset($data['Remember'])); + $member->logIn(isset($data['Remember'])); return $member; } /** diff --git a/src/Security/Security.php b/src/Security/Security.php index dff7e5e42..068cf6ed0 100644 --- a/src/Security/Security.php +++ b/src/Security/Security.php @@ -238,13 +238,13 @@ class Security extends Controller implements TemplateGlobalProvider parent::init(); // Prevent clickjacking, see https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options - $frameOptions = $this->config()->get('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 = $this->config()->get('robots_tag'); + $robotsTag = static::config()->get('robots_tag'); if ($robotsTag) { $this->getResponse()->addHeader('X-Robots-Tag', $robotsTag); } @@ -267,9 +267,9 @@ class Security extends Controller implements TemplateGlobalProvider */ protected function getAuthenticator($name) { - $authenticators = self::config()->authenticators; + $authenticators = self::config()->get('authenticators'); - if (!$name) $name = 'default'; + $name = $name ?: 'default'; if (isset($authenticators[$name])) { return Injector::inst()->get($authenticators[$name]); @@ -283,13 +283,20 @@ class Security extends Controller implements TemplateGlobalProvider * * @return array Return an array of Authenticator objects */ - public static function getAuthenticators() + public static function getAuthenticators($service = Authenticator::LOGIN) { - $authenticators = self::config()->authenticators; + $authenticators = self::config()->get('authenticators'); - return array_map(function ($class) { - return Injector::inst()->get($class); - }, $authenticators); + foreach($authenticators as $name => &$class) { + /** @var Authenticator $authenticator */ + $authenticator = Injector::inst()->get($class); + if($authenticator->supportedServices() & $service) { + $class = $authenticator; + } else { + unset($authenticators[$name]); + } + } + return $authenticators; } /** @@ -421,22 +428,6 @@ class Security extends Controller implements TemplateGlobalProvider )); } - /** - * Get the login form to process according to the submitted data - * - * @return Form - * @throws Exception - */ - public function LoginForm() - { - $authenticator = $this->getAuthenticator('default'); - if ($authenticator) { - $handler = $authenticator->getLoginHandler($this->Link()); - return $handler->handleRequest($this->request, DataModel::inst()); - } - throw new Exception('Passed invalid authentication method'); - } - /** * Get the login forms for all available authentication methods * @@ -480,6 +471,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 @@ -488,14 +485,43 @@ class Security extends Controller implements TemplateGlobalProvider */ public function logout($redirect = true) { + $this->extend('beforeMemberLoggedOut'); + $request = $this->getRequest(); $member = Member::currentUser(); - if ($member) { - $member->logOut(); + // Reasoning for (now) to not go with a full LoginHandler call, is to not make it circular + // re-sending the request forward to the authenticator. In the case of logout, I think it would be + // overkill. + if (($name = $request->param('ID')) && self::hasAuthenticator($request->param('ID'))){ + /** @var Authenticator $authenticator */ + $authenticator = $this->getAuthenticator($request->param('ID')); + if($authenticator->doLogOut($member) !== true) { + $this->extend('failureMemberLoggedOut', $authenticator); + return $this->redirectBack(); + } + $this->extend('successMemberLoggedOut', $authenticator); + } else { + $authenticators = static::getAuthenticators(Authenticator::LOGOUT); + /** + * @var string $name + * @var Authenticator $authenticator + */ + foreach ($authenticators as $name => $authenticator) { + if ($authenticator->logOut($member) !== true) { + $this->extend('failureMemberLoggedOut', $authenticator); + // Break on first log out failure(?) + 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; } @@ -644,7 +670,7 @@ class Security extends Controller implements TemplateGlobalProvider * @return HTTPResponse|string Returns the "login" page as HTML code. * @throws HTTPResponse_Exception */ - public function login($request) + public function login($request, $service = Authenticator::LOGIN) { // Check pre-login process if ($response = $this->preLogin()) { @@ -654,19 +680,20 @@ class Security extends Controller implements TemplateGlobalProvider $link = $this->link("login"); // Delegate to a single handler - Security/login//... - if ($name = $request->param('ID')) { + if (($name = $request->param('ID')) && self::hasAuthenticator($request->param('ID'))) { $request->shift(); $authenticator = $this->getAuthenticator($name); - if (!$authenticator) { - throw new HTTPResponse_Exception(404, 'No authenticator "' . $name . '"'); + // @todo handle different Authenticator situations + if (!$authenticator->supportedServices() & $service) { + throw new HTTPResponse_Exception('Invalid Authenticator "' . $name . '" for login action', 418); } $authenticators = [ $name => $authenticator ]; // Delegate to all of them, building a tabbed view - Security/login } else { - $authenticators = $this->getAuthenticators(); + $authenticators = static::getAuthenticators($service); } $handlers = $authenticators; @@ -746,7 +773,7 @@ class Security extends Controller implements TemplateGlobalProvider */ protected function delegateToHandler(RequestHandler $handler, $title, array $templates) { - $result = $handler->handleRequest($this->getRequest(), \SilverStripe\ORM\DataModel::inst()); + $result = $handler->handleRequest($this->getRequest(), DataModel::inst()); // Return the customised controller - used to render in a Form // Post requests are expected to be login posts, so they'll be handled downstairs @@ -927,7 +954,7 @@ class Security extends Controller implements TemplateGlobalProvider * Factory method for the lost password form * * @skipUpgrade - * @return ChangePasswordForm Returns the lost password form + * @return MemberAuthenticator\ChangePasswordForm */ public function ChangePasswordForm() { @@ -1076,7 +1103,7 @@ class Security extends Controller implements TemplateGlobalProvider */ public static function has_default_admin() { - return !empty(self::$default_username) && !empty(self::$default_password); + return !empty(self::$default_username) && !empty(self::$default_password) && (Director::get_environment_type() === 'dev'); } /** diff --git a/tests/php/Security/MemberAuthenticatorTest.php b/tests/php/Security/MemberAuthenticatorTest.php index af9bbba1d..75af65aa0 100644 --- a/tests/php/Security/MemberAuthenticatorTest.php +++ b/tests/php/Security/MemberAuthenticatorTest.php @@ -5,6 +5,7 @@ namespace SilverStripe\Security\Tests; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\ORM\ValidationResult; +use SilverStripe\Security\MemberAuthenticator\CMSAuthenticator; use SilverStripe\Security\PasswordEncryptor; use SilverStripe\Security\PasswordEncryptor_PHPHash; use SilverStripe\Security\Security; @@ -89,7 +90,7 @@ class MemberAuthenticatorTest extends SapphireTest */ public function testAuthenticateByTempID() { - $authenticator = new Authenticator(); + $authenticator = new CMSAuthenticator(); $member = new Member(); $member->Email = 'test1@test.com'; @@ -186,7 +187,7 @@ class MemberAuthenticatorTest extends SapphireTest $dummy ); - $this->assertTrue(Member::default_admin()->isLockedOut()); + $this->assertFalse(Member::default_admin()->canLogin()->isValid()); $this->assertEquals('2016-04-18 00:10:00', Member::default_admin()->LockedOutUntil); } } diff --git a/tests/php/Security/MemberTest.php b/tests/php/Security/MemberTest.php index 4b33939cf..f3f7348f7 100644 --- a/tests/php/Security/MemberTest.php +++ b/tests/php/Security/MemberTest.php @@ -1155,8 +1155,8 @@ class MemberTest extends FunctionalTest 'Failed to increment $member->FailedLoginCount' ); - $this->assertFalse( - $member->isLockedOut(), + $this->assertTrue( + $member->canLogin()->isValid(), "Member has been locked out too early" ); } From f9ea752bae7344ea9df1eeef56e7e8259e65fbcb Mon Sep 17 00:00:00 2001 From: Sam Minnee Date: Mon, 8 May 2017 07:11:00 +1200 Subject: [PATCH 4/9] NEW: Add AuthenticationHandler interface NEW: Add IdentityStore for registering log-in / log-out data NEW: Add AuthenticationRequestFilter for managing login NEW: Add Security:setCurrentUser() / Security::getCurrentUser() NEW: Add FunctionalTest::logOut() --- _config/security.yml | 23 +- src/Dev/FunctionalTest.php | 29 ++- src/Dev/SapphireTest.php | 2 +- src/Security/AuthenticationHandler.php | 42 ++++ src/Security/AuthenticationRequestFilter.php | 96 ++++++++ src/Security/IdentityStore.php | 32 +++ src/Security/Member.php | 222 +++++------------ .../ChangePasswordHandler.php | 8 +- .../CookieAuthenticationHandler.php | 223 ++++++++++++++++++ .../MemberAuthenticator/LoginHandler.php | 9 +- .../SessionAuthenticationHandler.php | 112 +++++++++ src/Security/Security.php | 12 + .../php/Security/MemberAuthenticatorTest.php | 4 +- tests/php/Security/MemberTest.php | 48 ++-- tests/php/Security/SecurityTest.php | 6 +- 15 files changed, 663 insertions(+), 205 deletions(-) create mode 100644 src/Security/AuthenticationHandler.php create mode 100644 src/Security/AuthenticationRequestFilter.php create mode 100644 src/Security/IdentityStore.php create mode 100644 src/Security/MemberAuthenticator/CookieAuthenticationHandler.php create mode 100644 src/Security/MemberAuthenticator/SessionAuthenticationHandler.php diff --git a/_config/security.yml b/_config/security.yml index 039e089b1..60f128f6b 100644 --- a/_config/security.yml +++ b/_config/security.yml @@ -8,5 +8,26 @@ SilverStripe\Security\Security: default: SilverStripe\Security\MemberAuthenticator\Authenticator cms: SilverStripe\Security\MemberAuthenticator\CMSAuthenticator +SilverStripe\Core\Injector\Injector: + SilverStripe\Control\RequestProcessor: + properties: + filters: + - '%$SilverStripe\Security\AuthenticationRequestFilter' + SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler: + properties: + SessionVariable: loggedInAs + SilverStripe\Security\MemberAuthenticator\CookieAuthenticationHandler: + properties: + TokenCookieName: alc_enc + DeviceCookieName: alc_device + CascadeLogInTo: %$SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler + SilverStripe\Security\IdentityStore: + class: SilverStripe\Security\AuthenticationRequestFilter + +SilverStripe\Security\AuthenticationRequestFilter: + handlers: + session: SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler + alc: SilverStripe\Security\MemberAuthenticator\CookieAuthenticationHandler + SilverStripe\Security\MemberAuthenticator\CMSSecurity: - reauth_enabled: true \ No newline at end of file + reauth_enabled: true diff --git a/src/Dev/FunctionalTest.php b/src/Dev/FunctionalTest.php index ca50d9d8f..123b689c1 100644 --- a/src/Dev/FunctionalTest.php +++ b/src/Dev/FunctionalTest.php @@ -7,6 +7,7 @@ use SilverStripe\Control\HTTPResponse; use SilverStripe\Core\Config\Config; use SilverStripe\Security\BasicAuth; use SilverStripe\Security\Member; +use SilverStripe\Security\Security; use SilverStripe\Security\SecurityToken; use SilverStripe\View\SSViewer; use PHPUnit_Framework_AssertionFailedError; @@ -104,6 +105,9 @@ class FunctionalTest extends SapphireTest // basis. BasicAuth::protect_entire_site(false); + $this->session()->inst_clear('loggedInAs'); + Security::setCurrentUser(null); + SecurityToken::disable(); } @@ -401,15 +405,26 @@ class FunctionalTest extends SapphireTest */ public function logInAs($member) { - if (is_object($member)) { - $memberID = $member->ID; - } elseif (is_numeric($member)) { - $memberID = $member; - } else { - $memberID = $this->idFromFixture('SilverStripe\\Security\\Member', $member); + if (is_numeric($member)) { + $member = DataObject::get_by_id(Member::class, $member); + } elseif (!is_object($member)) { + $member = $this->objFromFixture('SilverStripe\\Security\\Member', $member); } - $this->session()->inst_set('loggedInAs', $memberID); + $this->session()->inst_set('loggedInAs', $member->ID); + Security::setCurrentUser($member); + } + + + /** + * Log in as the given member + * + * @param Member|int|string $member The ID, fixture codename, or Member object of the member that you want to log in + */ + public function logOut() + { + $this->session()->inst_set('loggedInAs', null); + Security::setCurrentUser(null); } /** diff --git a/src/Dev/SapphireTest.php b/src/Dev/SapphireTest.php index 20b49f020..3651d6fc6 100644 --- a/src/Dev/SapphireTest.php +++ b/src/Dev/SapphireTest.php @@ -1250,7 +1250,7 @@ class SapphireTest extends PHPUnit_Framework_TestCase $this->cache_generatedMembers[$permCode] = $member; } - $member->logIn(); + Security::setCurrentUser($member); return $member->ID; } diff --git a/src/Security/AuthenticationHandler.php b/src/Security/AuthenticationHandler.php new file mode 100644 index 000000000..64cb88774 --- /dev/null +++ b/src/Security/AuthenticationHandler.php @@ -0,0 +1,42 @@ + + * SilverStripe\Security\Security: + * authentication_handlers: + * - SilverStripe\Security\BasicAuthentionHandler + * + * + * @param HTTPRequest $request The current HTTP request + * @return Member|null The authenticated Member, or null if this auth mechanism isn't used. + * @throws ValidationException If authentication data exists but does not match a member. + */ + public function authenticateRequest(HTTPRequest $request); +} diff --git a/src/Security/AuthenticationRequestFilter.php b/src/Security/AuthenticationRequestFilter.php new file mode 100644 index 000000000..f66bc081f --- /dev/null +++ b/src/Security/AuthenticationRequestFilter.php @@ -0,0 +1,96 @@ +get($identifier); + }, + $this->config()->get('handlers') + ); + } + + /** + * Identify the current user from the request + */ + public function preRequest(HTTPRequest $request, Session $session, DataModel $model) + { + try { + foreach ($this->getHandlers() as $handler) { + // @todo Update requestfilter logic to allow modification of initial response + // in order to add cookies, etc + $member = $handler->authenticateRequest($request, new HTTPResponse()); + if ($member) { + // @todo Remove the static coupling here + Security::setCurrentUser($member); + break; + } + } + } catch (ValidationException $e) { + throw new HTTPResponse_Exception( + "Bad log-in details: " . $e->getMessage(), + 400 + ); + } + } + + /** + * No-op + */ + public function postRequest(HTTPRequest $request, HTTPResponse $response, DataModel $model) + { + } + + /** + * Log into the identity-store handlers attached to this request filter + * + * @inherit + */ + public function logIn(Member $member, $persistent, HTTPRequest $request) + { + // @todo Coupling here isn't ideal. + $member->beforeMemberLoggedIn(); + + foreach ($this->getHandlers() as $handler) { + if ($handler instanceof IdentityStore) { + $handler->logIn($member, $persistent, $request); + } + } + + // @todo Coupling here isn't ideal. + Security::setCurrentUser($member); + $member->afterMemberLoggedIn(); + } + + /** + * Log out of all the identity-store handlers attached to this request filter + * + * @inherit + */ + public function logOut(HTTPRequest $request) + { + foreach ($this->getHandlers() as $handler) { + if ($handler instanceof IdentityStore) { + $handler->logOut($request); + } + } + + // @todo Coupling here isn't ideal. + Security::setCurrentUser(null); + } +} diff --git a/src/Security/IdentityStore.php b/src/Security/IdentityStore.php new file mode 100644 index 000000000..ab0f37813 --- /dev/null +++ b/src/Security/IdentityStore.php @@ -0,0 +1,32 @@ +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. * @@ -447,52 +420,34 @@ class Member extends DataObject implements TemplateGlobalProvider } /** - * Logs this member in + * @deprecated 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() { + user_error("This method is deprecated and now only logs in for the current request", E_USER_WARNING); + 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(); @@ -539,91 +494,37 @@ 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. + * Logs this member out. */ - public static function autoLogin() + public function logOut() { - // 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; + $this->extend('beforeMemberLoggedOut'); + + Session::clear("loggedInAs"); + if (Member::config()->login_marker_cookie) { + Cookie::set(Member::config()->login_marker_cookie, null, 0); } - if (!Security::config()->autologin_enabled - || strpos(Cookie::get('alc_enc'), ':') === false - || Session::get("loggedInAs") - || !Security::database_is_ready() - ) { - return; - } + Session::destroy(); - if (strpos(Cookie::get('alc_enc'), ':') && Cookie::get('alc_device') && !Session::get("loggedInAs")) { - list($uid, $token) = explode(':', Cookie::get('alc_enc'), 2); + $this->extend('memberLoggedOut'); - if (!$uid || !$token) { - return; - } + // Clears any potential previous hashes for this member + RememberLoginHash::clear($this, Cookie::get('alc_device')); - $deviceID = 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'); - /** @var Member $member */ - $member = Member::get()->byID($uid); + // 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'); - /** @var RememberLoginHash $rememberLoginHash */ - $rememberLoginHash = null; + $this->write(); - // 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'); - } - } + // Audit logging hook + $this->extend('memberLoggedOut'); } /** @@ -820,20 +721,9 @@ class Member extends DataObject implements TemplateGlobalProvider */ public static function currentUser() { - $id = Member::currentUserID(); - - 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. @@ -851,13 +741,18 @@ class Member extends DataObject implements TemplateGlobalProvider */ 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); } } @@ -868,22 +763,13 @@ class Member extends DataObject implements TemplateGlobalProvider */ public static function currentUserID() { - if (isset(static::$overrideID)) { - return static::$overrideID; + if ($member = Security::getCurrentUser()) { + return $member->ID; + } else { + return 0; } - - $id = Session::get("loggedInAs"); - if (!$id && !self::$_already_tried_to_auto_log_in) { - self::autoLogin(); - $id = Session::get("loggedInAs"); - } - - 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. diff --git a/src/Security/MemberAuthenticator/ChangePasswordHandler.php b/src/Security/MemberAuthenticator/ChangePasswordHandler.php index 26cafae22..244740004 100644 --- a/src/Security/MemberAuthenticator/ChangePasswordHandler.php +++ b/src/Security/MemberAuthenticator/ChangePasswordHandler.php @@ -3,12 +3,13 @@ namespace SilverStripe\Security\MemberAuthenticator; +use SilverStripe\Core\Injector\Injector; use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\Session; use SilverStripe\Forms\FormRequestHandler; use SilverStripe\Security\Member; use SilverStripe\Security\Security; - +use SilverStripe\Security\IdentityStore; class ChangePasswordHandler extends FormRequestHandler { @@ -18,7 +19,7 @@ class ChangePasswordHandler extends FormRequestHandler * @param array $data The user submitted data * @return HTTPResponse */ - public function doChangePassword(array $data) + public function doChangePassword(array $data, $form) { $member = Member::currentUser(); // The user was logged in, check the current password @@ -80,7 +81,8 @@ class ChangePasswordHandler extends FormRequestHandler $member->write(); if ($member->canLogIn()->isValid()) { - $member->logIn(); + Injector::inst()->get(IdentityStore::class) + ->logIn($member, false, $form->getRequestHandler()->getRequest()); } // TODO Add confirmation message to login redirect diff --git a/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php b/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php new file mode 100644 index 000000000..2090f82ff --- /dev/null +++ b/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php @@ -0,0 +1,223 @@ +deviceCookieName; + } + + /** + * Set the name of the cookie used to track this device + * + * @param string $cookieName + * @return null + */ + public function setDeviceCookieName($deviceCookieName) + { + $this->deviceCookieName = $deviceCookieName; + } + + /** + * 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 $cookieName + * @return null + */ + public function setTokenCookieName($tokenCookieName) + { + $this->tokenCookieName = $tokenCookieName; + } + + /** + * Once a member is found by authenticateRequest() pass it to this identity store + * + * @return IdentityStore + */ + public function getCascadeLogInTo() + { + return $this->cascadeLogInTo; + } + + /** + * Set the name of the cookie used to store an login token + * + * @param $cascadeLogInTo + * @return null + */ + public function setCascadeLogInTo(IdentityStore $cascadeLogInTo) + { + $this->cascadeLogInTo = $cascadeLogInTo; + } + + /** + * @inherit + */ + 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 || strpos($uidAndToken, ':') === false || !Security::database_is_ready()) { + return; + } + + list($uid, $token) = explode(':', $uidAndToken, 2); + + if (!$uid || !$token) { + return; + } + + /** @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) { + if ($this->cascadeLogInTo) { + // @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->cascadeLogInTo->logIn($member, false, $request); + //\SilverStripe\Dev\Debug::message('here'); + } + + // @todo Consider whether response should be part of logIn() as well + + // Renew the token + if ($rememberLoginHash) { + $rememberLoginHash->renew(); + $tokenExpiryDays = RememberLoginHash::config()->uninherited('token_expiry_days'); + Cookie::set( + $this->getTokenCookieName(), + $member->ID . ':' . $rememberLoginHash->getToken(), + $tokenExpiryDays, + null, + null, + false, + true + ); + } + + return $member; + + // Audit logging hook + $member->extend('memberAutoLoggedIn'); + } + } + + /** + * @inherit + */ + public function logIn(Member $member, $persistent, HTTPRequest $request) + { + // @todo couple the cookies to the response object + + // 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 + ); + + // Clear a cookie for non-persistent log-ins + } else { + $this->logOut($request); + } + } + + /** + * @inherit + */ + public function logOut(HTTPRequest $request) + { + // @todo couple the cookies to the response object + + 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 index c09b61fd6..d0c91e681 100644 --- a/src/Security/MemberAuthenticator/LoginHandler.php +++ b/src/Security/MemberAuthenticator/LoginHandler.php @@ -9,6 +9,8 @@ use SilverStripe\Control\RequestHandler; use SilverStripe\ORM\ValidationResult; use SilverStripe\Security\Security; use SilverStripe\Security\Member; +use SilverStripe\Core\Injector\Injector; +use SilverStripe\Security\IdentityStore; /** * Handle login requests from MemberLoginForm @@ -99,7 +101,7 @@ class LoginHandler extends RequestHandler // Successful login if ($member = $this->checkLogin($data, $failureMessage)) { - $this->performLogin($member, $data); + $this->performLogin($member, $data, $form->getRequestHandler()->getRequest()); return $this->redirectAfterSuccessfulLogin(); } @@ -220,9 +222,10 @@ class LoginHandler extends RequestHandler * @return Member Returns the member object on successful authentication * or NULL on failure. */ - public function performLogin($member, $data) + public function performLogin($member, $data, $request) { - $member->logIn(isset($data['Remember'])); + // @todo pass request/response + Injector::inst()->get(IdentityStore::class)->logIn($member, !empty($data['Remember']), $request); return $member; } /** diff --git a/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php b/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php new file mode 100644 index 000000000..18f1521fe --- /dev/null +++ b/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php @@ -0,0 +1,112 @@ +sessionVariable; + } + + /** + * Set the session variable name used to track member ID + * + * @param string $sessionVariable + * @return null + */ + public function setSessionVariable($sessionVariable) + { + $this->sessionVariable = $sessionVariable; + } + + /** + * @inherit + */ + public function authenticateRequest(HTTPRequest $request) + { + // @todo couple the session to a request object + // $session = $request->getSession(); + + if ($id = Session::get($this->getSessionVariable())) { + // If ID is a bad ID it will be treated as if the user is not logged in, rather than throwing a + // ValidationException + return DataObject::get_by_id(Member::class, $id); + } + + return null; + } + + /** + * @inherit + */ + public function logIn(Member $member, $persistent, HTTPRequest $request) + { + // @todo couple the session to a request object + // $session = $request->getSession(); + + $this->regenerateSessionId(); + Session::set($this->getSessionVariable(), $member->ID); + + // This lets apache rules detect whether the user has logged in + // @todo make this a settign on the authentication handler + if (Member::config()->login_marker_cookie) { + Cookie::set(Member::config()->login_marker_cookie, 1, 0); + } + } + + /** + * Regenerate the session_id. + */ + protected static function regenerateSessionId() + { + if (!Member::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); + } + } + /** + * @inherit + */ + public function logOut(HTTPRequest $request) + { + // @todo couple the session to a request object + // $session = $request->getSession(); + + Session::clear($this->getSessionVariable()); + } +} diff --git a/src/Security/Security.php b/src/Security/Security.php index 068cf6ed0..a2fda442e 100644 --- a/src/Security/Security.php +++ b/src/Security/Security.php @@ -428,6 +428,18 @@ class Security extends Controller implements TemplateGlobalProvider )); } + private static $currentUser; + + public static function setCurrentUser($currentUser) + { + self::$currentUser = $currentUser; + } + + public static function getCurrentUser() + { + return self::$currentUser; + } + /** * Get the login forms for all available authentication methods * diff --git a/tests/php/Security/MemberAuthenticatorTest.php b/tests/php/Security/MemberAuthenticatorTest.php index 75af65aa0..4c2bb4896 100644 --- a/tests/php/Security/MemberAuthenticatorTest.php +++ b/tests/php/Security/MemberAuthenticatorTest.php @@ -2,6 +2,7 @@ namespace SilverStripe\Security\Tests; +use SilverStripe\Core\Injector\Injector; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\ORM\ValidationResult; @@ -13,6 +14,7 @@ use SilverStripe\Security\Member; use SilverStripe\Security\MemberAuthenticator\Authenticator; use SilverStripe\Security\MemberAuthenticator\LoginForm; use SilverStripe\Security\CMSMemberLoginForm; +use SilverStripe\Security\IdentityStore; use SilverStripe\Core\Config\Config; use SilverStripe\Dev\SapphireTest; use SilverStripe\Forms\FieldList; @@ -103,7 +105,7 @@ class MemberAuthenticatorTest extends SapphireTest $this->assertEmpty($tempID); // If the user logs in then they have a temp id - $member->logIn(true); + Injector::inst()->get(IdentityStore::class)->logIn($member, true, new HTTPRequest('GET', '/')); $tempID = $member->TempIDHash; $this->assertNotEmpty($tempID); diff --git a/tests/php/Security/MemberTest.php b/tests/php/Security/MemberTest.php index f3f7348f7..836a3ab56 100644 --- a/tests/php/Security/MemberTest.php +++ b/tests/php/Security/MemberTest.php @@ -15,10 +15,12 @@ use SilverStripe\Security\Security; use SilverStripe\Security\MemberPassword; use SilverStripe\Security\Group; use SilverStripe\Security\Permission; +use SilverStripe\Security\IdentityStore; use SilverStripe\Security\PasswordEncryptor_Blowfish; use SilverStripe\Security\RememberLoginHash; use SilverStripe\Security\Member_Validator; use SilverStripe\Security\Tests\MemberTest\FieldsExtension; +use SilverStripe\Control\HTTPRequest; class MemberTest extends FunctionalTest { @@ -534,26 +536,25 @@ class MemberTest extends FunctionalTest $member = $this->objFromFixture(Member::class, 'test'); $member2 = $this->objFromFixture(Member::class, 'staffmember'); - $this->session()->inst_set('loggedInAs', null); - /* Not logged in, you can't view, delete or edit the record */ $this->assertFalse($member->canView()); $this->assertFalse($member->canDelete()); $this->assertFalse($member->canEdit()); /* Logged in users can edit their own record */ - $this->session()->inst_set('loggedInAs', $member->ID); + $this->logInAs($member); $this->assertTrue($member->canView()); $this->assertFalse($member->canDelete()); $this->assertTrue($member->canEdit()); /* Other uses cannot view, delete or edit others records */ - $this->session()->inst_set('loggedInAs', $member2->ID); + $this->logInAs($member2); $this->assertFalse($member->canView()); $this->assertFalse($member->canDelete()); $this->assertFalse($member->canEdit()); - $this->session()->inst_set('loggedInAs', null); + $this->addExtensions($extensions); + $this->logOut(); } public function testAuthorisedMembersCanManipulateOthersRecords() @@ -562,14 +563,19 @@ class MemberTest extends FunctionalTest $member2 = $this->objFromFixture(Member::class, 'staffmember'); /* Group members with SecurityAdmin permissions can manipulate other records */ - $this->session()->inst_set('loggedInAs', $member->ID); + $this->logInAs($member); $this->assertTrue($member2->canView()); $this->assertTrue($member2->canDelete()); $this->assertTrue($member2->canEdit()); + + $this->addExtensions($extensions); + $this->logOut(); } public function testExtendedCan() { + + $extensions = $this->removeExtensions(Object::get_extensions(Member::class)); $member = $this->objFromFixture(Member::class, 'test'); /* Normal behaviour is that you can't view a member unless canView() on an extension returns true */ @@ -664,12 +670,12 @@ class MemberTest extends FunctionalTest 'Adding new admin group relation is not allowed for non-admin members' ); - $this->session()->inst_set('loggedInAs', $adminMember->ID); + $this->logInAs($adminMember); $this->assertTrue( $staffMember->onChangeGroups(array($newAdminGroup->ID)), 'Adding new admin group relation is allowed for normal users, when granter is logged in as admin' ); - $this->session()->inst_set('loggedInAs', null); + $this->logOut(); $this->assertTrue( $adminMember->onChangeGroups(array($newAdminGroup->ID)), @@ -719,7 +725,7 @@ class MemberTest extends FunctionalTest ); // Test staff member can be added if they are already admin - $this->session()->inst_set('loggedInAs', null); + $this->logOut(); $this->assertFalse($adminMember->inGroup($newAdminGroup)); $adminMember->Groups()->add($newAdminGroup); $this->assertTrue( @@ -872,7 +878,8 @@ class MemberTest extends FunctionalTest { $m1 = $this->objFromFixture(Member::class, 'grouplessmember'); - $m1->login(true); + Injector::inst()->get(IdentityStore::class)->logIn($m1, true, new HTTPRequest('GET', '/')); + $hashes = RememberLoginHash::get()->filter('MemberID', $m1->ID); $this->assertEquals($hashes->count(), 1); $firstHash = $hashes->first(); @@ -887,7 +894,8 @@ class MemberTest extends FunctionalTest */ $m1 = $this->objFromFixture(Member::class, 'noexpiry'); - $m1->logIn(true); + Injector::inst()->get(IdentityStore::class)->logIn($m1, true, new HTTPRequest('GET', '/')); + $firstHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->first(); $this->assertNotNull($firstHash); @@ -914,7 +922,7 @@ class MemberTest extends FunctionalTest ); $this->assertContains($message, $response->getBody()); - $this->session()->inst_set('loggedInAs', null); + $this->logOut(); // A wrong token or a wrong device ID should not let us autologin $response = $this->get( @@ -922,7 +930,7 @@ class MemberTest extends FunctionalTest $this->session(), null, array( - 'alc_enc' => $m1->ID.':'.str_rot13($token), + 'alc_enc' => $m1->ID.':asdfasd'.str_rot13($token), 'alc_device' => $firstHash->DeviceID ) ); @@ -965,7 +973,7 @@ class MemberTest extends FunctionalTest * @var Member $m1 */ $m1 = $this->objFromFixture(Member::class, 'noexpiry'); - $m1->logIn(true); + Injector::inst()->get(IdentityStore::class)->logIn($m1, true, new HTTPRequest('GET', '/')); $firstHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->first(); $this->assertNotNull($firstHash); @@ -995,7 +1003,7 @@ class MemberTest extends FunctionalTest ); $this->assertContains($message, $response->getBody()); - $this->session()->inst_set('loggedInAs', null); + $this->logOut(); // re-generates the hash so we can get the token $firstHash->Hash = $firstHash->getNewHash($m1); @@ -1015,7 +1023,7 @@ class MemberTest extends FunctionalTest ) ); $this->assertNotContains($message, $response->getBody()); - $this->session()->inst_set('loggedInAs', null); + $this->logOut(); DBDatetime::clear_mock_now(); } @@ -1024,10 +1032,10 @@ class MemberTest extends FunctionalTest $m1 = $this->objFromFixture(Member::class, 'noexpiry'); // First device - $m1->login(true); + Injector::inst()->get(IdentityStore::class)->logIn($m1, true, new HTTPRequest('GET', '/')); Cookie::set('alc_device', null); // Second device - $m1->login(true); + Injector::inst()->get(IdentityStore::class)->logIn($m1, true, new HTTPRequest('GET', '/')); // Hash of first device $firstHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->first(); @@ -1068,7 +1076,7 @@ class MemberTest extends FunctionalTest ); $this->assertContains($message, $response->getBody()); - $this->session()->inst_set('loggedInAs', null); + $this->logOut(); // Accessing the login page from the second device $response = $this->get( @@ -1100,7 +1108,7 @@ class MemberTest extends FunctionalTest // Logging out from any device when all login hashes should be removed RememberLoginHash::config()->update('logout_across_devices', true); - $m1->login(true); + Injector::inst()->get(IdentityStore::class)->logIn($m1, true, new HTTPRequest('GET', '/')); $response = $this->get('Security/logout', $this->session()); $this->assertEquals( RememberLoginHash::get()->filter('MemberID', $m1->ID)->count(), diff --git a/tests/php/Security/SecurityTest.php b/tests/php/Security/SecurityTest.php index 019d6160f..847a58443 100644 --- a/tests/php/Security/SecurityTest.php +++ b/tests/php/Security/SecurityTest.php @@ -378,6 +378,8 @@ class SecurityTest extends FunctionalTest ); $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->inst_get('loggedInAs')); + $this->logOut(); + /* EXPIRED PASSWORDS ARE SENT TO THE CHANGE PASSWORD FORM */ $expiredResponse = $this->doTestLoginForm('expired@silverstripe.com', '1nitialPassword'); $this->assertEquals(302, $expiredResponse->getStatusCode()); @@ -415,6 +417,7 @@ class SecurityTest extends FunctionalTest $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->inst_get('loggedInAs')); // Check if we can login with the new password + $this->logOut(); $goodResponse = $this->doTestLoginForm('testuser@example.com', 'changedPassword'); $this->assertEquals(302, $goodResponse->getStatusCode()); $this->assertEquals( @@ -460,6 +463,7 @@ class SecurityTest extends FunctionalTest $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->inst_get('loggedInAs')); // Check if we can login with the new password + $this->logOut(); $goodResponse = $this->doTestLoginForm('testuser@example.com', 'changedPassword'); $this->assertEquals(302, $goodResponse->getStatusCode()); $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->inst_get('loggedInAs')); @@ -532,7 +536,7 @@ class SecurityTest extends FunctionalTest ); // Log the user out - $this->session()->inst_set('loggedInAs', null); + $this->logOut(); // Login again with wrong password, but less attempts than threshold for ($i = 1; $i < Member::config()->lock_out_after_incorrect_logins; $i++) { From 2b26cafcffa35ece22285f1583d706702a5947d4 Mon Sep 17 00:00:00 2001 From: Simon Erkelens Date: Sat, 20 May 2017 16:32:25 +1200 Subject: [PATCH 5/9] Separate out the log-out handling. Repairing tests and regressions Consistently use `Security::getCurrentUser()` and `Security::setCurrentUser()` Fix for the logout handler to properly logout, some minor wording updates Remove the login hashes for the member when logging out. BasicAuth to use `HTTPRequest` --- .../00_Model/05_Extending_DataObjects.md | 2 +- .../00_Model/07_Permissions.md | 2 +- .../01_Templates/04_Rendering_Templates.md | 2 +- .../05_Extending/01_Extensions.md | 4 +- .../09_Security/00_Member.md | 4 +- docs/en/02_Developer_Guides/10_Email/index.md | 2 +- src/Control/Controller.php | 3 +- src/Dev/FunctionalTest.php | 5 +- src/Dev/TestSession.php | 2 +- src/Forms/ConfirmedPasswordField.php | 3 +- src/Forms/GridField/GridFieldPrintButton.php | 3 +- src/ORM/DataObject.php | 7 +- src/ORM/FieldType/DBDate.php | 3 +- src/ORM/FieldType/DBDatetime.php | 3 +- src/ORM/FieldType/DBTime.php | 3 +- src/Security/AuthenticationRequestFilter.php | 10 ++- src/Security/Authenticator.php | 24 +++--- src/Security/BasicAuth.php | 50 ++++++++----- src/Security/Group.php | 6 +- src/Security/IdentityStore.php | 12 +-- src/Security/InheritedPermissions.php | 6 +- src/Security/Member.php | 22 +++--- .../MemberAuthenticator/Authenticator.php | 36 +-------- .../MemberAuthenticator/CMSAuthenticator.php | 3 +- .../MemberAuthenticator/CMSLoginHandler.php | 2 +- .../ChangePasswordForm.php | 4 +- .../ChangePasswordHandler.php | 2 +- .../CookieAuthenticationHandler.php | 23 +++++- .../MemberAuthenticator/LoginForm.php | 9 ++- .../MemberAuthenticator/LoginHandler.php | 57 +++++++------- .../MemberAuthenticator/LogoutHandler.php | 68 +++++++++++++++++ .../SessionAuthenticationHandler.php | 9 ++- src/Security/Permission.php | 2 +- src/Security/Security.php | 74 +++++++++---------- tests/php/Control/ControllerTest.php | 13 ++-- .../GridField/GridFieldDeleteActionTest.php | 11 ++- .../GridField/GridFieldEditButtonTest.php | 5 +- tests/php/Security/BasicAuthTest.php | 26 +++---- .../TestPermissionNode.php | 7 +- tests/php/Security/MemberTest.php | 13 ++-- tests/php/Security/SecurityTest.php | 8 +- tests/php/View/SSViewerTest.php | 9 ++- 42 files changed, 315 insertions(+), 244 deletions(-) create mode 100644 src/Security/MemberAuthenticator/LogoutHandler.php 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()`).
By default, all `DataObject` subclasses can only be edited, created and viewed by users with the 'ADMIN' permission diff --git a/docs/en/02_Developer_Guides/01_Templates/04_Rendering_Templates.md b/docs/en/02_Developer_Guides/01_Templates/04_Rendering_Templates.md index c7c499d6d..b575d5229 100644 --- a/docs/en/02_Developer_Guides/01_Templates/04_Rendering_Templates.md +++ b/docs/en/02_Developer_Guides/01_Templates/04_Rendering_Templates.md @@ -40,7 +40,7 @@ includes [api:Controller], [api:FormField] and [api:DataObject] instances. ```php $controller->renderWith(array('MyController', 'MyBaseController')); -Member::currentUser()->renderWith('Member_Profile'); +Security::getCurrentUser()->renderWith('Member_Profile'); ``` `renderWith` can be used to override the default template process. For instance, to provide an ajax version of a diff --git a/docs/en/02_Developer_Guides/05_Extending/01_Extensions.md b/docs/en/02_Developer_Guides/05_Extending/01_Extensions.md index 36297b886..7758c4567 100644 --- a/docs/en/02_Developer_Guides/05_Extending/01_Extensions.md +++ b/docs/en/02_Developer_Guides/05_Extending/01_Extensions.md @@ -109,7 +109,7 @@ we added a `SayHi` method which is unique to our extension. **mysite/code/Page.php** :::php - $member = Member::currentUser(); + $member = Security::getCurrentUser(); echo $member->SayHi; // "Hi Sam" @@ -220,7 +220,7 @@ To see what extensions are currently enabled on an object, use [api:Object::getE :::php - $member = Member::currentUser(); + $member = Security::getCurrentUser(); print_r($member->getExtensionInstances()); diff --git a/docs/en/02_Developer_Guides/09_Security/00_Member.md b/docs/en/02_Developer_Guides/09_Security/00_Member.md index 6bca454c9..742bca49f 100644 --- a/docs/en/02_Developer_Guides/09_Security/00_Member.md +++ b/docs/en/02_Developer_Guides/09_Security/00_Member.md @@ -24,12 +24,12 @@ next method for testing if you just need to test. } -**Member::currentUser()** +**Security::getCurrentUser()** Returns the full *Member* Object for the current user, returns *null* if user is not logged in. :::php - if( $member = Member::currentUser() ) { + if( $member = Security::getCurrentUser() ) { // Work with $member } else { // Do non-member stuff diff --git a/docs/en/02_Developer_Guides/10_Email/index.md b/docs/en/02_Developer_Guides/10_Email/index.md index 7c317a6ab..82d20a614 100644 --- a/docs/en/02_Developer_Guides/10_Email/index.md +++ b/docs/en/02_Developer_Guides/10_Email/index.md @@ -60,7 +60,7 @@ The PHP Logic.. $email = SilverStripe\Control\Email\Email::create() ->setHTMLTemplate('Email\\MyCustomEmail') ->setData(array( - 'Member' => Member::currentUser(), + 'Member' => Security::getCurrentUser(), 'Link'=> $link, )) ->setFrom($from) diff --git a/src/Control/Controller.php b/src/Control/Controller.php index 8701510a0..9f2e6f9b3 100644 --- a/src/Control/Controller.php +++ b/src/Control/Controller.php @@ -9,6 +9,7 @@ use SilverStripe\ORM\DataModel; use SilverStripe\ORM\FieldType\DBHTMLText; use SilverStripe\Security\BasicAuth; use SilverStripe\Security\Member; +use SilverStripe\Security\Security; use SilverStripe\View\SSViewer; use SilverStripe\View\TemplateGlobalProvider; @@ -575,7 +576,7 @@ class Controller extends RequestHandler implements TemplateGlobalProvider public function can($perm, $member = null) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } if (is_array($perm)) { $perm = array_map(array($this, 'can'), $perm, array_fill(0, count($perm), $member)); diff --git a/src/Dev/FunctionalTest.php b/src/Dev/FunctionalTest.php index 123b689c1..8d5f4ec6f 100644 --- a/src/Dev/FunctionalTest.php +++ b/src/Dev/FunctionalTest.php @@ -5,6 +5,7 @@ namespace SilverStripe\Dev; use SilverStripe\Control\Session; use SilverStripe\Control\HTTPResponse; use SilverStripe\Core\Config\Config; +use SilverStripe\ORM\DataObject; use SilverStripe\Security\BasicAuth; use SilverStripe\Security\Member; use SilverStripe\Security\Security; @@ -415,11 +416,9 @@ class FunctionalTest extends SapphireTest Security::setCurrentUser($member); } - /** - * Log in as the given member + * Log out the member * - * @param Member|int|string $member The ID, fixture codename, or Member object of the member that you want to log in */ public function logOut() { diff --git a/src/Dev/TestSession.php b/src/Dev/TestSession.php index 3492b357f..22d7042cc 100644 --- a/src/Dev/TestSession.php +++ b/src/Dev/TestSession.php @@ -38,7 +38,7 @@ class TestSession /** * Necessary to use the mock session * created in {@link session} in the normal controller stack, - * e.g. to overwrite Member::currentUser() with custom login data. + * e.g. to overwrite Security::getCurrentUser() with custom login data. * * @var Controller */ diff --git a/src/Forms/ConfirmedPasswordField.php b/src/Forms/ConfirmedPasswordField.php index 83ee1e918..886039803 100644 --- a/src/Forms/ConfirmedPasswordField.php +++ b/src/Forms/ConfirmedPasswordField.php @@ -5,6 +5,7 @@ namespace SilverStripe\Forms; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataObjectInterface; use SilverStripe\Security\Member; +use SilverStripe\Security\Security; use SilverStripe\View\Requirements; /** @@ -504,7 +505,7 @@ class ConfirmedPasswordField extends FormField } // Check this password is valid for the current user - $member = Member::currentUser(); + $member = Security::getCurrentUser(); if (!$member) { $validator->validationError( $name, diff --git a/src/Forms/GridField/GridFieldPrintButton.php b/src/Forms/GridField/GridFieldPrintButton.php index e8cfce14f..3e1c5db09 100644 --- a/src/Forms/GridField/GridFieldPrintButton.php +++ b/src/Forms/GridField/GridFieldPrintButton.php @@ -10,6 +10,7 @@ use SilverStripe\ORM\DataObject; use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\ORM\FieldType\DBHTMLText; use SilverStripe\Security\Member; +use SilverStripe\Security\Security; use SilverStripe\View\Requirements; use SilverStripe\View\ArrayData; @@ -249,7 +250,7 @@ class GridFieldPrintButton implements GridField_HTMLProvider, GridField_ActionPr "Header" => $header, "ItemRows" => $itemRows, "Datetime" => DBDatetime::now(), - "Member" => Member::currentUser(), + "Member" => Security::getCurrentUser(), )); return $ret; diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php index 229d16f4b..7e5736329 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -24,6 +24,7 @@ use SilverStripe\ORM\FieldType\DBComposite; use SilverStripe\ORM\FieldType\DBClassName; use SilverStripe\Security\Member; use SilverStripe\Security\Permission; +use SilverStripe\Security\Security; use SilverStripe\View\ViewableData; use LogicException; use InvalidArgumentException; @@ -76,11 +77,11 @@ use stdClass; * static $api_access = true; * * function canView($member = false) { - * if(!$member) $member = Member::currentUser(); + * if(!$member) $member = Security::getCurrentUser(); * return $member->inGroup('Subscribers'); * } * function canEdit($member = false) { - * if(!$member) $member = Member::currentUser(); + * if(!$member) $member = Security::getCurrentUser(); * return $member->inGroup('Editors'); * } * @@ -2498,7 +2499,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity public function can($perm, $member = null, $context = array()) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } if ($member && Permission::checkMember($member, "ADMIN")) { diff --git a/src/ORM/FieldType/DBDate.php b/src/ORM/FieldType/DBDate.php index 8107b7127..9f358c9e9 100644 --- a/src/ORM/FieldType/DBDate.php +++ b/src/ORM/FieldType/DBDate.php @@ -9,6 +9,7 @@ use SilverStripe\Forms\DateField; use SilverStripe\i18n\i18n; use SilverStripe\ORM\DB; use SilverStripe\Security\Member; +use SilverStripe\Security\Security; /** * Represents a date field. @@ -250,7 +251,7 @@ class DBDate extends DBField public function FormatFromSettings($member = null) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } // Fall back to nice diff --git a/src/ORM/FieldType/DBDatetime.php b/src/ORM/FieldType/DBDatetime.php index 2e2dd673d..6e03a6f0c 100644 --- a/src/ORM/FieldType/DBDatetime.php +++ b/src/ORM/FieldType/DBDatetime.php @@ -7,6 +7,7 @@ use SilverStripe\Forms\DatetimeField; use SilverStripe\i18n\i18n; use SilverStripe\ORM\DB; use SilverStripe\Security\Member; +use SilverStripe\Security\Security; use SilverStripe\View\TemplateGlobalProvider; use Exception; use InvalidArgumentException; @@ -97,7 +98,7 @@ class DBDatetime extends DBDate implements TemplateGlobalProvider public function FormatFromSettings($member = null) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } // Fall back to nice diff --git a/src/ORM/FieldType/DBTime.php b/src/ORM/FieldType/DBTime.php index b0e596956..a01fa4f98 100644 --- a/src/ORM/FieldType/DBTime.php +++ b/src/ORM/FieldType/DBTime.php @@ -8,6 +8,7 @@ use SilverStripe\Forms\TimeField; use SilverStripe\i18n\i18n; use SilverStripe\ORM\DB; use SilverStripe\Security\Member; +use SilverStripe\Security\Security; /** * Represents a column in the database with the type 'Time'. @@ -153,7 +154,7 @@ class DBTime extends DBField public function FormatFromSettings($member = null) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } // Fall back to nice diff --git a/src/Security/AuthenticationRequestFilter.php b/src/Security/AuthenticationRequestFilter.php index f66bc081f..ec44fa281 100644 --- a/src/Security/AuthenticationRequestFilter.php +++ b/src/Security/AuthenticationRequestFilter.php @@ -2,6 +2,8 @@ namespace SilverStripe\Security; +use Exception; +use SilverStripe\Control\HTTPResponse_Exception; use SilverStripe\Control\RequestFilter; use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPResponse; @@ -15,13 +17,16 @@ class AuthenticationRequestFilter implements RequestFilter, IdentityStore use Configurable; + /** + * @return array|IdentityStore[] + */ protected function getHandlers() { return array_map( function ($identifier) { return Injector::inst()->get($identifier); }, - $this->config()->get('handlers') + static::config()->get('handlers') ); } @@ -31,6 +36,7 @@ class AuthenticationRequestFilter implements RequestFilter, IdentityStore public function preRequest(HTTPRequest $request, Session $session, DataModel $model) { try { + /** @var AuthenticationHandler $handler */ foreach ($this->getHandlers() as $handler) { // @todo Update requestfilter logic to allow modification of initial response // in order to add cookies, etc @@ -41,7 +47,7 @@ class AuthenticationRequestFilter implements RequestFilter, IdentityStore break; } } - } catch (ValidationException $e) { + } catch (Exception $e) { // There's no valid exception currently. I would say AuthenticationException? throw new HTTPResponse_Exception( "Bad log-in details: " . $e->getMessage(), 400 diff --git a/src/Security/Authenticator.php b/src/Security/Authenticator.php index 884893624..cc929108b 100644 --- a/src/Security/Authenticator.php +++ b/src/Security/Authenticator.php @@ -39,16 +39,26 @@ interface Authenticator /** * Return RequestHandler to manage the log-in process. * - * The default URL of the RequetHandler should return the initial log-in form, any other + * The default URL of the RequestHandler should return the initial log-in form, any other * URL may be added for other steps & processing. * * URL-handling methods may return an array [ "Form" => (form-object) ] which can then * be merged into a default controller. * - * @param string $link The base link to use for this RequestHnadler + * @param string $link The base link to use for this RequestHandler */ public function getLoginHandler($link); + /** + * Return the RequestHandler to manage the log-out process. + * + * The default URL of the RequestHandler should log the user out immediately and destroy the session. + * + * @param string $link The base link to use for this RequestHandler + * @return mixed + */ + public function getLogOutHandler($link); + /** * Return RequestHandler to manage the change-password process. * @@ -62,6 +72,7 @@ interface Authenticator */ public function getChangePasswordHandler($link); + /** * @todo */ @@ -81,13 +92,4 @@ interface Authenticator * @return array */ // public function getAuthenticateFields(); - - /** - * Log the member out of this Authentication method. - * - * @param Member $member by reference, to allow for multiple actions on the member with a single write - * @return boolean|Member if logout was unsuccessfull, return true, otherwise, the member is returned - */ - public function doLogOut(&$member); - } diff --git a/src/Security/BasicAuth.php b/src/Security/BasicAuth.php index 82fbab687..d4235c4dd 100644 --- a/src/Security/BasicAuth.php +++ b/src/Security/BasicAuth.php @@ -2,11 +2,14 @@ namespace SilverStripe\Security; +use SilverStripe\Control\Controller; use SilverStripe\Control\Director; +use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\HTTPResponse_Exception; use SilverStripe\Core\Config\Config; use SilverStripe\Core\Config\Configurable; +use SilverStripe\Dev\Debug; use SilverStripe\Dev\SapphireTest; use SilverStripe\Core\Injector\Injector; @@ -51,15 +54,16 @@ class BasicAuth * * Used by {@link Controller::init()}. * - * @throws HTTPResponse_Exception * + * @param HTTPRequest $request * @param string $realm * @param string|array $permissionCode Optional * @param boolean $tryUsingSessionLogin If true, then the method with authenticate against the * session log-in if those credentials are disabled. - * @return Member|bool $member + * @return bool|Member + * @throws HTTPResponse_Exception */ - public static function requireLogin($realm, $permissionCode = null, $tryUsingSessionLogin = true) + public static function requireLogin(HTTPRequest $request, $realm, $permissionCode = null, $tryUsingSessionLogin = true) { $isRunningTests = (class_exists('SilverStripe\\Dev\\SapphireTest', false) && SapphireTest::is_running_test()); if (!Security::database_is_ready() || (Director::is_cli() && !$isRunningTests)) { @@ -74,28 +78,32 @@ class BasicAuth * The follow rewrite rule must be in the sites .htaccess file to enable this workaround * RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] */ - $authHeader = (isset($_SERVER['HTTP_AUTHORIZATION']) ? $_SERVER['HTTP_AUTHORIZATION'] : - (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION']) ? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] : null)); + $authHeader = $request->getHeader('Authorization'); $matches = array(); if ($authHeader && preg_match('/Basic\s+(.*)$/i', $authHeader, $matches)) { list($name, $password) = explode(':', base64_decode($matches[1])); - $_SERVER['PHP_AUTH_USER'] = strip_tags($name); - $_SERVER['PHP_AUTH_PW'] = strip_tags($password); + $request->addHeader('PHP_AUTH_USER', strip_tags($name)); + $request->addHeader('PHP_AUTH_PW', strip_tags($password)); } $member = null; - if (isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) { + + if ($request->getHeader('PHP_AUTH_USER') && $request->getHeader('PHP_AUTH_PW')) { /** @var Authenticator $authenticator */ $authenticator = Injector::inst()->get(Authenticator::class); $member = $authenticator->authenticate([ - 'Email' => $_SERVER['PHP_AUTH_USER'], - 'Password' => $_SERVER['PHP_AUTH_PW'], + 'Email' => $request->getHeader('PHP_AUTH_USER'), + 'Password' => $request->getHeader('PHP_AUTH_PW'), ], $dummy); } + if($member) { + Security::setCurrentUser($member); + } + if (!$member && $tryUsingSessionLogin) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } // If we've failed the authentication mechanism, then show the login form @@ -103,7 +111,7 @@ class BasicAuth $response = new HTTPResponse(null, 401); $response->addHeader('WWW-Authenticate', "Basic realm=\"$realm\""); - if (isset($_SERVER['PHP_AUTH_USER'])) { + if ($request->getHeader('PHP_AUTH_USER')) { $response->setBody(_t('SilverStripe\\Security\\BasicAuth.ERRORNOTREC', "That username / password isn't recognised")); } else { $response->setBody(_t('SilverStripe\\Security\\BasicAuth.ENTERINFO', "Please enter a username and password.")); @@ -119,7 +127,7 @@ class BasicAuth $response = new HTTPResponse(null, 401); $response->addHeader('WWW-Authenticate', "Basic realm=\"$realm\""); - if (isset($_SERVER['PHP_AUTH_USER'])) { + if ($request->getHeader('PHP_AUTH_USER')) { $response->setBody(_t('SilverStripe\\Security\\BasicAuth.ERRORNOTADMIN', "That user is not an administrator.")); } @@ -152,9 +160,9 @@ class BasicAuth */ public static function protect_entire_site($protect = true, $code = 'ADMIN', $message = null) { - Config::inst()->update(self::class, 'entire_site_protected', $protect); - Config::inst()->update(self::class, 'entire_site_protected_code', $code); - Config::inst()->update(self::class, 'entire_site_protected_message', $message); + Config::modify()->set(self::class, 'entire_site_protected', $protect); + Config::modify()->set(self::class, 'entire_site_protected_code', $code); + Config::modify()->set(self::class, 'entire_site_protected_message', $message); } /** @@ -167,8 +175,14 @@ class BasicAuth public static function protect_site_if_necessary() { $config = Config::forClass(BasicAuth::class); - if ($config->entire_site_protected) { - self::requireLogin($config->entire_site_protected_message, $config->entire_site_protected_code, false); + $request = Controller::curr()->getRequest(); + if ($config->get('entire_site_protected')) { + /** @noinspection ExceptionsAnnotatingAndHandlingInspection */ + static::requireLogin( + $request, + $config->get('entire_site_protected_message'), + $config->get('entire_site_protected_code'), + false); } } } diff --git a/src/Security/Group.php b/src/Security/Group.php index 204f1a077..6970a1249 100755 --- a/src/Security/Group.php +++ b/src/Security/Group.php @@ -476,7 +476,7 @@ class Group extends DataObject public function canEdit($member = null) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } // extended access checks @@ -512,7 +512,7 @@ class Group extends DataObject public function canView($member = null) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } // extended access checks @@ -534,7 +534,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/IdentityStore.php b/src/Security/IdentityStore.php index ab0f37813..f39688106 100644 --- a/src/Security/IdentityStore.php +++ b/src/Security/IdentityStore.php @@ -15,18 +15,18 @@ interface IdentityStore /** * Log the given member into this identity store. * - * @param $member The member to log in. - * @param $persistent boolean If set to true, the login may persist beyond the current session. - * @param $request The request of the visitor that is logging in, to get, for example, cookies. - * @param $response The response object to modify, if needed. + * @param Member $member The member to log in. + * @param Boolean $persistent boolean If set to true, the login may persist beyond the current session. + * @param HTTPRequest $request The request of the visitor that is logging in, to get, for example, cookies. + * @param HTTPResponse $response The response object to modify, if needed. */ public function logIn(Member $member, $persistent, HTTPRequest $request); /** * Log any logged-in member out of this identity store. * - * @param $request The request of the visitor that is logging out, to get, for example, cookies. - * @param $response The response object to modify, if needed. + * @param HTTPRequest $request The request of the visitor that is logging out, to get, for example, cookies. + * @param HTTPResponse $response The response object to modify, if needed. */ public function logOut(HTTPRequest $request); } diff --git a/src/Security/InheritedPermissions.php b/src/Security/InheritedPermissions.php index b22e45557..b10014fa5 100644 --- a/src/Security/InheritedPermissions.php +++ b/src/Security/InheritedPermissions.php @@ -158,13 +158,13 @@ class InheritedPermissions implements PermissionChecker { switch ($permission) { case self::EDIT: - $this->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/Member.php b/src/Security/Member.php index 6f672ea54..6aa638a23 100644 --- a/src/Security/Member.php +++ b/src/Security/Member.php @@ -57,7 +57,7 @@ use DateTime; * @property string $DateFormat * @property string $TimeFormat */ -class Member extends DataObject implements TemplateGlobalProvider +class Member extends DataObject { private static $db = array( @@ -717,6 +717,8 @@ class Member extends DataObject implements TemplateGlobalProvider /** * Returns the current logged in user * + * @deprecated use Security::getCurrentUser() + * * @return Member */ public static function currentUser() @@ -759,6 +761,8 @@ class Member extends DataObject implements TemplateGlobalProvider /** * Get the ID of the current logged in user * + * @deprecated use Security::getCurrentUser() + * * @return int Returns the ID of the current logged in user or 0. */ public static function currentUserID() @@ -1077,7 +1081,7 @@ class Member extends DataObject implements TemplateGlobalProvider 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'); @@ -1491,7 +1495,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); @@ -1522,7 +1526,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); @@ -1556,7 +1560,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); @@ -1686,12 +1690,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/Authenticator.php b/src/Security/MemberAuthenticator/Authenticator.php index 4e9c836e1..745396ac2 100644 --- a/src/Security/MemberAuthenticator/Authenticator.php +++ b/src/Security/MemberAuthenticator/Authenticator.php @@ -3,12 +3,10 @@ namespace SilverStripe\Security\MemberAuthenticator; use SilverStripe\Control\Controller; -use SilverStripe\Control\Cookie; use SilverStripe\Control\Session; use SilverStripe\ORM\ValidationResult; use InvalidArgumentException; use SilverStripe\Security\Authenticator as BaseAuthenticator; -use SilverStripe\Security\RememberLoginHash; use SilverStripe\Security\Security; use SilverStripe\Security\Member; use SilverStripe\Security\LoginAttempt; @@ -186,38 +184,10 @@ class Authenticator implements BaseAuthenticator } /** - * - * @param Member $member - * @return bool|Member + * @inherit */ - public function doLogOut(&$member) + public function getLogoutHandler($link) { - if($member instanceof Member) { - Session::clear("loggedInAs"); - if (Member::config()->login_marker_cookie) { - Cookie::set(Member::config()->login_marker_cookie, null, 0); - } - - Session::destroy(); - - // Clears any potential previous hashes for this member - RememberLoginHash::clear($member, 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'); - - // Log out unsuccessful. Useful for 3rd-party logins that return failure. Shouldn't happen - // on the default authenticator though. - if(Member::currentUserID()) { - return Member::currentUser(); - } - } - return true; + return LogoutHandler::create($link, $this); } } diff --git a/src/Security/MemberAuthenticator/CMSAuthenticator.php b/src/Security/MemberAuthenticator/CMSAuthenticator.php index 43946398f..5d3faf217 100644 --- a/src/Security/MemberAuthenticator/CMSAuthenticator.php +++ b/src/Security/MemberAuthenticator/CMSAuthenticator.php @@ -37,5 +37,4 @@ class CMSAuthenticator extends Authenticator { return CMSLoginHandler::create($link, $this); } - -} \ No newline at end of file +} diff --git a/src/Security/MemberAuthenticator/CMSLoginHandler.php b/src/Security/MemberAuthenticator/CMSLoginHandler.php index 7cf751d52..cb27878a1 100644 --- a/src/Security/MemberAuthenticator/CMSLoginHandler.php +++ b/src/Security/MemberAuthenticator/CMSLoginHandler.php @@ -82,7 +82,7 @@ PHP 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/ChangePasswordForm.php b/src/Security/MemberAuthenticator/ChangePasswordForm.php index 740f7f51d..c0eb829de 100644 --- a/src/Security/MemberAuthenticator/ChangePasswordForm.php +++ b/src/Security/MemberAuthenticator/ChangePasswordForm.php @@ -10,7 +10,7 @@ use SilverStripe\Forms\PasswordField; use SilverStripe\Forms\FormAction; use SilverStripe\Forms\HiddenField; use SilverStripe\Forms\Form; -use SilverStripe\Security\Member; +use SilverStripe\Security\Security; /** * Standard Change Password Form @@ -36,7 +36,7 @@ class ChangePasswordForm extends Form // 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()) { + if (Security::getCurrentUser()) { $fields->push(new PasswordField("OldPassword", _t('SilverStripe\\Security\\Member.YOUROLDPASSWORD', "Your old password"))); } diff --git a/src/Security/MemberAuthenticator/ChangePasswordHandler.php b/src/Security/MemberAuthenticator/ChangePasswordHandler.php index 244740004..2f87eb74d 100644 --- a/src/Security/MemberAuthenticator/ChangePasswordHandler.php +++ b/src/Security/MemberAuthenticator/ChangePasswordHandler.php @@ -21,7 +21,7 @@ class ChangePasswordHandler extends FormRequestHandler */ public function doChangePassword(array $data, $form) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); // The user was logged in, check the current password if ($member && ( empty($data['OldPassword']) || diff --git a/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php b/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php index 2090f82ff..9fd2059d9 100644 --- a/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php +++ b/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php @@ -2,10 +2,8 @@ namespace SilverStripe\Security\MemberAuthenticator; -use SilverStripe\ORM\DataObject; use SilverStripe\Security\Member; use SilverStripe\Control\HTTPRequest; -use SilverStripe\Control\HTTPResponse; use SilverStripe\Security\AuthenticationHandler as AuthenticationHandlerInterface; use SilverStripe\Security\IdentityStore; use SilverStripe\Security\RememberLoginHash; @@ -19,8 +17,19 @@ use SilverStripe\Control\Cookie; class CookieAuthenticationHandler implements AuthenticationHandlerInterface, IdentityStore { + /** + * @var string + */ private $deviceCookieName; + + /** + * @var string + */ private $tokenCookieName; + + /** + * @var IdentityStore + */ private $cascadeLogInTo; /** @@ -36,7 +45,7 @@ class CookieAuthenticationHandler implements AuthenticationHandlerInterface, Ide /** * Set the name of the cookie used to track this device * - * @param string $cookieName + * @param $deviceCookieName * @return null */ public function setDeviceCookieName($deviceCookieName) @@ -57,7 +66,7 @@ class CookieAuthenticationHandler implements AuthenticationHandlerInterface, Ide /** * Set the name of the cookie used to store an login token * - * @param string $cookieName + * @param $tokenCookieName * @return null */ public function setTokenCookieName($tokenCookieName) @@ -213,11 +222,17 @@ class CookieAuthenticationHandler implements AuthenticationHandlerInterface, Ide */ public function logOut(HTTPRequest $request) { + $member = Security::getCurrentUser(); + if ($member) { + RememberLoginHash::clear($member, Cookie::get('alc_device')); + } // @todo couple the cookies to the response object Cookie::set($this->getTokenCookieName(), null); Cookie::set($this->getDeviceCookieName(), null); Cookie::force_expiry($this->getTokenCookieName()); Cookie::force_expiry($this->getDeviceCookieName()); + + Security::setCurrentUser(null); } } diff --git a/src/Security/MemberAuthenticator/LoginForm.php b/src/Security/MemberAuthenticator/LoginForm.php index e59bc9d98..c32298c67 100644 --- a/src/Security/MemberAuthenticator/LoginForm.php +++ b/src/Security/MemberAuthenticator/LoginForm.php @@ -87,7 +87,9 @@ class LoginForm extends BaseLoginForm $backURL = Session::get('BackURL'); } - if ($checkCurrentUser && Member::currentUser() && Member::logged_in_session_exists()) { + if ($checkCurrentUser && Security::getCurrentUser() && Member::logged_in_session_exists()) { + // @todo find a more elegant way to handle this + $logoutAction = Security::logout_url(); $fields = FieldList::create( HiddenField::create("AuthenticationMethod", null, $this->authenticator_class, $this) ); @@ -112,6 +114,9 @@ class LoginForm extends BaseLoginForm parent::__construct($controller, $name, $fields, $actions); + if (isset($logoutAction)) { + $this->setFormAction($logoutAction); + } $this->setValidator(RequiredFields::create(self::config()->get('required_fields'))); } @@ -182,7 +187,7 @@ class LoginForm extends BaseLoginForm 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}.", diff --git a/src/Security/MemberAuthenticator/LoginHandler.php b/src/Security/MemberAuthenticator/LoginHandler.php index d0c91e681..4da92efaa 100644 --- a/src/Security/MemberAuthenticator/LoginHandler.php +++ b/src/Security/MemberAuthenticator/LoginHandler.php @@ -3,6 +3,7 @@ namespace SilverStripe\Security\MemberAuthenticator; use SilverStripe\Control\Controller; +use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\Session; use SilverStripe\Control\RequestHandler; @@ -17,6 +18,9 @@ use SilverStripe\Security\IdentityStore; */ class LoginHandler extends RequestHandler { + /** + * @var Authenticator + */ protected $authenticator; private static $url_handlers = [ @@ -33,11 +37,10 @@ class LoginHandler extends RequestHandler private static $allowed_actions = [ 'login', 'LoginForm', - 'dologin', 'logout', ]; - private $link = null; + private $link; /** * @param string $link The URL to recreate this request handler @@ -53,15 +56,16 @@ class LoginHandler extends RequestHandler /** * 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); - } else { - return $this->link; } + + return $this->link; } /** @@ -89,7 +93,7 @@ class LoginHandler extends RequestHandler /** * Login form handler method * - * This method is called when the user clicks on "Log in" + * This method is called when the user finishes the login flow * * @param array $data Submitted data * @param LoginForm $form @@ -102,6 +106,7 @@ class LoginHandler extends RequestHandler // Successful login if ($member = $this->checkLogin($data, $failureMessage)) { $this->performLogin($member, $data, $form->getRequestHandler()->getRequest()); + return $this->redirectAfterSuccessfulLogin(); } @@ -119,7 +124,6 @@ class LoginHandler extends RequestHandler return $form->getRequestHandler()->redirectBackToForm(); } - public function getReturnReferer() { return $this->link(); @@ -137,7 +141,6 @@ class LoginHandler extends RequestHandler * [Optional: 'Remember' => 1 ] * ) * - * @param array $data * @return HTTPResponse */ protected function redirectAfterSuccessfulLogin() @@ -145,7 +148,7 @@ class LoginHandler extends RequestHandler Session::clear('SessionForms.MemberLoginForm.Email'); Session::clear('SessionForms.MemberLoginForm.Remember'); - $member = Member::currentUser(); + $member = Security::getCurrentUser(); if ($member->isPasswordExpired()) { return $this->redirectToChangePassword(); } @@ -167,7 +170,7 @@ class LoginHandler extends RequestHandler // Welcome message $message = _t( 'SilverStripe\\Security\\Member.WELCOMEBACK', - "Welcome Back, {firstname}", + 'Welcome Back, {firstname}', ['firstname' => $member->FirstName] ); Security::setLoginMessage($message, ValidationResult::TYPE_GOOD); @@ -177,26 +180,11 @@ class LoginHandler extends RequestHandler 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() - { - Security::singleton()->logout(); - return $this->redirectBack(); - } - /** * Try to authenticate the user * * @param array $data Submitted data + * @param string $message * @return Member Returns the member object on successful authentication * or NULL on failure. */ @@ -206,19 +194,19 @@ class LoginHandler extends RequestHandler $member = $this->authenticator->authenticate($data, $message); if ($member) { return $member; - - } else { - // No member, can't login - $this->extend('authenticationFailed', $data); - return null; - } + // No member, can't login + $this->extend('authenticationFailed', $data); + + 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. */ @@ -226,8 +214,10 @@ class LoginHandler extends RequestHandler { // @todo pass request/response Injector::inst()->get(IdentityStore::class)->logIn($member, !empty($data['Remember']), $request); + return $member; } + /** * Invoked if password is expired and must be changed * @@ -242,13 +232,15 @@ class LoginHandler extends RequestHandler 'good' ); $changedPasswordLink = Security::singleton()->Link('changepassword'); + return $this->redirect($this->addBackURLParam($changedPasswordLink)); } - /** * @todo copypaste from FormRequestHandler - refactor + * @param string $link + * @return string */ protected function addBackURLParam($link) { @@ -256,6 +248,7 @@ class LoginHandler extends RequestHandler if ($backURL) { return Controller::join_links($link, '?BackURL=' . urlencode($backURL)); } + return $link; } } diff --git a/src/Security/MemberAuthenticator/LogoutHandler.php b/src/Security/MemberAuthenticator/LogoutHandler.php new file mode 100644 index 000000000..db4b3771e --- /dev/null +++ b/src/Security/MemberAuthenticator/LogoutHandler.php @@ -0,0 +1,68 @@ + '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/SessionAuthenticationHandler.php b/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php index 18f1521fe..1408df9ba 100644 --- a/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php +++ b/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php @@ -2,6 +2,7 @@ namespace SilverStripe\Security\MemberAuthenticator; +use SilverStripe\Control\Cookie; use SilverStripe\ORM\DataObject; use SilverStripe\Security\Member; use SilverStripe\Control\HTTPRequest; @@ -52,7 +53,7 @@ class SessionAuthenticationHandler implements AuthenticationHandlerInterface, Id if ($id = Session::get($this->getSessionVariable())) { // If ID is a bad ID it will be treated as if the user is not logged in, rather than throwing a // ValidationException - return DataObject::get_by_id(Member::class, $id); + return Member::get()->byID($id); } return null; @@ -66,13 +67,13 @@ class SessionAuthenticationHandler implements AuthenticationHandlerInterface, Id // @todo couple the session to a request object // $session = $request->getSession(); - $this->regenerateSessionId(); + static::regenerateSessionId(); Session::set($this->getSessionVariable(), $member->ID); // This lets apache rules detect whether the user has logged in // @todo make this a settign on the authentication handler - if (Member::config()->login_marker_cookie) { - Cookie::set(Member::config()->login_marker_cookie, 1, 0); + if (Member::config()->get('login_marker_cookie')) { + Cookie::set(Member::config()->get('login_marker_cookie'), 1, 0); } } diff --git a/src/Security/Permission.php b/src/Security/Permission.php index 8d5a663d9..cb71526c2 100644 --- a/src/Security/Permission.php +++ b/src/Security/Permission.php @@ -347,7 +347,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]; } diff --git a/src/Security/Security.php b/src/Security/Security.php index a2fda442e..2c995e537 100644 --- a/src/Security/Security.php +++ b/src/Security/Security.php @@ -22,11 +22,13 @@ use SilverStripe\Forms\FieldList; use SilverStripe\Forms\Form; use SilverStripe\Forms\FormAction; use SilverStripe\ORM\ArrayList; +use SilverStripe\ORM\Connect\Database; use SilverStripe\ORM\DataModel; use SilverStripe\ORM\DB; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\FieldType\DBField; use SilverStripe\ORM\ValidationResult; +use SilverStripe\Security\MemberAuthenticator\LogoutHandler; use SilverStripe\View\ArrayData; use SilverStripe\View\SSViewer; use SilverStripe\View\TemplateGlobalProvider; @@ -230,6 +232,11 @@ class Security extends Controller implements TemplateGlobalProvider */ protected static $default_authenticator = MemberAuthenticator\Authenticator::class; + /** + * @var Member Currently logged in user (if available) + */ + private static $currentUser; + /** * @inheritdoc */ @@ -262,7 +269,7 @@ class Security extends Controller implements TemplateGlobalProvider * Get the selected authenticator for this request * * @param $name string The identifier of the authenticator in your config - * @return string Class name of Authenticator + * @return Authenticator Class name of Authenticator * @throws LogicException */ protected function getAuthenticator($name) @@ -287,15 +294,16 @@ class Security extends Controller implements TemplateGlobalProvider { $authenticators = self::config()->get('authenticators'); - foreach($authenticators as $name => &$class) { + foreach ($authenticators as $name => &$class) { /** @var Authenticator $authenticator */ $authenticator = Injector::inst()->get($class); - if($authenticator->supportedServices() & $service) { + if ($authenticator->supportedServices() & $service) { $class = $authenticator; } else { unset($authenticators[$name]); } } + return $authenticators; } @@ -348,7 +356,7 @@ class Security extends Controller implements TemplateGlobalProvider if (Director::is_ajax()) { $response = ($controller) ? $controller->getResponse() : new HTTPResponse(); $response->setStatusCode(403); - if (!Member::currentUser()) { + 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-aunthentication @@ -384,7 +392,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()) { @@ -428,8 +436,6 @@ class Security extends Controller implements TemplateGlobalProvider )); } - private static $currentUser; - public static function setCurrentUser($currentUser) { self::$currentUser = $currentUser; @@ -498,36 +504,22 @@ class Security extends Controller implements TemplateGlobalProvider public function logout($redirect = true) { $this->extend('beforeMemberLoggedOut'); - $request = $this->getRequest(); - $member = Member::currentUser(); - // Reasoning for (now) to not go with a full LoginHandler call, is to not make it circular - // re-sending the request forward to the authenticator. In the case of logout, I think it would be - // overkill. - if (($name = $request->param('ID')) && self::hasAuthenticator($request->param('ID'))){ + $member = static::getCurrentUser(); + + if ($member) { // If we don't have a member, there's not much to log out. /** @var Authenticator $authenticator */ - $authenticator = $this->getAuthenticator($request->param('ID')); - if($authenticator->doLogOut($member) !== true) { + $authenticator = $this->getAuthenticator('default'); // Always use the default authenticator to log out + $handler = $authenticator->getLogOutHandler(Controller::join_links($this->Link(), 'logout')); + $result = $this->delegateToHandler($handler, 'default', []); + if ($result !== true) { $this->extend('failureMemberLoggedOut', $authenticator); + return $this->redirectBack(); } $this->extend('successMemberLoggedOut', $authenticator); - } else { - $authenticators = static::getAuthenticators(Authenticator::LOGOUT); - /** - * @var string $name - * @var Authenticator $authenticator - */ - foreach ($authenticators as $name => $authenticator) { - if ($authenticator->logOut($member) !== true) { - $this->extend('failureMemberLoggedOut', $authenticator); - // Break on first log out failure(?) - return $this->redirectBack(); - } - $this->extend('successMemberLoggedOut', $authenticator); - } + // Member is successfully logged out. Write possible changes to the database. + $member->write(); } - // Member is successfully logged out. Write possible changes to the database. - $member->write(); $this->extend('afterMemberLoggedOut'); if ($redirect && (!$this->getResponse()->isFinished())) { @@ -568,7 +560,7 @@ class Security extends Controller implements TemplateGlobalProvider // where the user has permissions to continue but is not given the option. if ($this->getRequest()->requestVar('BackURL') && !$this->getLoginMessage() - && ($member = Member::currentUser()) + && ($member = static::getCurrentUser()) && $member->exists() ) { return $this->redirectBack(); @@ -611,7 +603,7 @@ class Security extends Controller implements TemplateGlobalProvider /** * Combine the given forms into a formset with a tabbed interface * - * @param array $authenticators List of Authenticator instances + * @param $forms * @return string */ protected function generateLoginFormSet($forms) @@ -772,7 +764,6 @@ class Security extends Controller implements TemplateGlobalProvider ], $templates ); - } /** @@ -833,8 +824,8 @@ class Security extends Controller implements TemplateGlobalProvider public function basicauthlogin() { - $member = BasicAuth::requireLogin("SilverStripe login", 'ADMIN'); - $member->logIn(); + $member = BasicAuth::requireLogin($this->getRequest(), "SilverStripe login", 'ADMIN'); + static::setCurrentUser($member); } /** @@ -888,8 +879,10 @@ class Security extends Controller implements TemplateGlobalProvider // On first valid password reset request redirect to the same URL without hash to avoid referrer leakage. // if there is a current member, they should be logged out - if ($curMember = Member::currentUser()) { - $curMember->logOut(); + if ($curMember = static::getCurrentUser()) { + /** @var LogoutHandler $handler */ + $handler = $this->getAuthenticator('default')->getLogoutHandler($this->Link('logout')); + $handler->doLogOut($curMember); } // Store the hash for the change password form. Will be unset after reload within the ChangePasswordForm. @@ -905,7 +898,7 @@ class Security extends Controller implements TemplateGlobalProvider ), 'Form' => $this->ChangePasswordForm(), )); - } elseif (Member::currentUser()) { + } elseif (static::getCurrentUser()) { // Logged in user requested a password change form. $customisedController = $controller->customise(array( 'Content' => DBField::create_field( @@ -1112,6 +1105,7 @@ class Security extends Controller implements TemplateGlobalProvider /** * Check that the default admin account has been set. + * @todo Check if we _actually_ only want this to work on dev */ public static function has_default_admin() { @@ -1354,6 +1348,8 @@ class Security extends Controller implements TemplateGlobalProvider "LoginURL" => "login_url", "LogoutURL" => "logout_url", "LostPasswordURL" => "lost_password_url", + "CurrentMember" => "getCurrentUser", + "currentUser" => "getCurrentUser" ); } } diff --git a/tests/php/Control/ControllerTest.php b/tests/php/Control/ControllerTest.php index dd5000725..43c911d24 100644 --- a/tests/php/Control/ControllerTest.php +++ b/tests/php/Control/ControllerTest.php @@ -23,6 +23,7 @@ use SilverStripe\Dev\Deprecation; use SilverStripe\Dev\FunctionalTest; use SilverStripe\ORM\DataModel; use SilverStripe\Security\Member; +use SilverStripe\Security\Security; use SilverStripe\View\SSViewer; class ControllerTest extends FunctionalTest @@ -203,7 +204,7 @@ class ControllerTest extends FunctionalTest 'if action is not a method but rather a template discovered by naming convention' ); - $this->session()->inst_set('loggedInAs', $adminUser->ID); + Security::setCurrentUser($adminUser); $response = $this->get("AccessSecuredController/templateaction"); $this->assertEquals( 200, @@ -211,8 +212,8 @@ class ControllerTest extends FunctionalTest 'Access granted for logged in admin on action with $allowed_actions on defining controller, ' . 'if action is not a method but rather a template discovered by naming convention' ); - $this->session()->inst_set('loggedInAs', null); + Security::setCurrentUser(null); $response = $this->get("AccessSecuredController/adminonly"); $this->assertEquals( 403, @@ -236,15 +237,15 @@ class ControllerTest extends FunctionalTest "Access denied to protected method even if its listed in allowed_actions" ); - $this->session()->inst_set('loggedInAs', $adminUser->ID); + Security::setCurrentUser($adminUser); $response = $this->get("AccessSecuredController/adminonly"); $this->assertEquals( 200, $response->getStatusCode(), "Permission codes are respected when set in \$allowed_actions" ); - $this->session()->inst_set('loggedInAs', null); + Security::setCurrentUser(null); $response = $this->get('AccessBaseController/extensionmethod1'); $this->assertEquals( 200, @@ -285,7 +286,7 @@ class ControllerTest extends FunctionalTest "and doesn't satisfy checks" ); - $this->session()->inst_set('loggedInAs', $adminUser->ID); + Security::setCurrentUser($adminUser); $response = $this->get('IndexSecuredController/'); $this->assertEquals( 200, @@ -293,7 +294,7 @@ class ControllerTest extends FunctionalTest "Access granted when index action is limited through allowed_actions, " . "and does satisfy checks" ); - $this->session()->inst_set('loggedInAs', null); + Security::setCurrentUser(null); } public function testWildcardAllowedActions() diff --git a/tests/php/Forms/GridField/GridFieldDeleteActionTest.php b/tests/php/Forms/GridField/GridFieldDeleteActionTest.php index b825c00a1..5456f32c0 100644 --- a/tests/php/Forms/GridField/GridFieldDeleteActionTest.php +++ b/tests/php/Forms/GridField/GridFieldDeleteActionTest.php @@ -10,11 +10,10 @@ use SilverStripe\Forms\Tests\GridField\GridFieldTest\Team; use SilverStripe\ORM\ArrayList; use SilverStripe\ORM\DataList; use SilverStripe\ORM\ValidationException; -use SilverStripe\Security\Member; +use SilverStripe\Security\Security; use SilverStripe\Security\SecurityToken; use SilverStripe\Dev\CSSContentParser; use SilverStripe\Dev\SapphireTest; -use SilverStripe\Control\Controller; use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\Session; use SilverStripe\Forms\FieldList; @@ -67,8 +66,8 @@ class GridFieldDeleteActionTest extends SapphireTest public function testDontShowDeleteButtons() { - if (Member::currentUser()) { - Member::currentUser()->logOut(); + if (Security::getCurrentUser()) { + Security::setCurrentUser(null); } $content = new CSSContentParser($this->gridField->FieldHolder()); // Check that there are content @@ -116,8 +115,8 @@ class GridFieldDeleteActionTest extends SapphireTest public function testDeleteActionWithoutCorrectPermission() { - if (Member::currentUser()) { - Member::currentUser()->logOut(); + if (Security::getCurrentUser()) { + Security::setCurrentUser(null); } $this->setExpectedException(ValidationException::class); diff --git a/tests/php/Forms/GridField/GridFieldEditButtonTest.php b/tests/php/Forms/GridField/GridFieldEditButtonTest.php index bb7875b94..03166cf7b 100644 --- a/tests/php/Forms/GridField/GridFieldEditButtonTest.php +++ b/tests/php/Forms/GridField/GridFieldEditButtonTest.php @@ -17,6 +17,7 @@ use SilverStripe\Forms\Form; use SilverStripe\Forms\GridField\GridFieldConfig; use SilverStripe\Forms\GridField\GridFieldEditButton; use SilverStripe\Forms\GridField\GridField; +use SilverStripe\Security\Security; class GridFieldEditButtonTest extends SapphireTest { @@ -62,8 +63,8 @@ class GridFieldEditButtonTest extends SapphireTest public function testShowEditLinks() { - if (Member::currentUser()) { - Member::currentUser()->logOut(); + if (Security::getCurrentUser()) { + Security::getCurrentUser()->logOut(); } $content = new CSSContentParser($this->gridField->FieldHolder()); diff --git a/tests/php/Security/BasicAuthTest.php b/tests/php/Security/BasicAuthTest.php index 5fca445c1..1092b7fc5 100644 --- a/tests/php/Security/BasicAuthTest.php +++ b/tests/php/Security/BasicAuthTest.php @@ -15,7 +15,7 @@ use SilverStripe\Security\Tests\BasicAuthTest\ControllerSecuredWithPermission; class BasicAuthTest extends FunctionalTest { - static $original_unique_identifier_field; + protected static $original_unique_identifier_field; protected static $fixture_file = 'BasicAuthTest.yml'; @@ -42,7 +42,7 @@ class BasicAuthTest extends FunctionalTest unset($_SERVER['PHP_AUTH_USER']); unset($_SERVER['PHP_AUTH_PW']); - $response = Director::test('BasicAuthTest_ControllerSecuredWithPermission'); + $response = Director::test('BasicAuthTest_ControllerSecuredWithPermission', null, $_SESSION, null, null, $_SERVER); $this->assertEquals(401, $response->getStatusCode()); $_SERVER['PHP_AUTH_USER'] = $origUser; @@ -56,13 +56,13 @@ class BasicAuthTest extends FunctionalTest unset($_SERVER['PHP_AUTH_USER']); unset($_SERVER['PHP_AUTH_PW']); - $response = Director::test('BasicAuthTest_ControllerSecuredWithPermission'); + $response = Director::test('BasicAuthTest_ControllerSecuredWithPermission', null, $_SESSION, null, null, $_SERVER); $this->assertFalse(BasicAuthTest\ControllerSecuredWithPermission::$index_called); $this->assertFalse(BasicAuthTest\ControllerSecuredWithPermission::$post_init_called); $_SERVER['PHP_AUTH_USER'] = 'user-in-mygroup@test.com'; $_SERVER['PHP_AUTH_PW'] = 'test'; - $response = Director::test('BasicAuthTest_ControllerSecuredWithPermission'); + $response = Director::test('BasicAuthTest_ControllerSecuredWithPermission', null, $_SESSION, null, null, $_SERVER); $this->assertTrue(BasicAuthTest\ControllerSecuredWithPermission::$index_called); $this->assertTrue(BasicAuthTest\ControllerSecuredWithPermission::$post_init_called); @@ -77,17 +77,17 @@ class BasicAuthTest extends FunctionalTest $_SERVER['PHP_AUTH_USER'] = 'user-in-mygroup@test.com'; $_SERVER['PHP_AUTH_PW'] = 'wrongpassword'; - $response = Director::test('BasicAuthTest_ControllerSecuredWithPermission'); + $response = Director::test('BasicAuthTest_ControllerSecuredWithPermission', null, $_SESSION, null, null, $_SERVER); $this->assertEquals(401, $response->getStatusCode(), 'Invalid users dont have access'); $_SERVER['PHP_AUTH_USER'] = 'user-without-groups@test.com'; $_SERVER['PHP_AUTH_PW'] = 'test'; - $response = Director::test('BasicAuthTest_ControllerSecuredWithPermission'); + $response = Director::test('BasicAuthTest_ControllerSecuredWithPermission', null, $_SESSION, null, null, $_SERVER); $this->assertEquals(401, $response->getStatusCode(), 'Valid user without required permission has no access'); $_SERVER['PHP_AUTH_USER'] = 'user-in-mygroup@test.com'; $_SERVER['PHP_AUTH_PW'] = 'test'; - $response = Director::test('BasicAuthTest_ControllerSecuredWithPermission'); + $response = Director::test('BasicAuthTest_ControllerSecuredWithPermission', null, $_SESSION, null, null, $_SERVER);; $this->assertEquals(200, $response->getStatusCode(), 'Valid user with required permission has access'); $_SERVER['PHP_AUTH_USER'] = $origUser; @@ -101,17 +101,17 @@ class BasicAuthTest extends FunctionalTest $_SERVER['PHP_AUTH_USER'] = 'user-without-groups@test.com'; $_SERVER['PHP_AUTH_PW'] = 'wrongpassword'; - $response = Director::test('BasicAuthTest_ControllerSecuredWithoutPermission'); + $response = Director::test('BasicAuthTest_ControllerSecuredWithoutPermission', null, $_SESSION, null, null, $_SERVER); $this->assertEquals(401, $response->getStatusCode(), 'Invalid users dont have access'); $_SERVER['PHP_AUTH_USER'] = 'user-without-groups@test.com'; $_SERVER['PHP_AUTH_PW'] = 'test'; - $response = Director::test('BasicAuthTest_ControllerSecuredWithoutPermission'); + $response = Director::test('BasicAuthTest_ControllerSecuredWithoutPermission', null, $_SESSION, null, null, $_SERVER); $this->assertEquals(200, $response->getStatusCode(), 'All valid users have access'); $_SERVER['PHP_AUTH_USER'] = 'user-in-mygroup@test.com'; $_SERVER['PHP_AUTH_PW'] = 'test'; - $response = Director::test('BasicAuthTest_ControllerSecuredWithoutPermission'); + $response = Director::test('BasicAuthTest_ControllerSecuredWithoutPermission', null, $_SESSION, null, null, $_SERVER); $this->assertEquals(200, $response->getStatusCode(), 'All valid users have access'); $_SERVER['PHP_AUTH_USER'] = $origUser; @@ -127,19 +127,19 @@ class BasicAuthTest extends FunctionalTest // First failed attempt $_SERVER['PHP_AUTH_USER'] = 'failedlogin@test.com'; $_SERVER['PHP_AUTH_PW'] = 'test'; - $response = Director::test('BasicAuthTest_ControllerSecuredWithoutPermission'); + $response = Director::test('BasicAuthTest_ControllerSecuredWithoutPermission', null, $_SESSION, null, null, $_SERVER); $check = Member::get()->filter('Email', 'failedlogin@test.com')->first(); $this->assertEquals(1, $check->FailedLoginCount); // Second failed attempt $_SERVER['PHP_AUTH_PW'] = 'testwrong'; - $response = Director::test('BasicAuthTest_ControllerSecuredWithoutPermission'); + $response = Director::test('BasicAuthTest_ControllerSecuredWithoutPermission', null, $_SESSION, null, null, $_SERVER); $check = Member::get()->filter('Email', 'failedlogin@test.com')->first(); $this->assertEquals(2, $check->FailedLoginCount); // successful basic auth should reset failed login count $_SERVER['PHP_AUTH_PW'] = 'Password'; - $response = Director::test('BasicAuthTest_ControllerSecuredWithoutPermission'); + $response = Director::test('BasicAuthTest_ControllerSecuredWithoutPermission', null, $_SESSION, null, null, $_SERVER); $check = Member::get()->filter('Email', 'failedlogin@test.com')->first(); $this->assertEquals(0, $check->FailedLoginCount); } diff --git a/tests/php/Security/InheritedPermissionsTest/TestPermissionNode.php b/tests/php/Security/InheritedPermissionsTest/TestPermissionNode.php index 8a311836b..f8f321943 100644 --- a/tests/php/Security/InheritedPermissionsTest/TestPermissionNode.php +++ b/tests/php/Security/InheritedPermissionsTest/TestPermissionNode.php @@ -9,6 +9,7 @@ use SilverStripe\Security\InheritedPermissions; use SilverStripe\Security\InheritedPermissionsExtension; use SilverStripe\Security\Member; use SilverStripe\Security\PermissionChecker; +use SilverStripe\Security\Security; use SilverStripe\Versioned\Versioned; /** @@ -45,7 +46,7 @@ class TestPermissionNode extends DataObject implements TestOnly public function canEdit($member = null) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } return static::getInheritedPermissions()->canEdit($this->ID, $member); } @@ -53,7 +54,7 @@ class TestPermissionNode extends DataObject implements TestOnly public function canView($member = null) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } return static::getInheritedPermissions()->canView($this->ID, $member); } @@ -61,7 +62,7 @@ class TestPermissionNode extends DataObject implements TestOnly public function canDelete($member = null) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } return static::getInheritedPermissions()->canDelete($this->ID, $member); } diff --git a/tests/php/Security/MemberTest.php b/tests/php/Security/MemberTest.php index 836a3ab56..abbb5efc9 100644 --- a/tests/php/Security/MemberTest.php +++ b/tests/php/Security/MemberTest.php @@ -3,6 +3,7 @@ namespace SilverStripe\Security\Tests; use SilverStripe\Core\Convert; +use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\FunctionalTest; use SilverStripe\Control\Cookie; use SilverStripe\i18n\i18n; @@ -553,7 +554,6 @@ class MemberTest extends FunctionalTest $this->assertFalse($member->canDelete()); $this->assertFalse($member->canEdit()); - $this->addExtensions($extensions); $this->logOut(); } @@ -568,14 +568,11 @@ class MemberTest extends FunctionalTest $this->assertTrue($member2->canDelete()); $this->assertTrue($member2->canEdit()); - $this->addExtensions($extensions); $this->logOut(); } public function testExtendedCan() { - - $extensions = $this->removeExtensions(Object::get_extensions(Member::class)); $member = $this->objFromFixture(Member::class, 'test'); /* Normal behaviour is that you can't view a member unless canView() on an extension returns true */ @@ -1369,12 +1366,12 @@ class MemberTest extends FunctionalTest public function testCurrentUser() { - $this->assertNull(Member::currentUser()); + $this->assertNull(Security::getCurrentUser()); $adminMember = $this->objFromFixture(Member::class, 'admin'); $this->logInAs($adminMember); - $userFromSession = Member::currentUser(); + $userFromSession = Security::getCurrentUser(); $this->assertEquals($adminMember->ID, $userFromSession->ID); } @@ -1383,7 +1380,7 @@ class MemberTest extends FunctionalTest */ public function testActAsUserPermissions() { - $this->assertNull(Member::currentUser()); + $this->assertNull(Security::getCurrentUser()); /** @var Member $adminMember */ $adminMember = $this->objFromFixture(Member::class, 'admin'); @@ -1422,7 +1419,7 @@ class MemberTest extends FunctionalTest */ public function testActAsUser() { - $this->assertNull(Member::currentUser()); + $this->assertNull(Security::getCurrentUser()); /** @var Member $adminMember */ $adminMember = $this->objFromFixture(Member::class, 'admin'); diff --git a/tests/php/Security/SecurityTest.php b/tests/php/Security/SecurityTest.php index 847a58443..1b21b6c6d 100644 --- a/tests/php/Security/SecurityTest.php +++ b/tests/php/Security/SecurityTest.php @@ -182,8 +182,8 @@ class SecurityTest extends FunctionalTest public function testAutomaticRedirectionOnLogin() { // BackURL with permission error (not authenticated) should not redirect - if ($member = Member::currentUser()) { - $member->logOut(); + if ($member = Security::getCurrentUser()) { + Security::setCurrentUser(null); } $response = $this->getRecursive('SecurityTest_SecuredController'); $this->assertContains(Convert::raw2xml("That page is secured."), $response->getBody()); @@ -228,7 +228,7 @@ class SecurityTest extends FunctionalTest $member = DataObject::get_one(Member::class); /* Log in with any user that we can find */ - $this->session()->inst_set('loggedInAs', $member->ID); + Security::setCurrentUser($member); /* View the Security/login page */ $response = $this->get(Config::inst()->get(Security::class, 'login_url')); @@ -254,7 +254,7 @@ class SecurityTest extends FunctionalTest $this->assertNotNull($response->getBody(), 'There is body content on the page'); /* Log the user out */ - $this->session()->inst_set('loggedInAs', null); + Security::setCurrentUser(null); } public function testMemberIDInSessionDoesntExistInDatabaseHasToLogin() diff --git a/tests/php/View/SSViewerTest.php b/tests/php/View/SSViewerTest.php index 886df0bac..11a66f7f7 100644 --- a/tests/php/View/SSViewerTest.php +++ b/tests/php/View/SSViewerTest.php @@ -16,6 +16,7 @@ use SilverStripe\ORM\FieldType\DBField; use SilverStripe\ORM\ArrayList; use SilverStripe\ORM\PaginatedList; use SilverStripe\Security\Member; +use SilverStripe\Security\Security; use SilverStripe\Security\SecurityToken; use SilverStripe\Security\Permission; use SilverStripe\View\ArrayData; @@ -406,22 +407,22 @@ SS; ); $this->assertEquals( - (string)Member::currentUser(), + (string)Security::getCurrentUser(), $this->render('{$CurrentMember}'), 'Member template functions result correct result' ); $this->assertEquals( - (string)Member::currentUser(), + (string)Security::getCurrentUser(), $this->render('{$CurrentUser}'), 'Member template functions result correct result' ); $this->assertEquals( - (string)Member::currentUser(), + (string)Security::getCurrentUser(), $this->render('{$currentMember}'), 'Member template functions result correct result' ); $this->assertEquals( - (string)Member::currentUser(), + (string)Security::getCurrentUser(), $this->render('{$currentUser}'), 'Member template functions result correct result' ); From 082db895509f90bf935bc0cbe593fd2a939e6bac Mon Sep 17 00:00:00 2001 From: Simon Erkelens Date: Tue, 30 May 2017 19:42:00 +1200 Subject: [PATCH 6/9] Feedback from Damian. - Move the success and message to a validationresult - Fix tests for validationresult return - We need to clear the session in Test logOut method - Rename to MemberAuthenticator and CMSMemberAuthenticator for consistency. - Unify all to getCurrentUser on Security - ChangePasswordHandler removed from Security - Update SapphireTest for CMS login/logout - Get the Member ID correctly, if it's an object. - Only enable "remember me" when it's allowed. - Add flag to disable password logging - Remove Subsites coupling, give it an extension hook to disable itself - Change cascadeLogInTo to cascadeInTo for the logout method logic naming - Docblocks - Basicauth config --- _config.php | 6 - _config/security.yml | 23 +- src/Control/RequestHandler.php | 14 + src/Core/Config/Config_ForClass.php | 2 +- src/Dev/FunctionalTest.php | 13 +- src/Dev/SapphireTest.php | 3 +- src/Forms/FormRequestHandler.php | 6 +- src/Security/AuthenticationRequestFilter.php | 59 ++- src/Security/Authenticator.php | 14 +- src/Security/BasicAuth.php | 68 ++- src/Security/CMSMemberLoginForm.php | 2 +- src/Security/CMSSecurity.php | 26 +- src/Security/IdentityStore.php | 9 +- src/Security/Member.php | 233 +++++----- ...ticator.php => CMSMemberAuthenticator.php} | 15 +- .../ChangePasswordForm.php | 51 ++- .../ChangePasswordHandler.php | 227 +++++++++- .../CookieAuthenticationHandler.php | 71 ++-- .../MemberAuthenticator/LoginHandler.php | 79 ++-- .../MemberAuthenticator/LogoutHandler.php | 1 - .../LostPasswordHandler.php | 103 ++--- ...henticator.php => MemberAuthenticator.php} | 88 ++-- .../{LoginForm.php => MemberLoginForm.php} | 6 +- .../SessionAuthenticationHandler.php | 42 +- src/Security/Permission.php | 10 +- src/Security/Security.php | 400 ++++++++---------- src/View/ViewableData.php | 4 +- tests/behat/features/login.feature | 2 +- .../GridField/GridFieldEditButtonTest.php | 2 +- tests/php/Security/BasicAuthTest.php | 2 +- .../php/Security/MemberAuthenticatorTest.php | 53 ++- tests/php/Security/MemberTest.php | 25 +- tests/php/Security/SecurityTest.php | 60 ++- 33 files changed, 978 insertions(+), 741 deletions(-) rename src/Security/MemberAuthenticator/{CMSAuthenticator.php => CMSMemberAuthenticator.php} (66%) rename src/Security/MemberAuthenticator/{Authenticator.php => MemberAuthenticator.php} (65%) rename src/Security/MemberAuthenticator/{LoginForm.php => MemberLoginForm.php} (97%) 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 60f128f6b..72b8cd1a4 100644 --- a/_config/security.yml +++ b/_config/security.yml @@ -1,18 +1,16 @@ -SilverStripe\Security\MemberAuthenticator\LoginForm: +--- +Name: coresecurity +--- +SilverStripe\Security\MemberAuthenticator\MemberLoginForm: required_fields: - Email - Password -SilverStripe\Security\Security: - authenticators: - default: SilverStripe\Security\MemberAuthenticator\Authenticator - cms: SilverStripe\Security\MemberAuthenticator\CMSAuthenticator - SilverStripe\Core\Injector\Injector: SilverStripe\Control\RequestProcessor: properties: filters: - - '%$SilverStripe\Security\AuthenticationRequestFilter' + - %$SilverStripe\Security\AuthenticationRequestFilter SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler: properties: SessionVariable: loggedInAs @@ -20,14 +18,15 @@ SilverStripe\Core\Injector\Injector: properties: TokenCookieName: alc_enc DeviceCookieName: alc_device - CascadeLogInTo: %$SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler + CascadeInTo: %$SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler SilverStripe\Security\IdentityStore: class: SilverStripe\Security\AuthenticationRequestFilter - + SilverStripe\Security\Security: + properties: + authenticators: + default: %$SilverStripe\Security\MemberAuthenticator\MemberAuthenticator + cms: %$SilverStripe\Security\MemberAuthenticator\CMSMemberAuthenticator SilverStripe\Security\AuthenticationRequestFilter: handlers: session: SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler alc: SilverStripe\Security\MemberAuthenticator\CookieAuthenticationHandler - -SilverStripe\Security\MemberAuthenticator\CMSSecurity: - reauth_enabled: true diff --git a/src/Control/RequestHandler.php b/src/Control/RequestHandler.php index 43fdfef95..0a087835f 100644 --- a/src/Control/RequestHandler.php +++ b/src/Control/RequestHandler.php @@ -296,6 +296,20 @@ class RequestHandler extends ViewableData return null; } + /** + * @param string $link + * @return string + */ + protected function addBackURLParam($link) + { + $backURL = $this->getBackURL(); + if ($backURL) { + return Controller::join_links($link, '?BackURL=' . urlencode($backURL)); + } + + return $link; + } + /** * Given a request, and an action name, call that action name on this RequestHandler * diff --git a/src/Core/Config/Config_ForClass.php b/src/Core/Config/Config_ForClass.php index 448bd9443..b89e5007b 100644 --- a/src/Core/Config/Config_ForClass.php +++ b/src/Core/Config/Config_ForClass.php @@ -9,7 +9,7 @@ class Config_ForClass /** * @var string $class */ - public $class; + protected $class; /** * @param string|object $class diff --git a/src/Dev/FunctionalTest.php b/src/Dev/FunctionalTest.php index 8d5f4ec6f..cef88598d 100644 --- a/src/Dev/FunctionalTest.php +++ b/src/Dev/FunctionalTest.php @@ -106,8 +106,7 @@ class FunctionalTest extends SapphireTest // basis. BasicAuth::protect_entire_site(false); - $this->session()->inst_clear('loggedInAs'); - Security::setCurrentUser(null); + $this->logOut(); SecurityToken::disable(); } @@ -412,8 +411,7 @@ class FunctionalTest extends SapphireTest $member = $this->objFromFixture('SilverStripe\\Security\\Member', $member); } - $this->session()->inst_set('loggedInAs', $member->ID); - Security::setCurrentUser($member); + $this->logIn($member); } /** @@ -422,10 +420,15 @@ class FunctionalTest extends SapphireTest */ public function logOut() { - $this->session()->inst_set('loggedInAs', null); + $this->session()->inst_clear('loggedInAs'); Security::setCurrentUser(null); } + public function logIn($member) + { + Security::setCurrentUser($member); + } + /** * Use the draft (stage) site for testing. * This is helpful if you're not testing publication functionality and don't want "stage management" cluttering diff --git a/src/Dev/SapphireTest.php b/src/Dev/SapphireTest.php index 3651d6fc6..f557f5a40 100644 --- a/src/Dev/SapphireTest.php +++ b/src/Dev/SapphireTest.php @@ -25,6 +25,7 @@ use SilverStripe\Core\Resettable; use SilverStripe\i18n\i18n; use SilverStripe\ORM\DataExtension; use SilverStripe\ORM\SS_List; +use SilverStripe\Security\IdentityStore; use SilverStripe\Versioned\Versioned; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataModel; @@ -1250,7 +1251,7 @@ class SapphireTest extends PHPUnit_Framework_TestCase $this->cache_generatedMembers[$permCode] = $member; } - Security::setCurrentUser($member); + Injector::inst()->get(IdentityStore::class)->logIn($member); return $member->ID; } diff --git a/src/Forms/FormRequestHandler.php b/src/Forms/FormRequestHandler.php index 526f488bb..f1f82606d 100644 --- a/src/Forms/FormRequestHandler.php +++ b/src/Forms/FormRequestHandler.php @@ -233,18 +233,18 @@ class FormRequestHandler extends RequestHandler // Otherwise, try a handler method on the form request handler. if ($this->hasMethod($funcName)) { - return $this->$funcName($vars, $this->form, $request); + return $this->$funcName($vars, $this->form, $request, $this); } // Otherwise, try a handler method on the form itself if ($this->form->hasMethod($funcName)) { - return $this->form->$funcName($vars, $this->form, $request); + return $this->form->$funcName($vars, $this->form, $request, $this); } // Check for inline actions $field = $this->checkFieldsForAction($this->form->Fields(), $funcName); if ($field) { - return $field->$funcName($vars, $this->form, $request); + return $field->$funcName($vars, $this->form, $request, $this); } } catch (ValidationException $e) { // The ValdiationResult contains all the relevant metadata diff --git a/src/Security/AuthenticationRequestFilter.php b/src/Security/AuthenticationRequestFilter.php index ec44fa281..743d582e3 100644 --- a/src/Security/AuthenticationRequestFilter.php +++ b/src/Security/AuthenticationRequestFilter.php @@ -2,15 +2,16 @@ namespace SilverStripe\Security; -use Exception; use SilverStripe\Control\HTTPResponse_Exception; use SilverStripe\Control\RequestFilter; use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\Session; +use SilverStripe\Dev\Debug; use SilverStripe\ORM\DataModel; use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\Injector\Injector; +use SilverStripe\ORM\ValidationException; class AuthenticationRequestFilter implements RequestFilter, IdentityStore { @@ -18,10 +19,21 @@ class AuthenticationRequestFilter implements RequestFilter, IdentityStore use Configurable; /** - * @return array|IdentityStore[] + * @var array|AuthenticationHandler[] + */ + protected $handlers; + + /** + * This method currently uses a fallback as loading the handlers via YML has proven unstable + * + * @return array|AuthenticationHandler[] */ protected function getHandlers() { + if (is_array($this->handlers)) { + return $this->handlers; + } + return array_map( function ($identifier) { return Injector::inst()->get($identifier); @@ -30,24 +42,39 @@ class AuthenticationRequestFilter implements RequestFilter, IdentityStore ); } + /** + * Set an associative array of handlers + * + * @param array|AuthenticationHandler[] $handlers + */ + public function setHandlers($handlers) + { + $this->handlers = $handlers; + } + /** * Identify the current user from the request + * + * @param HTTPRequest $request + * @param Session $session + * @param DataModel $model + * @return bool|void + * @throws HTTPResponse_Exception */ public function preRequest(HTTPRequest $request, Session $session, DataModel $model) { try { /** @var AuthenticationHandler $handler */ - foreach ($this->getHandlers() as $handler) { + foreach ($this->getHandlers() as $name => $handler) { // @todo Update requestfilter logic to allow modification of initial response // in order to add cookies, etc - $member = $handler->authenticateRequest($request, new HTTPResponse()); + $member = $handler->authenticateRequest($request); if ($member) { - // @todo Remove the static coupling here Security::setCurrentUser($member); break; } } - } catch (Exception $e) { // There's no valid exception currently. I would say AuthenticationException? + } catch (ValidationException $e) { throw new HTTPResponse_Exception( "Bad log-in details: " . $e->getMessage(), 400 @@ -57,6 +84,11 @@ class AuthenticationRequestFilter implements RequestFilter, IdentityStore /** * No-op + * + * @param HTTPRequest $request + * @param HTTPResponse $response + * @param DataModel $model + * @return bool|void */ public function postRequest(HTTPRequest $request, HTTPResponse $response, DataModel $model) { @@ -65,11 +97,13 @@ class AuthenticationRequestFilter implements RequestFilter, IdentityStore /** * Log into the identity-store handlers attached to this request filter * - * @inherit + * @param Member $member + * @param bool $persistent + * @param HTTPRequest $request + * @return HTTPResponse|void */ - public function logIn(Member $member, $persistent, HTTPRequest $request) + public function logIn(Member $member, $persistent = false, HTTPRequest $request = null) { - // @todo Coupling here isn't ideal. $member->beforeMemberLoggedIn(); foreach ($this->getHandlers() as $handler) { @@ -78,7 +112,6 @@ class AuthenticationRequestFilter implements RequestFilter, IdentityStore } } - // @todo Coupling here isn't ideal. Security::setCurrentUser($member); $member->afterMemberLoggedIn(); } @@ -86,9 +119,10 @@ class AuthenticationRequestFilter implements RequestFilter, IdentityStore /** * Log out of all the identity-store handlers attached to this request filter * - * @inherit + * @param HTTPRequest $request + * @return HTTPResponse|void */ - public function logOut(HTTPRequest $request) + public function logOut(HTTPRequest $request = null) { foreach ($this->getHandlers() as $handler) { if ($handler instanceof IdentityStore) { @@ -96,7 +130,6 @@ class AuthenticationRequestFilter implements RequestFilter, IdentityStore } } - // @todo Coupling here isn't ideal. Security::setCurrentUser(null); } } diff --git a/src/Security/Authenticator.php b/src/Security/Authenticator.php index cc929108b..5077361da 100644 --- a/src/Security/Authenticator.php +++ b/src/Security/Authenticator.php @@ -7,6 +7,7 @@ use SilverStripe\Core\Extensible; use SilverStripe\Core\Injector\Injectable; use SilverStripe\Control\Controller; use SilverStripe\Forms\Form; +use SilverStripe\ORM\ValidationResult; /** * Abstract base class for an authentication method @@ -74,7 +75,8 @@ interface Authenticator /** - * @todo + * @param $link + * @return mixed */ public function getLostPasswordHandler($link); @@ -82,14 +84,8 @@ interface Authenticator * Method to authenticate an user. * * @param array $data Raw data to authenticate the user. - * @param string $message A variable to return an error message if authentication fails + * @param ValidationResult $result A validationresult which is either valid or contains the error message(s) * @return Member The matched member, or null if the authentication fails */ - public function authenticate($data, &$message); - - /** - * Return the keys that should be passed to authenticate() - * @return array - */ -// public function getAuthenticateFields(); + public function authenticate($data, &$result); } diff --git a/src/Security/BasicAuth.php b/src/Security/BasicAuth.php index d4235c4dd..768ad6f94 100644 --- a/src/Security/BasicAuth.php +++ b/src/Security/BasicAuth.php @@ -7,13 +7,10 @@ use SilverStripe\Control\Director; use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\HTTPResponse_Exception; -use SilverStripe\Core\Config\Config; use SilverStripe\Core\Config\Configurable; -use SilverStripe\Dev\Debug; use SilverStripe\Dev\SapphireTest; -use SilverStripe\Core\Injector\Injector; -use SilverStripe\Security\MemberAuthenticator\Authenticator; +use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator; /** * Provides an interface to HTTP basic authentication. @@ -47,7 +44,7 @@ class BasicAuth * @var String Message that shows in the authentication box. * Set this value through {@link protect_entire_site()}. */ - private static $entire_site_protected_message = "SilverStripe test website. Use your CMS login."; + private static $entire_site_protected_message = 'SilverStripe test website. Use your CMS login.'; /** * Require basic authentication. Will request a username and password if none is given. @@ -63,9 +60,13 @@ class BasicAuth * @return bool|Member * @throws HTTPResponse_Exception */ - public static function requireLogin(HTTPRequest $request, $realm, $permissionCode = null, $tryUsingSessionLogin = true) - { - $isRunningTests = (class_exists('SilverStripe\\Dev\\SapphireTest', false) && SapphireTest::is_running_test()); + public static function requireLogin( + HTTPRequest $request, + $realm, + $permissionCode = null, + $tryUsingSessionLogin = true + ) { + $isRunningTests = (class_exists(SapphireTest::class, false) && SapphireTest::is_running_test()); if (!Security::database_is_ready() || (Director::is_cli() && !$isRunningTests)) { return true; } @@ -89,16 +90,21 @@ class BasicAuth $member = null; if ($request->getHeader('PHP_AUTH_USER') && $request->getHeader('PHP_AUTH_PW')) { - /** @var Authenticator $authenticator */ - $authenticator = Injector::inst()->get(Authenticator::class); + /** @var MemberAuthenticator $authenticator */ + $authenticators = Security::singleton()->getApplicableAuthenticators(Authenticator::LOGIN); - $member = $authenticator->authenticate([ - 'Email' => $request->getHeader('PHP_AUTH_USER'), - 'Password' => $request->getHeader('PHP_AUTH_PW'), - ], $dummy); + foreach ($authenticators as $name => $authenticator) { + $member = $authenticator->authenticate([ + 'Email' => $request->getHeader('PHP_AUTH_USER'), + 'Password' => $request->getHeader('PHP_AUTH_PW'), + ]); + if ($member instanceof Member) { + break; + } + } } - if($member) { + if ($member instanceof Member) { Security::setCurrentUser($member); } @@ -112,9 +118,19 @@ class BasicAuth $response->addHeader('WWW-Authenticate', "Basic realm=\"$realm\""); if ($request->getHeader('PHP_AUTH_USER')) { - $response->setBody(_t('SilverStripe\\Security\\BasicAuth.ERRORNOTREC', "That username / password isn't recognised")); + $response->setBody( + _t( + 'SilverStripe\\Security\\BasicAuth.ERRORNOTREC', + "That username / password isn't recognised" + ) + ); } else { - $response->setBody(_t('SilverStripe\\Security\\BasicAuth.ENTERINFO', "Please enter a username and password.")); + $response->setBody( + _t( + 'SilverStripe\\Security\\BasicAuth.ENTERINFO', + 'Please enter a username and password.' + ) + ); } // Exception is caught by RequestHandler->handleRequest() and will halt further execution @@ -128,7 +144,12 @@ class BasicAuth $response->addHeader('WWW-Authenticate', "Basic realm=\"$realm\""); if ($request->getHeader('PHP_AUTH_USER')) { - $response->setBody(_t('SilverStripe\\Security\\BasicAuth.ERRORNOTADMIN', "That user is not an administrator.")); + $response->setBody( + _t( + 'SilverStripe\\Security\\BasicAuth.ERRORNOTADMIN', + 'That user is not an administrator.' + ) + ); } // Exception is caught by RequestHandler->handleRequest() and will halt further execution @@ -160,9 +181,9 @@ class BasicAuth */ public static function protect_entire_site($protect = true, $code = 'ADMIN', $message = null) { - Config::modify()->set(self::class, 'entire_site_protected', $protect); - Config::modify()->set(self::class, 'entire_site_protected_code', $code); - Config::modify()->set(self::class, 'entire_site_protected_message', $message); + static::config()->set('entire_site_protected', $protect); + static::config()->set('entire_site_protected_code', $code); + static::config()->set('entire_site_protected_message', $message); } /** @@ -174,7 +195,7 @@ class BasicAuth */ public static function protect_site_if_necessary() { - $config = Config::forClass(BasicAuth::class); + $config = static::config(); $request = Controller::curr()->getRequest(); if ($config->get('entire_site_protected')) { /** @noinspection ExceptionsAnnotatingAndHandlingInspection */ @@ -182,7 +203,8 @@ class BasicAuth $request, $config->get('entire_site_protected_message'), $config->get('entire_site_protected_code'), - false); + false + ); } } } diff --git a/src/Security/CMSMemberLoginForm.php b/src/Security/CMSMemberLoginForm.php index e71865b50..197240037 100644 --- a/src/Security/CMSMemberLoginForm.php +++ b/src/Security/CMSMemberLoginForm.php @@ -15,7 +15,7 @@ use SilverStripe\Security\Security; /** * Provides the in-cms session re-authentication form for the "member" authenticator */ -class CMSMemberLoginForm extends LoginForm +class CMSMemberLoginForm extends MemberLoginForm { /** diff --git a/src/Security/CMSSecurity.php b/src/Security/CMSSecurity.php index 1a89f1c9e..97b5ffd9a 100644 --- a/src/Security/CMSSecurity.php +++ b/src/Security/CMSSecurity.php @@ -10,7 +10,7 @@ use SilverStripe\Control\Director; use SilverStripe\Control\Controller; use SilverStripe\Core\Injector\Injector; use SilverStripe\ORM\FieldType\DBField; -use SilverStripe\Security\MemberAuthenticator\CMSAuthenticator; +use SilverStripe\Security\MemberAuthenticator\CMSMemberAuthenticator; use SilverStripe\View\Requirements; /** @@ -44,7 +44,7 @@ class CMSSecurity extends Security Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/client/dist/js/vendor.js'); } - public function login($request, $service = Authenticator::CMS_LOGIN) + public function login($request = null, $service = Authenticator::CMS_LOGIN) { return parent::login($request, Authenticator::CMS_LOGIN); } @@ -60,9 +60,9 @@ class CMSSecurity extends Security return parent::getAuthenticator($name); } - public static function getAuthenticators($service = Authenticator::CMS_LOGIN) + public function getApplicableAuthenticators($service = Authenticator::CMS_LOGIN) { - return parent::getAuthenticators($service); + return parent::getApplicableAuthenticators($service); } /** @@ -97,7 +97,7 @@ class CMSSecurity extends Security public function getTitle() { // Check if logged in already - if (Member::currentUserID()) { + if (Security::getCurrentUser()) { return _t('SilverStripe\\Security\\CMSSecurity.SUCCESS', 'Success'); } @@ -174,19 +174,7 @@ PHP return false; } - /** @var [] $authenticators */ - $authenticators = Security::config()->get('authenticators'); - foreach ($authenticators as $name => $authenticator) { - // Supported if at least one authenticator is supported - $authenticator = Injector::inst()->get($authenticator); - if (($authenticator->supportedServices() & Authenticator::CMS_LOGIN) - && Security::hasAuthenticator($name) - ) { - return true; - } - } - - return false; + return count(Security::singleton()->getApplicableAuthenticators(Authenticator::CMS_LOGIN)) > 0; } /** @@ -197,7 +185,7 @@ PHP public function success() { // Ensure member is properly logged in - if (!Member::currentUserID() || !class_exists(AdminRootController::class)) { + if (!Security::getCurrentUser() || !class_exists(AdminRootController::class)) { return $this->redirectToExternalLogin(); } diff --git a/src/Security/IdentityStore.php b/src/Security/IdentityStore.php index f39688106..259db694b 100644 --- a/src/Security/IdentityStore.php +++ b/src/Security/IdentityStore.php @@ -4,6 +4,7 @@ namespace SilverStripe\Security; use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPResponse; +use SilverStripe\Core\Config\Configurable; /** * Represents an authentication handler that can have identities logged into & out of it. @@ -18,15 +19,15 @@ interface IdentityStore * @param Member $member The member to log in. * @param Boolean $persistent boolean If set to true, the login may persist beyond the current session. * @param HTTPRequest $request The request of the visitor that is logging in, to get, for example, cookies. - * @param HTTPResponse $response The response object to modify, if needed. + * @return HTTPResponse $response The response object to modify, if needed. */ - public function logIn(Member $member, $persistent, HTTPRequest $request); + public function logIn(Member $member, $persistent = false, HTTPRequest $request = null); /** * Log any logged-in member out of this identity store. * * @param HTTPRequest $request The request of the visitor that is logging out, to get, for example, cookies. - * @param HTTPResponse $response The response object to modify, if needed. + * @return HTTPResponse $response The response object to modify, if needed. */ - public function logOut(HTTPRequest $request); + public function logOut(HTTPRequest $request = null); } diff --git a/src/Security/Member.php b/src/Security/Member.php index 6aa638a23..21a5439e7 100644 --- a/src/Security/Member.php +++ b/src/Security/Member.php @@ -5,6 +5,7 @@ namespace SilverStripe\Security; use IntlDateFormatter; use SilverStripe\Admin\LeftAndMain; use SilverStripe\CMS\Controllers\CMSMain; +use SilverStripe\Control\Controller; use SilverStripe\Control\Cookie; use SilverStripe\Control\Director; use SilverStripe\Control\Email\Email; @@ -12,6 +13,8 @@ use SilverStripe\Control\Email\Mailer; use SilverStripe\Control\Session; use SilverStripe\Core\Convert; use SilverStripe\Core\Injector\Injector; +use SilverStripe\Dev\Debug; +use SilverStripe\Dev\Deprecation; use SilverStripe\Dev\SapphireTest; use SilverStripe\Dev\TestMailer; use SilverStripe\Forms\ConfirmedPasswordField; @@ -22,6 +25,7 @@ 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; @@ -61,25 +65,25 @@ 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 +91,7 @@ class Member extends DataObject ); private static $has_many = array( - 'LoggedPasswords' => MemberPassword::class, + 'LoggedPasswords' => MemberPassword::class, 'RememberLoginHashes' => RememberLoginHash::class, ); @@ -191,6 +195,12 @@ class Member extends DataObject */ 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 +286,7 @@ class Member extends DataObject // Find member /** @skipUpgrade */ - $admin = Member::get() + $admin = static::get() ->filter('Email', Security::default_admin_username()) ->first(); if (!$admin) { @@ -284,7 +294,7 @@ class Member extends DataObject // 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 +333,15 @@ class Member extends DataObject // 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,7 +375,7 @@ class Member extends DataObject 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, @@ -374,6 +385,7 @@ class Member extends DataObject } $this->extend('canLogIn', $result); + return $result; } @@ -382,11 +394,12 @@ class Member extends DataObject * * @return bool */ - protected function isLockedOut() + public function isLockedOut() { if (!$this->LockedOutUntil) { return false; } + return DBDatetime::now()->getTimestamp() < $this->dbObject('LockedOutUntil')->getTimestamp(); } @@ -416,17 +429,20 @@ class Member extends DataObject if (!$this->PasswordExpiry) { return false; } + return strtotime(date('Y-m-d')) >= strtotime($this->PasswordExpiry); } /** - * @deprecated Use Security::setCurrentUser() or IdentityStore::logIn() + * @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() { - user_error("This method is deprecated and now only logs in for the current request", E_USER_WARNING); + 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); } @@ -478,15 +494,20 @@ class Member extends DataObject * 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; } } @@ -494,37 +515,21 @@ class Member extends DataObject } /** + * @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'); } /** @@ -548,6 +553,7 @@ class Member extends DataObject // We assume we have PasswordEncryption and Salt available here. $e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption); + return $e->encrypt($string, $this->Salt); } @@ -590,6 +596,7 @@ class Member extends DataObject { $hash = $this->encryptWithUserSettings($autologinToken); $member = self::member_from_autologinhash($hash, false); + return (bool)$member; } @@ -605,13 +612,13 @@ class Member extends DataObject 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; @@ -625,11 +632,12 @@ class Member extends DataObject */ public static function member_from_tempid($tempid) { - $members = Member::get() + $members = static::get() ->filter('TempIDHash', $tempid); // Exclude expired if (static::config()->get('temp_id_lifetime')) { + /** @var DataList|Member[] $members */ $members = $members->filter('TempIDExpired:GreaterThan', DBDatetime::now()->getValue()); } @@ -640,6 +648,8 @@ class Member extends DataObject * 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. */ @@ -660,6 +670,7 @@ class Member extends DataObject $this->extend('updateMemberFormFields', $fields); + return $fields; } @@ -672,7 +683,7 @@ class Member extends DataObject { $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( @@ -684,12 +695,13 @@ class Member extends DataObject ); // 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; } @@ -717,12 +729,17 @@ class Member extends DataObject /** * Returns the current logged in user * - * @deprecated use Security::getCurrentUser() + * @deprecated 5.0.0 use Security::getCurrentUser() * * @return Member */ public static function currentUser() { + Deprecation::notice( + '5.0.0', + 'This method is deprecated. Please use Security::getCurrentUser() or an IdentityStore' + ); + return Security::getCurrentUser(); } @@ -761,12 +778,17 @@ class Member extends DataObject /** * Get the ID of the current logged in user * - * @deprecated use Security::getCurrentUser() + * @deprecated 5.0.0 use Security::getCurrentUser() * * @return int Returns the ID of the current logged in user or 0. */ public static function currentUserID() { + Deprecation::notice( + '5.0.0', + 'This method is deprecated. Please use Security::getCurrentUser() or an IdentityStore' + ); + if ($member = Security::getCurrentUser()) { return $member->ID; } else { @@ -774,12 +796,12 @@ class Member extends DataObject } } - /* - * 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'); @@ -788,16 +810,17 @@ class Member extends DataObject $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; } } @@ -827,12 +850,12 @@ class Member extends DataObject 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 ) )); @@ -841,6 +864,7 @@ class Member extends DataObject // 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'] @@ -850,7 +874,7 @@ class Member extends DataObject ->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(); } @@ -858,7 +882,7 @@ class Member extends DataObject // 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 @@ -878,8 +902,8 @@ class Member extends DataObject // 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; } @@ -900,7 +924,7 @@ class Member extends DataObject Permission::reset(); - if ($this->isChanged('Password')) { + if ($this->isChanged('Password') && static::config()->get('password_logging_enabled')) { MemberPassword::log($this); } } @@ -924,6 +948,7 @@ class Member extends DataObject $password->delete(); $password->destroy(); } + return $this; } @@ -942,9 +967,10 @@ class Member extends DataObject } // 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; } @@ -1081,6 +1107,7 @@ class Member extends DataObject foreach ($format['columns'] as $col) { $values[] = $this->getField($col); } + return implode($format['sep'], $values); } if ($this->getField('ID') === 0) { @@ -1114,17 +1141,18 @@ class Member extends DataObject 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).")"; + + return "(" . join(" $op $sepSQL $op ", $columnsWithTablename) . ")"; } @@ -1195,6 +1223,7 @@ class Member extends DataObject if ($locale) { return $locale; } + return i18n::get_locale(); } @@ -1271,7 +1300,7 @@ class Member extends DataObject // 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(); @@ -1281,6 +1310,7 @@ class Member extends DataObject } $membersList->removeDuplicates('ID'); + return $membersList->map(); } @@ -1335,7 +1365,7 @@ class Member extends DataObject } /** @skipUpgrade */ - $members = Member::get() + $members = static::get() ->innerJoin("Group_Members", '"Group_Members"."MemberID" = "Member"."ID"') ->innerJoin("Group", '"Group"."ID" = "Group_Members"."GroupID"'); if ($groupIDList) { @@ -1395,12 +1425,12 @@ class Member extends DataObject $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(static::config()->get('hidden_fields')); - if (! static::config()->get('lock_out_after_incorrect_logins')) { + if (!static::config()->get('lock_out_after_incorrect_logins')) { $mainFields->removeByName('FailedLoginCount'); } @@ -1426,7 +1456,7 @@ class Member extends DataObject ->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') ) ); @@ -1465,21 +1495,22 @@ class Member extends DataObject { $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; } @@ -1511,6 +1542,7 @@ class Member extends DataObject if ($this->ID == $member->ID) { return true; } + //standard check return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin'); } @@ -1547,9 +1579,11 @@ class Member extends DataObject 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 @@ -1582,10 +1616,11 @@ class Member extends DataObject // 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'); } @@ -1644,7 +1679,7 @@ class Member extends DataObject 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); + $this->LockedOutUntil = date('Y-m-d H:i:s', DBDatetime::now()->getTimestamp() + $lockoutMins * 60); $this->FailedLoginCount = 0; } } diff --git a/src/Security/MemberAuthenticator/CMSAuthenticator.php b/src/Security/MemberAuthenticator/CMSMemberAuthenticator.php similarity index 66% rename from src/Security/MemberAuthenticator/CMSAuthenticator.php rename to src/Security/MemberAuthenticator/CMSMemberAuthenticator.php index 5d3faf217..5f3cd3403 100644 --- a/src/Security/MemberAuthenticator/CMSAuthenticator.php +++ b/src/Security/MemberAuthenticator/CMSMemberAuthenticator.php @@ -2,10 +2,11 @@ namespace SilverStripe\Security\MemberAuthenticator; +use SilverStripe\ORM\ValidationResult; use SilverStripe\Security\Authenticator as BaseAuthenticator; use SilverStripe\Security\Member; -class CMSAuthenticator extends Authenticator +class CMSMemberAuthenticator extends MemberAuthenticator { public function supportedServices() @@ -15,11 +16,11 @@ class CMSAuthenticator extends Authenticator /** * @param array $data - * @param $message - * @param bool $success + * @param ValidationResult|null $result + * @param Member|null $member * @return Member */ - protected function authenticateMember($data, &$message, &$success, $member = null) + protected function authenticateMember($data, &$result = null, $member = null) { // Attempt to identify by temporary ID if (!empty($data['tempid'])) { @@ -30,9 +31,13 @@ class CMSAuthenticator extends Authenticator } } - return parent::authenticateMember($data, $message, $success, $member); + 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 index c0eb829de..155ae00b4 100644 --- a/src/Security/MemberAuthenticator/ChangePasswordForm.php +++ b/src/Security/MemberAuthenticator/ChangePasswordForm.php @@ -31,36 +31,51 @@ class ChangePasswordForm extends Form $backURL = $controller->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 (Security::getCurrentUser()) { - $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"))); + $fields = $this->getFormFields(); } if (!$actions) { - $actions = new FieldList( - new FormAction("doChangePassword", _t('SilverStripe\\Security\\Member.BUTTONCHANGEPASSWORD', "Change Password")) - ); + $actions = $this->getFormActions(); } if ($backURL) { - $fields->push(new HiddenField('BackURL', false, $backURL)); + $fields->push(HiddenField::create('BackURL', false, $backURL)); } parent::__construct($controller, $name, $fields, $actions); } /** - * @return ChangePasswordHandler + * @return FieldList */ - protected function buildRequestHandler() + protected function getFormFields() { - return ChangePasswordHandler::create($this); + $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 index 2f87eb74d..0bc6e8369 100644 --- a/src/Security/MemberAuthenticator/ChangePasswordHandler.php +++ b/src/Security/MemberAuthenticator/ChangePasswordHandler.php @@ -3,34 +3,226 @@ namespace SilverStripe\Security\MemberAuthenticator; +use SilverStripe\Control\Controller; +use SilverStripe\Control\RequestHandler; use SilverStripe\Core\Injector\Injector; use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\Session; -use SilverStripe\Forms\FormRequestHandler; +use SilverStripe\ORM\FieldType\DBDatetime; +use SilverStripe\ORM\FieldType\DBField; +use SilverStripe\ORM\FieldType\DBHTMLText; +use SilverStripe\Security\Authenticator; +use SilverStripe\Security\CMSSecurity; use SilverStripe\Security\Member; use SilverStripe\Security\Security; use SilverStripe\Security\IdentityStore; -class ChangePasswordHandler extends FormRequestHandler +class ChangePasswordHandler extends RequestHandler { + /** + * @var Authenticator + */ + protected $authenticator; + + /** + * @var string + */ + protected $link; + + /** + * @var array Allowed Actions + */ + private static $allowed_actions = [ + 'changepassword', + 'changePasswordForm', + ]; + + /** + * @var array URL Handlers. All should point to changepassword + */ + private static $url_handlers = [ + '' => '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 HTTPResponse|DBHTMLText + */ + 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 $this->buildResponse($message); + } + + 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 $this->buildResponse($message); + } + // Show a friendly message saying the login token has expired + if ($token !== null && $member && !$member->validateAutoLoginToken($token)) { + $customisedController = Controller::curr()->customise( + array( + '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 $customisedController->renderWith('changepassword'); + } + + // 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 DBField $message + * @return DBHTMLText + */ + protected function buildResponse($message) + { + $customisedController = Controller::curr()->customise( + [ + 'Content' => $message, + 'Form' => $this->changePasswordForm() + ] + ); + + return $customisedController->renderWith(Security::singleton()->getTemplatesFor('changepassword')); + } + + /** + * @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 * @return HTTPResponse */ - public function doChangePassword(array $data, $form) + public function doChangePassword(array $data) { $member = Security::getCurrentUser(); // The user was logged in, check the current password if ($member && ( - empty($data['OldPassword']) || - !$member->checkPassword($data['OldPassword'])->isValid() - )) { + empty($data['OldPassword']) || + !$member->checkPassword($data['OldPassword'])->isValid() + ) + ) { $this->form->sessionMessage( - _t('SilverStripe\\Security\\Member.ERRORPASSWORDNOTMATCH', "Your current password does not match, please try again"), + _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(); } @@ -43,6 +235,7 @@ class ChangePasswordHandler extends FormRequestHandler // 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'))); } } @@ -50,7 +243,10 @@ class ChangePasswordHandler extends FormRequestHandler // 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"), + _t( + 'SilverStripe\\Security\\Member.EMPTYNEWPASSWORD', + "The new password can't be empty, please try again" + ), "bad" ); @@ -61,9 +257,13 @@ class ChangePasswordHandler extends FormRequestHandler // 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"), + _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(); } @@ -72,17 +272,20 @@ class ChangePasswordHandler extends FormRequestHandler $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; + // 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, $form->getRequestHandler()->getRequest()); + Injector::inst()->get(IdentityStore::class)->logIn($member, false, $this->getRequest()); } // TODO Add confirmation message to login redirect @@ -96,6 +299,7 @@ class ChangePasswordHandler extends FormRequestHandler // Redirect to default location - the login form saying "You are logged in as..." $url = Security::singleton()->Link('login'); + return $this->redirect($url); } @@ -103,6 +307,7 @@ class ChangePasswordHandler extends FormRequestHandler { // Redirect back to form $url = $this->addBackURLParam(CMSSecurity::singleton()->Link('changepassword')); + return $this->redirect($url); } } diff --git a/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php b/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php index 9fd2059d9..ca1085c6a 100644 --- a/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php +++ b/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php @@ -2,6 +2,7 @@ namespace SilverStripe\Security\MemberAuthenticator; +use SilverStripe\Control\HTTPResponse; use SilverStripe\Security\Member; use SilverStripe\Control\HTTPRequest; use SilverStripe\Security\AuthenticationHandler as AuthenticationHandlerInterface; @@ -30,7 +31,7 @@ class CookieAuthenticationHandler implements AuthenticationHandlerInterface, Ide /** * @var IdentityStore */ - private $cascadeLogInTo; + private $cascadeInTo; /** * Get the name of the cookie used to track this device @@ -45,7 +46,7 @@ class CookieAuthenticationHandler implements AuthenticationHandlerInterface, Ide /** * Set the name of the cookie used to track this device * - * @param $deviceCookieName + * @param string $deviceCookieName * @return null */ public function setDeviceCookieName($deviceCookieName) @@ -66,8 +67,7 @@ class CookieAuthenticationHandler implements AuthenticationHandlerInterface, Ide /** * Set the name of the cookie used to store an login token * - * @param $tokenCookieName - * @return null + * @param string $tokenCookieName */ public function setTokenCookieName($tokenCookieName) { @@ -81,22 +81,23 @@ class CookieAuthenticationHandler implements AuthenticationHandlerInterface, Ide */ public function getCascadeLogInTo() { - return $this->cascadeLogInTo; + return $this->cascadeInTo; } /** * Set the name of the cookie used to store an login token * - * @param $cascadeLogInTo + * @param IdentityStore $cascadeInTo * @return null */ - public function setCascadeLogInTo(IdentityStore $cascadeLogInTo) + public function setCascadeLogInTo(IdentityStore $cascadeInTo) { - $this->cascadeLogInTo = $cascadeLogInTo; + $this->cascadeInTo = $cascadeInTo; } /** - * @inherit + * @param HTTPRequest $request + * @return null|Member */ public function authenticateRequest(HTTPRequest $request) { @@ -104,14 +105,14 @@ class CookieAuthenticationHandler implements AuthenticationHandlerInterface, Ide $deviceID = Cookie::get($this->getDeviceCookieName()); // @todo Consider better placement of database_is_ready test - if (!$deviceID || strpos($uidAndToken, ':') === false || !Security::database_is_ready()) { - return; + if ($deviceID === null || strpos($uidAndToken, ':') === false || !Security::database_is_ready()) { + return null; } list($uid, $token) = explode(':', $uidAndToken, 2); if (!$uid || !$token) { - return; + return null; } /** @var Member $member */ @@ -127,7 +128,7 @@ class CookieAuthenticationHandler implements AuthenticationHandlerInterface, Ide ->filter(array( 'MemberID' => $member->ID, 'DeviceID' => $deviceID, - 'Hash' => $hash + 'Hash' => $hash ))->first(); if (!$rememberLoginHash) { @@ -144,11 +145,10 @@ class CookieAuthenticationHandler implements AuthenticationHandlerInterface, Ide } if ($member) { - if ($this->cascadeLogInTo) { + 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->cascadeLogInTo->logIn($member, false, $request); - //\SilverStripe\Dev\Debug::message('here'); + $this->cascadeInTo->logIn($member, false, $request); } // @todo Consider whether response should be part of logIn() as well @@ -168,20 +168,21 @@ class CookieAuthenticationHandler implements AuthenticationHandlerInterface, Ide ); } - return $member; - // Audit logging hook $member->extend('memberAutoLoggedIn'); + + return $member; } } /** - * @inherit + * @param Member $member + * @param bool $persistent + * @param HTTPRequest $request + * @return HTTPResponse|void */ - public function logIn(Member $member, $persistent, HTTPRequest $request) + public function logIn(Member $member, $persistent = false, HTTPRequest $request = null) { - // @todo couple the cookies to the response object - // Cleans up any potential previous hash for this member on this device if ($alcDevice = Cookie::get($this->getDeviceCookieName())) { RememberLoginHash::get()->filter('DeviceID', $alcDevice)->removeAll(); @@ -210,29 +211,39 @@ class CookieAuthenticationHandler implements AuthenticationHandlerInterface, Ide null, true ); - - // Clear a cookie for non-persistent log-ins } else { - $this->logOut($request); + // Clear a cookie for non-persistent log-ins + $this->clearCookies(); } } /** - * @inherit + * @param HTTPRequest|null $request + * @return HTTPResponse|void */ - public function logOut(HTTPRequest $request) + public function logOut(HTTPRequest $request = null) { $member = Security::getCurrentUser(); if ($member) { RememberLoginHash::clear($member, Cookie::get('alc_device')); } - // @todo couple the cookies to the response object + $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()); - - Security::setCurrentUser(null); } } diff --git a/src/Security/MemberAuthenticator/LoginHandler.php b/src/Security/MemberAuthenticator/LoginHandler.php index 4da92efaa..a344d64c4 100644 --- a/src/Security/MemberAuthenticator/LoginHandler.php +++ b/src/Security/MemberAuthenticator/LoginHandler.php @@ -8,6 +8,7 @@ use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\Session; use SilverStripe\Control\RequestHandler; use SilverStripe\ORM\ValidationResult; +use SilverStripe\Security\Authenticator; use SilverStripe\Security\Security; use SilverStripe\Security\Member; use SilverStripe\Core\Injector\Injector; @@ -23,14 +24,14 @@ class LoginHandler extends RequestHandler */ protected $authenticator; + /** + * @var array + */ private static $url_handlers = [ '' => 'login', ]; /** - * 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 */ @@ -40,23 +41,26 @@ class LoginHandler extends RequestHandler 'logout', ]; + /** + * @var string Called link on this handler + */ private $link; /** * @param string $link The URL to recreate this request handler - * @param Authenticator $authenticator The + * @param MemberAuthenticator $authenticator The authenticator to use */ - public function __construct($link, Authenticator $authenticator) + public function __construct($link, MemberAuthenticator $authenticator) { $this->link = $link; $this->authenticator = $authenticator; - parent::__construct($link, $this); + parent::__construct(); } /** * Return a link to this request handler. * The link returned is supplied in the constructor - * @param null $action + * @param null|string $action * @return string */ public function link($action = null) @@ -70,6 +74,8 @@ class LoginHandler extends RequestHandler /** * URL handler for the log-in screen + * + * @return array */ public function login() { @@ -80,10 +86,12 @@ class LoginHandler extends RequestHandler /** * Return the MemberLoginForm form + * + * @return MemberLoginForm */ public function loginForm() { - return LoginForm::create( + return MemberLoginForm::create( $this, get_class($this->authenticator), 'LoginForm' @@ -96,28 +104,41 @@ class LoginHandler extends RequestHandler * This method is called when the user finishes the login flow * * @param array $data Submitted data - * @param LoginForm $form + * @param MemberLoginForm $form * @return HTTPResponse */ public function doLogin($data, $form) { $failureMessage = null; + $this->extend('beforeLogin'); // Successful login - if ($member = $this->checkLogin($data, $failureMessage)) { + 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(); } - $form->sessionMessage($failureMessage, 'bad'); + $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', isset($data['Remember'])); + Session::set('SessionForms.MemberLoginForm.Remember', $rememberMe); } // Fail to login redirects back to form @@ -173,7 +194,7 @@ class LoginHandler extends RequestHandler 'Welcome Back, {firstname}', ['firstname' => $member->FirstName] ); - Security::setLoginMessage($message, ValidationResult::TYPE_GOOD); + Security::singleton()->setLoginMessage($message, ValidationResult::TYPE_GOOD); } // Redirect back @@ -184,19 +205,16 @@ class LoginHandler extends RequestHandler * Try to authenticate the user * * @param array $data Submitted data - * @param string $message + * @param ValidationResult $result * @return Member Returns the member object on successful authentication * or NULL on failure. */ - public function checkLogin($data, &$message) + public function checkLogin($data, &$result) { - $message = null; - $member = $this->authenticator->authenticate($data, $message); - if ($member) { + $member = $this->authenticator->authenticate($data, $result); + if ($member instanceof Member) { return $member; } - // No member, can't login - $this->extend('authenticationFailed', $data); return null; } @@ -212,8 +230,9 @@ class LoginHandler extends RequestHandler */ public function performLogin($member, $data, $request) { - // @todo pass request/response - Injector::inst()->get(IdentityStore::class)->logIn($member, !empty($data['Remember']), $request); + /** IdentityStore */ + $rememberMe = (isset($data['Remember']) && Security::config()->get('autologin_enabled')); + Injector::inst()->get(IdentityStore::class)->logIn($member, $rememberMe, $request); return $member; } @@ -235,20 +254,4 @@ class LoginHandler extends RequestHandler return $this->redirect($this->addBackURLParam($changedPasswordLink)); } - - - /** - * @todo copypaste from FormRequestHandler - refactor - * @param string $link - * @return string - */ - protected function addBackURLParam($link) - { - $backURL = $this->getBackURL(); - if ($backURL) { - return Controller::join_links($link, '?BackURL=' . urlencode($backURL)); - } - - return $link; - } } diff --git a/src/Security/MemberAuthenticator/LogoutHandler.php b/src/Security/MemberAuthenticator/LogoutHandler.php index db4b3771e..2c51899f8 100644 --- a/src/Security/MemberAuthenticator/LogoutHandler.php +++ b/src/Security/MemberAuthenticator/LogoutHandler.php @@ -16,7 +16,6 @@ use SilverStripe\Security\Security; * The logout process destroys all traces of the member on the server (not the actual computer user * at the other end of the line, don't worry) * - * @package SilverStripe\Security\MemberAuthenticator */ class LogoutHandler extends RequestHandler { diff --git a/src/Security/MemberAuthenticator/LostPasswordHandler.php b/src/Security/MemberAuthenticator/LostPasswordHandler.php index 5deb6d5c1..28cce5f60 100644 --- a/src/Security/MemberAuthenticator/LostPasswordHandler.php +++ b/src/Security/MemberAuthenticator/LostPasswordHandler.php @@ -7,10 +7,13 @@ use SilverStripe\Control\Email\Email; use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\Session; use SilverStripe\Control\RequestHandler; +use SilverStripe\Core\Injector\Injector; +use SilverStripe\Forms\Form; use SilverStripe\ORM\ValidationResult; use SilverStripe\Forms\FieldList; use SilverStripe\Forms\EmailField; use SilverStripe\Forms\FormAction; +use SilverStripe\Security\IdentityStore; use SilverStripe\Security\Member; use SilverStripe\Security\Security; use SilverStripe\Core\Convert; @@ -21,11 +24,18 @@ use SilverStripe\ORM\FieldType\DBField; */ class LostPasswordHandler extends RequestHandler { + /** + * Authentication class to use + * @var string + */ protected $authenticatorClass = MemberAuthenticator::class; + /** + * @var array + */ private static $url_handlers = [ 'passwordsent/$EmailAddress' => 'passwordsent', - '' => 'lostpassword', + '' => 'lostpassword', ]; /** @@ -44,7 +54,7 @@ class LostPasswordHandler extends RequestHandler private $link = null; /** - * @param $link The URL to recreate this request handler + * @param string $link The URL to recreate this request handler */ public function __construct($link) { @@ -55,37 +65,43 @@ class LostPasswordHandler extends RequestHandler /** * 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); - } else { - return $this->link; } + + return $this->link; } /** * URL handler for the initial lost-password screen + * + * @return array */ public function lostpassword() { $message = _t( - 'Security.NOTERESETPASSWORD', + '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(), + 'Form' => $this->lostPasswordForm(), ]; } /** * Show the "password sent" page, after a user has requested * to reset their password. + * + * @return array */ public function passwordsent() { @@ -93,20 +109,20 @@ class LostPasswordHandler extends RequestHandler $email = Convert::raw2xml(rawurldecode($request->param('EmailAddress')) . '.' . $request->getExtension()); $message = _t( - 'Security.PASSWORDSENTTEXT', + '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) ] + ['email' => Convert::raw2xml($email)] ); return [ - 'Title' => _t( - 'Security.PASSWORDSENTHEADER', + 'Title' => _t( + 'SilverStripe\\Security\\Security.PASSWORDSENTHEADER', "Password reset link sent to '{email}'", array('email' => $email) ), 'Content' => DBField::create_field('HTMLFragment', "

$message

"), - 'Email' => $email + 'Email' => $email ]; } @@ -119,17 +135,17 @@ class LostPasswordHandler extends RequestHandler */ public function lostPasswordForm() { - return LoginForm::create( + return MemberLoginForm::create( $this, $this->authenticatorClass, - 'LostPasswordForm', + 'lostPasswordForm', new FieldList( - new EmailField('Email', _t('Member.EMAIL', 'Email')) + new EmailField('Email', _t('SilverStripe\\Security\\Member.EMAIL', 'Email')) ), new FieldList( new FormAction( 'forgotPassword', - _t('Security.BUTTONSEND', 'Send me the password reset link') + _t('SilverStripe\\Security\\Security.BUTTONSEND', 'Send me the password reset link') ) ), false @@ -144,12 +160,8 @@ class LostPasswordHandler extends RequestHandler public function redirectToLostPassword() { $lostPasswordLink = Security::singleton()->Link('lostpassword'); - return $this->redirect($this->addBackURLParam($lostPasswordLink)); - } - public function getReturnReferer() - { - return $this->link(); + return $this->redirect($this->addBackURLParam($lostPasswordLink)); } /** @@ -167,29 +179,6 @@ class LostPasswordHandler extends RequestHandler 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". @@ -207,15 +196,20 @@ class LostPasswordHandler extends RequestHandler // Ensure password is given if (empty($data['Email'])) { $this->form->sessionMessage( - _t('Member.ENTEREMAIL', 'Please enter an email address to get a password reset link.'), + _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("Email", $data['Email'])->first(); + $member = Member::get()->filter([$field => $data['Email']])->first(); // Allow vetoing forgot password requests $results = $this->extend('forgotPassword', $member); @@ -229,7 +223,11 @@ class LostPasswordHandler extends RequestHandler Email::create() ->setHTMLTemplate('SilverStripe\\Control\\Email\\ForgotPasswordEmail') ->setData($member) - ->setSubject(_t('Member.SUBJECTPASSWORDRESET', "Your password reset link", 'Email subject')) + ->setSubject(_t( + 'SilverStripe\\Security\\Member.SUBJECTPASSWORDRESET', + "Your password reset link", + 'Email subject' + )) ->addData('PasswordResetLink', Security::getPasswordResetLink($member, $token)) ->setTo($member->Email) ->send(); @@ -242,18 +240,7 @@ class LostPasswordHandler extends RequestHandler rawurlencode($data['Email']), '/' ); + return $this->redirect($this->addBackURLParam($link)); } - - /** - * @todo copypaste from FormRequestHandler - refactor - */ - protected function addBackURLParam($link) - { - $backURL = $this->getBackURL(); - if ($backURL) { - return Controller::join_links($link, '?BackURL=' . urlencode($backURL)); - } - return $link; - } } diff --git a/src/Security/MemberAuthenticator/Authenticator.php b/src/Security/MemberAuthenticator/MemberAuthenticator.php similarity index 65% rename from src/Security/MemberAuthenticator/Authenticator.php rename to src/Security/MemberAuthenticator/MemberAuthenticator.php index 745396ac2..879c118bd 100644 --- a/src/Security/MemberAuthenticator/Authenticator.php +++ b/src/Security/MemberAuthenticator/MemberAuthenticator.php @@ -6,7 +6,7 @@ use SilverStripe\Control\Controller; use SilverStripe\Control\Session; use SilverStripe\ORM\ValidationResult; use InvalidArgumentException; -use SilverStripe\Security\Authenticator as BaseAuthenticator; +use SilverStripe\Security\Authenticator; use SilverStripe\Security\Security; use SilverStripe\Security\Member; use SilverStripe\Security\LoginAttempt; @@ -14,62 +14,68 @@ use SilverStripe\Security\LoginAttempt; /** * Authenticator for the default "member" method * - * @author Markus Lanthaler + * @author Sam Minnee + * @author Simon Erkelens */ -class Authenticator implements BaseAuthenticator +class MemberAuthenticator implements Authenticator { public function supportedServices() { - // Bitwise-OR of all the supported services, to make a bitmask - return BaseAuthenticator::LOGIN | BaseAuthenticator::LOGOUT | BaseAuthenticator::CHANGE_PASSWORD - | BaseAuthenticator::RESET_PASSWORD; + // Bitwise-OR of all the supported services in this Authenticator, to make a bitmask + return Authenticator::LOGIN | Authenticator::LOGOUT | Authenticator::CHANGE_PASSWORD + | Authenticator::RESET_PASSWORD; } /** - * @inherit + * @param array $data + * @param null|ValidationResult $result + * @return null|Member */ - public function authenticate($data, &$message) + public function authenticate($data, &$result = null) { - $success = null; - // Find authenticated member - $member = $this->authenticateMember($data, $message, $success); + $member = $this->authenticateMember($data, $result); // Optionally record every login attempt as a {@link LoginAttempt} object - $this->recordLoginAttempt($data, $member, $success); + $this->recordLoginAttempt($data, $member, $result->isValid()); if ($member) { Session::clear('BackURL'); } - return $success ? $member : null; + return $result->isValid() ? $member : null; } /** * Attempt to find and authenticate member if possible from the given data * * @param array $data Form submitted data - * @param $message - * @param bool &$success Success flag - * @param null|Member $member If the parent method already identified the member, it can be passed in + * @param ValidationResult $result + * @param Member|null This third parameter is used in the CMSAuthenticator(s) * @return Member Found member, regardless of successful login */ - protected function authenticateMember($data, &$message, &$success, $member = null) + protected function authenticateMember($data, &$result = null, $member = null) { // Default success to false - $success = false; - $email = !empty($data['Email']) ? $data['Email'] : null ; - + $email = !empty($data['Email']) ? $data['Email'] : null; + $result = new ValidationResult(); + // 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->canLogin()->isValid() && Security::check_default_admin($email, $data['Password']); + $success = Security::check_default_admin($email, $data['Password']); + $result = $member->canLogIn(); //protect against failed login - if ($success) { + if ($success && $result->isValid()) { return $member; + } else { + $result->addError(_t( + 'SilverStripe\\Security\\Member.ERRORWRONGCRED', + "The provided details don't seem to be correct. Please try again." + )); } } @@ -85,28 +91,23 @@ class Authenticator implements BaseAuthenticator // 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 (!$result->isValid()) { if ($member) { $member->registerFailedLogin(); } - $message = implode("; ", array_map( - function ($message) { - return $message['message']; - }, - $result->getMessages() - )); } else { - if ($member) { // How can success be true and member false? + if ($member) { $member->registerSuccessfulLogin(); + } else { + // A non-existing member occurred. This will make the result "valid" so let's invalidate + $result->addError(_t( + 'SilverStripe\\Security\\Member.ERRORWRONGCRED', + "The provided details don't seem to be correct. Please try again." + )); + $member = null; } } @@ -119,6 +120,7 @@ class Authenticator implements BaseAuthenticator * * @param array $data * @param Member $member + * @param boolean $success */ protected function recordLoginAttempt($data, $member, $success) { @@ -134,7 +136,7 @@ class Authenticator implements BaseAuthenticator } $attempt = LoginAttempt::create(); - if ($success) { + if ($success && $member) { // successful login (member is existing with matching password) $attempt->MemberID = $member->ID; $attempt->Status = 'Success'; @@ -160,7 +162,8 @@ class Authenticator implements BaseAuthenticator } /** - * @inherit + * @param $link + * @return LostPasswordHandler */ public function getLostPasswordHandler($link) { @@ -168,7 +171,8 @@ class Authenticator implements BaseAuthenticator } /** - * @inherit + * @param string $link + * @return ChangePasswordHandler */ public function getChangePasswordHandler($link) { @@ -176,7 +180,8 @@ class Authenticator implements BaseAuthenticator } /** - * @inherit + * @param string $link + * @return LoginHandler */ public function getLoginHandler($link) { @@ -184,7 +189,8 @@ class Authenticator implements BaseAuthenticator } /** - * @inherit + * @param string $link + * @return LogoutHandler */ public function getLogoutHandler($link) { diff --git a/src/Security/MemberAuthenticator/LoginForm.php b/src/Security/MemberAuthenticator/MemberLoginForm.php similarity index 97% rename from src/Security/MemberAuthenticator/LoginForm.php rename to src/Security/MemberAuthenticator/MemberLoginForm.php index c32298c67..8f9c941dc 100644 --- a/src/Security/MemberAuthenticator/LoginForm.php +++ b/src/Security/MemberAuthenticator/MemberLoginForm.php @@ -31,7 +31,7 @@ use SilverStripe\View\Requirements; * allowing extensions to "veto" execution by returning FALSE. * Arguments: $member containing the detected Member record */ -class LoginForm extends BaseLoginForm +class MemberLoginForm extends BaseLoginForm { /** @@ -87,7 +87,7 @@ class LoginForm extends BaseLoginForm $backURL = Session::get('BackURL'); } - if ($checkCurrentUser && Security::getCurrentUser() && Member::logged_in_session_exists()) { + if ($checkCurrentUser && Security::getCurrentUser()) { // @todo find a more elegant way to handle this $logoutAction = Security::logout_url(); $fields = FieldList::create( @@ -145,7 +145,7 @@ class LoginForm extends BaseLoginForm $this->setAttribute('autocomplete', 'off'); $emailField->setAttribute('autocomplete', 'off'); } - if (Security::config()->autologin_enabled) { + if (Security::config()->get('autologin_enabled')) { $fields->push( CheckboxField::create( "Remember", diff --git a/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php b/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php index 1408df9ba..4c0bac4bc 100644 --- a/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php +++ b/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php @@ -3,22 +3,24 @@ namespace SilverStripe\Security\MemberAuthenticator; use SilverStripe\Control\Cookie; +use SilverStripe\Control\HTTPResponse; use SilverStripe\ORM\DataObject; use SilverStripe\Security\Member; use SilverStripe\Control\HTTPRequest; -use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\Session; use SilverStripe\Control\Director; -use SilverStripe\Security\AuthenticationHandler as AuthenticationHandlerInterface; -use SilverStripe\ORM\ValidationException; +use SilverStripe\Security\AuthenticationHandler; use SilverStripe\Security\IdentityStore; /** * Authenticate a member pased on a session cookie */ -class SessionAuthenticationHandler implements AuthenticationHandlerInterface, IdentityStore +class SessionAuthenticationHandler implements AuthenticationHandler, IdentityStore { + /** + * @var string + */ private $sessionVariable; /** @@ -35,7 +37,6 @@ class SessionAuthenticationHandler implements AuthenticationHandlerInterface, Id * Set the session variable name used to track member ID * * @param string $sessionVariable - * @return null */ public function setSessionVariable($sessionVariable) { @@ -44,12 +45,11 @@ class SessionAuthenticationHandler implements AuthenticationHandlerInterface, Id /** * @inherit + * @param HTTPRequest $request + * @return null|DataObject|Member */ public function authenticateRequest(HTTPRequest $request) { - // @todo couple the session to a request object - // $session = $request->getSession(); - if ($id = Session::get($this->getSessionVariable())) { // If ID is a bad ID it will be treated as if the user is not logged in, rather than throwing a // ValidationException @@ -61,17 +61,18 @@ class SessionAuthenticationHandler implements AuthenticationHandlerInterface, Id /** * @inherit + * @param Member $member + * @param bool $persistent + * @param HTTPRequest|null $request + * @return HTTPResponse|void */ - public function logIn(Member $member, $persistent, HTTPRequest $request) + public function logIn(Member $member, $persistent = false, HTTPRequest $request = null) { - // @todo couple the session to a request object - // $session = $request->getSession(); - static::regenerateSessionId(); Session::set($this->getSessionVariable(), $member->ID); // This lets apache rules detect whether the user has logged in - // @todo make this a settign on the authentication handler + // @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); } @@ -82,7 +83,7 @@ class SessionAuthenticationHandler implements AuthenticationHandlerInterface, Id */ protected static function regenerateSessionId() { - if (!Member::config()->session_regenerate_id) { + if (!Member::config()->get('session_regenerate_id')) { return; } @@ -100,14 +101,13 @@ class SessionAuthenticationHandler implements AuthenticationHandlerInterface, Id @session_regenerate_id(true); } } - /** - * @inherit - */ - public function logOut(HTTPRequest $request) - { - // @todo couple the session to a request object - // $session = $request->getSession(); + /** + * @param HTTPRequest|null $request + * @return HTTPResponse|void + */ + public function logOut(HTTPRequest $request = null) + { Session::clear($this->getSessionVariable()); } } diff --git a/src/Security/Permission.php b/src/Security/Permission.php index cb71526c2..433339a3a 100644 --- a/src/Security/Permission.php +++ b/src/Security/Permission.php @@ -4,6 +4,7 @@ namespace SilverStripe\Security; use SilverStripe\Core\ClassInfo; use SilverStripe\Core\Resettable; +use SilverStripe\Dev\Debug; use SilverStripe\Dev\TestOnly; use SilverStripe\i18n\i18nEntityProvider; use SilverStripe\ORM\DB; @@ -131,10 +132,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 +172,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; diff --git a/src/Security/Security.php b/src/Security/Security.php index 2c995e537..f028e68ba 100644 --- a/src/Security/Security.php +++ b/src/Security/Security.php @@ -13,29 +13,21 @@ use SilverStripe\Control\HTTPResponse_Exception; use SilverStripe\Control\Session; use SilverStripe\Control\RequestHandler; use SilverStripe\Core\ClassInfo; -use SilverStripe\Core\Config\Config; use SilverStripe\Core\Convert; 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\Connect\Database; use SilverStripe\ORM\DataModel; use SilverStripe\ORM\DB; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\FieldType\DBField; +use SilverStripe\ORM\FieldType\DBHTMLText; use SilverStripe\ORM\ValidationResult; -use SilverStripe\Security\MemberAuthenticator\LogoutHandler; use SilverStripe\View\ArrayData; use SilverStripe\View\SSViewer; use SilverStripe\View\TemplateGlobalProvider; -use Exception; -use SilverStripe\View\ViewableData_Customised; use Subsite; -use SilverStripe\Core\Injector\Injector; /** * Implements a basic security model @@ -52,13 +44,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() @@ -66,7 +55,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() @@ -80,7 +69,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. @@ -102,7 +91,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 @@ -124,7 +113,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 @@ -163,7 +152,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 @@ -172,7 +161,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 @@ -181,7 +170,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 @@ -212,7 +201,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. */ - protected 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 @@ -223,19 +212,30 @@ class Security extends Controller implements TemplateGlobalProvider protected static $database_is_ready = false; /** - * @var array available authenticators + * @var Authenticator[] available authenticators */ - protected static $authenticators = []; - - /** - * @var string Default authenticator - */ - protected static $default_authenticator = MemberAuthenticator\Authenticator::class; + private static $authenticators = []; /** * @var Member Currently logged in user (if available) */ - private static $currentUser; + protected static $currentUser; + + /** + * @return array + */ + public static function getAuthenticators() + { + return self::$authenticators; + } + + /** + * @param array|Authenticator $authenticators + */ + public static function setAuthenticators(array $authenticators) + { + self::$authenticators = $authenticators; + } /** * @inheritdoc @@ -268,18 +268,16 @@ class Security extends Controller implements TemplateGlobalProvider /** * Get the selected authenticator for this request * - * @param $name string The identifier of the authenticator in your config + * @param string $name The identifier of the authenticator in your config * @return Authenticator Class name of Authenticator * @throws LogicException */ - protected function getAuthenticator($name) + protected function getAuthenticator($name = 'default') { - $authenticators = self::config()->get('authenticators'); - - $name = $name ?: 'default'; + $authenticators = static::$authenticators; if (isset($authenticators[$name])) { - return Injector::inst()->get($authenticators[$name]); + return $authenticators[$name]; } throw new LogicException('No valid authenticator found'); @@ -288,18 +286,16 @@ class Security extends Controller implements TemplateGlobalProvider /** * Get all registered authenticators * + * @param int $service The type of service that is requested * @return array Return an array of Authenticator objects */ - public static function getAuthenticators($service = Authenticator::LOGIN) + public function getApplicableAuthenticators($service = Authenticator::LOGIN) { - $authenticators = self::config()->get('authenticators'); + $authenticators = static::$authenticators; - foreach ($authenticators as $name => &$class) { - /** @var Authenticator $authenticator */ - $authenticator = Injector::inst()->get($class); - if ($authenticator->supportedServices() & $service) { - $class = $authenticator; - } else { + /** @var Authenticator $class */ + foreach ($authenticators as $name => $class) { + if (!($class->supportedServices() & $service)) { unset($authenticators[$name]); } } @@ -314,9 +310,10 @@ class Security extends Controller implements TemplateGlobalProvider * @return bool Returns TRUE if the authenticator is registered, FALSE * otherwise. */ - public static function hasAuthenticator($authenticator) + public function hasAuthenticator($authenticator) { - $authenticators = self::config()->get('authenticators'); + $authenticators = static::$authenticators; + return !empty($authenticators[$authenticator]); } @@ -357,13 +354,18 @@ class Security extends Controller implements TemplateGlobalProvider $response = ($controller) ? $controller->getResponse() : new HTTPResponse(); $response->setStatusCode(403); 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-aunthentication + $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; } @@ -373,15 +375,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." ) ); @@ -407,8 +409,8 @@ class Security extends Controller implements TemplateGlobalProvider $message = $messageSet['default']; } - Security::setLoginMessage($message, ValidationResult::TYPE_WARNING); - $loginResponse = (new Security())->login(new HTTPRequest('GET', '/')); + static::singleton()->setLoginMessage($message, ValidationResult::TYPE_WARNING); + $loginResponse = static::singleton()->login(); if ($loginResponse instanceof HTTPResponse) { return $loginResponse; } @@ -422,7 +424,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']); @@ -436,11 +438,17 @@ class Security extends Controller implements TemplateGlobalProvider )); } - public static function setCurrentUser($currentUser) + /** + * @param null|Member $currentUser + */ + public static function setCurrentUser($currentUser = null) { self::$currentUser = $currentUser; } + /** + * @return null|Member + */ public static function getCurrentUser() { return self::$currentUser; @@ -449,18 +457,21 @@ class Security extends Controller implements TemplateGlobalProvider /** * 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() { + Deprecation::notice('5.0.0', 'Now handled by delegateToMultipleHandlers'); + return array_map( function ($authenticator) { - return $authenticator->getLoginHandler($this->Link())->handleRequest($this->getRequest(), DataModel::inst()); + return [$authenticator->getLoginHandler($this->Link())->loginForm()]; }, - Security::getAuthenticators() + $this->getApplicableAuthenticators() ); } @@ -507,11 +518,16 @@ class Security extends Controller implements TemplateGlobalProvider $member = static::getCurrentUser(); if ($member) { // If we don't have a member, there's not much to log out. - /** @var Authenticator $authenticator */ - $authenticator = $this->getAuthenticator('default'); // Always use the default authenticator to log out - $handler = $authenticator->getLogOutHandler(Controller::join_links($this->Link(), 'logout')); - $result = $this->delegateToHandler($handler, 'default', []); - if ($result !== true) { + /** @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(); @@ -558,10 +574,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() + if (!$this->getLoginMessage() && ($member = static::getCurrentUser()) && $member->exists() + && $this->getRequest()->requestVar('BackURL') ) { return $this->redirectBack(); } @@ -590,20 +606,21 @@ class Security extends Controller implements TemplateGlobalProvider /** @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->setDataModel($this->model); $controller->doInit(); + return $controller; } /** * Combine the given forms into a formset with a tabbed interface * - * @param $forms + * @param array|Form[] $forms * @return string */ protected function generateLoginFormSet($forms) @@ -611,6 +628,7 @@ class Security extends Controller implements TemplateGlobalProvider $viewData = new ArrayData(array( 'Forms' => new ArrayList($forms), )); + return $viewData->renderWith( $this->getTemplatesFor('MultiAuthenticatorLogin') ); @@ -635,6 +653,7 @@ class Security extends Controller implements TemplateGlobalProvider if ($messageCast !== ValidationResult::CAST_HTML) { $message = Convert::raw2xml($message); } + return sprintf('

%s

', Convert::raw2att($messageType), $message); } @@ -645,14 +664,14 @@ class Security extends Controller implements TemplateGlobalProvider * @param string $messageType Message type. One of ValidationResult::TYPE_* * @param string $messageCast Message cast. One of ValidationResult::CAST_* */ - public static function setLoginMessage( + public function setLoginMessage( $message, $messageType = ValidationResult::TYPE_WARNING, $messageCast = ValidationResult::CAST_TEXT ) { - Session::set("Security.Message.message", $message); - Session::set("Security.Message.type", $messageType); - Session::set("Security.Message.cast", $messageCast); + Session::set('Security.Message.message', $message); + Session::set('Security.Message.type', $messageType); + Session::set('Security.Message.cast', $messageCast); } /** @@ -660,7 +679,7 @@ class Security extends Controller implements TemplateGlobalProvider */ public static function clearLoginMessage() { - Session::clear("Security.Message"); + Session::clear('Security.Message'); } @@ -670,37 +689,48 @@ class Security extends Controller implements TemplateGlobalProvider * For multiple authenticators, Security_MultiAuthenticatorLogin is used. * See getTemplatesFor and getIncludeTemplate for how to override template logic * - * @param $request + * @param null|HTTPRequest $request + * @param int $service * @return HTTPResponse|string Returns the "login" page as HTML code. * @throws HTTPResponse_Exception */ - public function login($request, $service = Authenticator::LOGIN) + public function login($request = null, $service = Authenticator::LOGIN) { // Check pre-login process if ($response = $this->preLogin()) { return $response; } + $authName = null; - $link = $this->link("login"); - - // Delegate to a single handler - Security/login//... - if (($name = $request->param('ID')) && self::hasAuthenticator($request->param('ID'))) { - $request->shift(); - - $authenticator = $this->getAuthenticator($name); - // @todo handle different Authenticator situations - if (!$authenticator->supportedServices() & $service) { - throw new HTTPResponse_Exception('Invalid Authenticator "' . $name . '" for login action', 418); - } - - $authenticators = [ $name => $authenticator ]; - - // Delegate to all of them, building a tabbed view - Security/login - } else { - $authenticators = static::getAuthenticators($service); + if (!$request) { + $request = $this->getRequest(); + } + + if ($request && $request->param('ID')) { + $authName = $request->param('ID'); + } + + $link = $this->Link('login'); + + // Delegate to a single handler - Security/login//... + if ($authName && $this->hasAuthenticator($authName) + ) { + if ($request) { + $request->shift(); + } + + $authenticator = $this->getAuthenticator($authName); + + if (!$authenticator->supportedServices() & $service) { + throw new HTTPResponse_Exception('Invalid Authenticator "' . $authName . '" for login action', 418); + } + + $handlers = [$authName => $authenticator]; + } else { + // Delegate to all of them, building a tabbed view - Security/login + $handlers = $this->getApplicableAuthenticators($service); } - $handlers = $authenticators; array_walk( $handlers, function (&$auth, $name) use ($link) { @@ -721,9 +751,10 @@ class Security extends Controller implements TemplateGlobalProvider * * If a single handler is passed, delegateToHandler() will be called instead * + * @param array|RequestHandler[] $handlers * @param string $title The title of the form * @param array $templates - * @return array|HTTPResponse|RequestHandler|\SilverStripe\ORM\FieldType\DBHTMLText|string + * @return array|HTTPResponse|RequestHandler|DBHTMLText|string */ protected function delegateToMultipleHandlers(array $handlers, $title, array $templates) { @@ -736,7 +767,7 @@ class Security extends Controller implements TemplateGlobalProvider // Process each of the handlers $results = array_map( function ($handler) { - return $handler->handleRequest($this->getRequest(), \SilverStripe\ORM\DataModel::inst()); + return $handler->handleRequest($this->getRequest(), DataModel::inst()); }, $handlers ); @@ -754,7 +785,7 @@ class Security extends Controller implements TemplateGlobalProvider } if (!$forms) { - throw new \LogicException("No authenticators found compatible with a tabbed login"); + throw new \LogicException('No authenticators found compatible with a tabbed login'); } return $this->renderWrappedController( @@ -770,11 +801,12 @@ class Security extends Controller implements TemplateGlobalProvider * Delegate to another RequestHandler, rendering any fragment arrays into an appropriate. * controller. * + * @param RequestHandler $handler * @param string $title The title of the form * @param array $templates - * @return array|HTTPResponse|RequestHandler|\SilverStripe\ORM\FieldType\DBHTMLText|string + * @return array|HTTPResponse|RequestHandler|DBHTMLText|string */ - protected function delegateToHandler(RequestHandler $handler, $title, array $templates) + protected function delegateToHandler(RequestHandler $handler, $title, array $templates = []) { $result = $handler->handleRequest($this->getRequest(), DataModel::inst()); @@ -792,7 +824,7 @@ class Security extends Controller implements TemplateGlobalProvider * @param string $title string The title to give the security page * @param array $fragments A map of objects to render into the page, e.g. "Form" * @param array $templates An array of templates to use for the render - * @return HTTPResponse|\SilverStripe\ORM\FieldType\DBHTMLText + * @return HTTPResponse|DBHTMLText */ protected function renderWrappedController($title, array $fragments, array $templates) { @@ -824,7 +856,7 @@ class Security extends Controller implements TemplateGlobalProvider public function basicauthlogin() { - $member = BasicAuth::requireLogin($this->getRequest(), "SilverStripe login", 'ADMIN'); + $member = BasicAuth::requireLogin($this->getRequest(), 'SilverStripe login', 'ADMIN'); static::setCurrentUser($member); } @@ -835,12 +867,17 @@ class Security extends Controller implements TemplateGlobalProvider */ public function lostpassword() { - $handler = $this->getAuthenticator('default')->getLostPasswordHandler( - Controller::join_links($this->link(), 'lostpassword') - ); + $handlers = []; + $authenticators = $this->getApplicableAuthenticators(Authenticator::RESET_PASSWORD); + /** @var Authenticator $authenticator */ + foreach ($authenticators as $authenticator) { + $handlers[] = $authenticator->getLostPasswordHandler( + Controller::join_links($this->Link(), 'lostpassword') + ); + } - return $this->delegateToHandler( - $handler, + return $this->delegateToMultipleHandlers( + $handlers, _t('SilverStripe\\Security\\Security.LOSTPASSWORDHEADER', 'Lost Password'), $this->getTemplatesFor('lostpassword') ); @@ -860,79 +897,18 @@ class Security extends Controller implements TemplateGlobalProvider */ public function changepassword() { - $controller = $this->getResponseController(_t('SilverStripe\\Security\\Security.CHANGEPASSWORDHEADER', 'Change your password')); - - // if the controller calls Director::redirect(), this will break early - if (($response = $controller->getResponse()) && $response->isFinished()) { - return $response; + /** @var array|Authenticator[] $authenticators */ + $authenticators = $this->getApplicableAuthenticators(Authenticator::CHANGE_PASSWORD); + $handlers = []; + foreach ($authenticators as $authenticator) { + $handlers[] = $authenticator->getChangePasswordHandler($this->Link('changepassword')); } - // Extract the member from the URL. - /** @var Member $member */ - $member = null; - if (isset($_REQUEST['m'])) { - $member = Member::get()->filter('ID', (int)$_REQUEST['m'])->first(); - } - - // Check whether we are merely changin password, or resetting. - if (isset($_REQUEST['t']) && $member && $member->validateAutoLoginToken($_REQUEST['t'])) { - // On first valid password reset request redirect to the same URL without hash to avoid referrer leakage. - - // if there is a current member, they should be logged out - if ($curMember = static::getCurrentUser()) { - /** @var LogoutHandler $handler */ - $handler = $this->getAuthenticator('default')->getLogoutHandler($this->Link('logout')); - $handler->doLogOut($curMember); - } - - // Store the hash for the change password form. Will be unset after reload within the ChangePasswordForm. - Session::set('AutoLoginHash', $member->encryptWithUserSettings($_REQUEST['t'])); - - return $this->redirect($this->Link('changepassword')); - } elseif (Session::get('AutoLoginHash')) { - // Subsequent request after the "first load with hash" (see previous if clause). - $customisedController = $controller->customise(array( - 'Content' => DBField::create_field( - 'HTMLFragment', - '

' . _t('SilverStripe\\Security\\Security.ENTERNEWPASSWORD', 'Please enter a new password.') . '

' - ), - 'Form' => $this->ChangePasswordForm(), - )); - } elseif (static::getCurrentUser()) { - // Logged in user requested a password change form. - $customisedController = $controller->customise(array( - 'Content' => DBField::create_field( - 'HTMLFragment', - '

' . _t('SilverStripe\\Security\\Security.CHANGEPASSWORDBELOW', 'You can change your password below.') . '

' - ), - 'Form' => $this->ChangePasswordForm())); - } else { - // Show friendly message if it seems like the user arrived here via password reset feature. - if (isset($_REQUEST['m']) || isset($_REQUEST['t'])) { - $customisedController = $controller->customise( - array('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') - ] - ) - )) - ); - } else { - return self::permissionFailure( - $this, - _t('SilverStripe\\Security\\Security.ERRORPASSWORDPERMISSION', 'You must be logged in in order to change your password!') - ); - } - } - - return $customisedController->renderWith($this->getTemplatesFor('changepassword')); + return $this->delegateToMultipleHandlers( + $handlers, + _t('SilverStripe\\Security\\Security.CHANGEPASSWORDHEADER', 'Change your password'), + $this->getTemplatesFor('changepassword') + ); } /** @@ -949,21 +925,8 @@ class Security extends Controller implements TemplateGlobalProvider public static function getPasswordResetLink($member, $autologinToken) { $autologinToken = urldecode($autologinToken); - $selfControllerClass = __CLASS__; - /** @var static $selfController */ - $selfController = new $selfControllerClass(); - return $selfController->Link('changepassword') . "?m={$member->ID}&t=$autologinToken"; - } - /** - * Factory method for the lost password form - * - * @skipUpgrade - * @return MemberAuthenticator\ChangePasswordForm - */ - public function ChangePasswordForm() - { - return MemberAuthenticator\ChangePasswordForm::create($this, 'ChangePasswordForm'); + return static::singleton()->Link('changepassword') . "?m={$member->ID}&t=$autologinToken"; } /** @@ -976,6 +939,7 @@ class Security extends Controller implements TemplateGlobalProvider public function getTemplatesFor($action) { $templates = SSViewer::get_templates_by_class(static::class, "_{$action}", __CLASS__); + return array_merge( $templates, [ @@ -1002,12 +966,7 @@ class Security extends Controller implements TemplateGlobalProvider */ public static function findAnAdministrator() { - // coupling to subsites module - $origSubsite = null; - if (is_callable('Subsite::changeSubsite')) { - $origSubsite = Subsite::currentSubsiteID(); - Subsite::changeSubsite(0); - } + static::singleton()->extend('beforeFindAdministrator'); /** @var Member $member */ $member = null; @@ -1015,19 +974,13 @@ class Security extends Controller implements TemplateGlobalProvider // find a group with ADMIN permission $adminGroup = Permission::get_groups_by_permission('ADMIN')->first(); - if (is_callable('Subsite::changeSubsite')) { - Subsite::changeSubsite($origSubsite); - } - - if ($adminGroup) { - $member = $adminGroup->Members()->First(); - } - if (!$adminGroup) { Group::singleton()->requireDefaultRecords(); $adminGroup = Permission::get_groups_by_permission('ADMIN')->first(); } + $member = $adminGroup->Members()->First(); + if (!$member) { Member::singleton()->requireDefaultRecords(); $member = Permission::get_members_by_permission('ADMIN')->first(); @@ -1049,6 +1002,8 @@ class Security extends Controller implements TemplateGlobalProvider ->add($member); } + static::singleton()->extend('afterFindAdministrator'); + return $member; } @@ -1083,6 +1038,7 @@ class Security extends Controller implements TemplateGlobalProvider self::$default_username = $username; self::$default_password = $password; + return true; } @@ -1105,11 +1061,10 @@ class Security extends Controller implements TemplateGlobalProvider /** * Check that the default admin account has been set. - * @todo Check if we _actually_ only want this to work on dev */ public static function has_default_admin() { - return !empty(self::$default_username) && !empty(self::$default_password) && (Director::get_environment_type() === 'dev'); + return !empty(self::$default_username) && !empty(self::$default_password); } /** @@ -1172,8 +1127,8 @@ class Security extends Controller implements TemplateGlobalProvider $salt = ($salt) ? $salt : $e->salt($password); return array( - 'password' => $e->encrypt($password, $salt, $member), - 'salt' => $salt, + 'password' => $e->encrypt($password, $salt, $member), + 'salt' => $salt, 'algorithm' => $algorithm, 'encryptor' => $e ); @@ -1251,29 +1206,6 @@ class Security extends Controller implements TemplateGlobalProvider self::$force_database_is_ready = $isReady; } - /** - * Enable or disable recording of login attempts - * through the {@link LoginRecord} object. - * - * @deprecated 4.0 Use the "Security.login_recording" config setting instead - * @param boolean $bool - */ - public static function set_login_recording($bool) - { - Deprecation::notice('4.0', 'Use the "Security.login_recording" config setting instead'); - self::$login_recording = (bool)$bool; - } - - /** - * @deprecated 4.0 Use the "Security.login_recording" config setting instead - * @return boolean - */ - public static function login_recording() - { - Deprecation::notice('4.0', 'Use the "Security.login_recording" config setting instead'); - return self::$login_recording; - } - /** * @config * @var string Set the default login dest @@ -1345,11 +1277,11 @@ class Security extends Controller implements TemplateGlobalProvider public static function get_template_global_variables() { return array( - "LoginURL" => "login_url", - "LogoutURL" => "logout_url", + "LoginURL" => "login_url", + "LogoutURL" => "logout_url", "LostPasswordURL" => "lost_password_url", - "CurrentMember" => "getCurrentUser", - "currentUser" => "getCurrentUser" + "CurrentMember" => "getCurrentUser", + "currentUser" => "getCurrentUser" ); } } diff --git a/src/View/ViewableData.php b/src/View/ViewableData.php index b681fd530..da2db5944 100644 --- a/src/View/ViewableData.php +++ b/src/View/ViewableData.php @@ -312,7 +312,7 @@ class ViewableData implements IteratorAggregate */ public function castingHelper($field) { - $specs = $this->config()->get('casting'); + $specs = static::config()->get('casting'); if (isset($specs[$field])) { return $specs[$field]; } @@ -329,7 +329,7 @@ class ViewableData implements IteratorAggregate } // Fall back to default_cast - $default = self::config()->get('default_cast'); + $default = $this->config()->get('default_cast'); if (empty($default)) { throw new Exception("No default_cast"); } diff --git a/tests/behat/features/login.feature b/tests/behat/features/login.feature index f4ae26290..577b20c25 100644 --- a/tests/behat/features/login.feature +++ b/tests/behat/features/login.feature @@ -6,7 +6,7 @@ Feature: Log in Scenario: Bad login Given I log in with "bad@example.com" and "badpassword" - Then I will see a "error" log-in message + Then I should see "The provided details don't seem to be correct" Scenario: Valid login Given I am logged in with "ADMIN" permissions diff --git a/tests/php/Forms/GridField/GridFieldEditButtonTest.php b/tests/php/Forms/GridField/GridFieldEditButtonTest.php index 03166cf7b..8c6c5abaa 100644 --- a/tests/php/Forms/GridField/GridFieldEditButtonTest.php +++ b/tests/php/Forms/GridField/GridFieldEditButtonTest.php @@ -64,7 +64,7 @@ class GridFieldEditButtonTest extends SapphireTest public function testShowEditLinks() { if (Security::getCurrentUser()) { - Security::getCurrentUser()->logOut(); + Security::setCurrentUser(null); } $content = new CSSContentParser($this->gridField->FieldHolder()); diff --git a/tests/php/Security/BasicAuthTest.php b/tests/php/Security/BasicAuthTest.php index 1092b7fc5..031fd4438 100644 --- a/tests/php/Security/BasicAuthTest.php +++ b/tests/php/Security/BasicAuthTest.php @@ -87,7 +87,7 @@ class BasicAuthTest extends FunctionalTest $_SERVER['PHP_AUTH_USER'] = 'user-in-mygroup@test.com'; $_SERVER['PHP_AUTH_PW'] = 'test'; - $response = Director::test('BasicAuthTest_ControllerSecuredWithPermission', null, $_SESSION, null, null, $_SERVER);; + $response = Director::test('BasicAuthTest_ControllerSecuredWithPermission', null, $_SESSION, null, null, $_SERVER); $this->assertEquals(200, $response->getStatusCode(), 'Valid user with required permission has access'); $_SERVER['PHP_AUTH_USER'] = $origUser; diff --git a/tests/php/Security/MemberAuthenticatorTest.php b/tests/php/Security/MemberAuthenticatorTest.php index 4c2bb4896..699ccac8c 100644 --- a/tests/php/Security/MemberAuthenticatorTest.php +++ b/tests/php/Security/MemberAuthenticatorTest.php @@ -3,22 +3,18 @@ namespace SilverStripe\Security\Tests; use SilverStripe\Core\Injector\Injector; -use SilverStripe\ORM\DataObject; +use SilverStripe\ORM\DataModel; use SilverStripe\ORM\FieldType\DBDatetime; -use SilverStripe\ORM\ValidationResult; -use SilverStripe\Security\MemberAuthenticator\CMSAuthenticator; -use SilverStripe\Security\PasswordEncryptor; -use SilverStripe\Security\PasswordEncryptor_PHPHash; +use SilverStripe\Security\Authenticator; +use SilverStripe\Security\MemberAuthenticator\CMSMemberAuthenticator; +use SilverStripe\Security\MemberAuthenticator\CMSMemberLoginForm; +use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator; use SilverStripe\Security\Security; use SilverStripe\Security\Member; -use SilverStripe\Security\MemberAuthenticator\Authenticator; -use SilverStripe\Security\MemberAuthenticator\LoginForm; -use SilverStripe\Security\CMSMemberLoginForm; +use SilverStripe\Security\MemberAuthenticator\MemberLoginForm; use SilverStripe\Security\IdentityStore; use SilverStripe\Core\Config\Config; use SilverStripe\Dev\SapphireTest; -use SilverStripe\Forms\FieldList; -use SilverStripe\Forms\Form; use SilverStripe\Control\HTTPRequest; class MemberAuthenticatorTest extends SapphireTest @@ -60,31 +56,31 @@ class MemberAuthenticatorTest extends SapphireTest public function testGenerateLoginForm() { - $authenticator = new Authenticator(); + $authenticator = new MemberAuthenticator(); $controller = new Security(); // Create basic login form $frontendResponse = $authenticator ->getLoginHandler($controller->link()) - ->handleRequest(new HTTPRequest('get', '/'), \SilverStripe\ORM\DataModel::inst()); + ->handleRequest(new HTTPRequest('get', '/'), DataModel::inst()); $this->assertTrue(is_array($frontendResponse)); $this->assertTrue(isset($frontendResponse['Form'])); - $this->assertTrue($frontendResponse['Form'] instanceof LoginForm); + $this->assertTrue($frontendResponse['Form'] instanceof MemberLoginForm); } - /* TO DO - reenable public function testGenerateCMSLoginForm() { - $authenticator = new Authenticator(); + /** @var CMSMemberAuthenticator $authenticator */ + $authenticator = new CMSMemberAuthenticator(); // Supports cms login form - $this->assertTrue(MemberAuthenticator::supports_cms()); - $cmsForm = MemberAuthenticator::get_cms_login_form($controller); + $this->assertGreaterThan(0, ($authenticator->supportedServices() & Authenticator::CMS_LOGIN)); + $cmsHandler = $authenticator->getLoginHandler('/'); + $cmsForm = $cmsHandler->loginForm(); $this->assertTrue($cmsForm instanceof CMSMemberLoginForm); } - */ /** @@ -92,7 +88,7 @@ class MemberAuthenticatorTest extends SapphireTest */ public function testAuthenticateByTempID() { - $authenticator = new CMSAuthenticator(); + $authenticator = new CMSMemberAuthenticator(); $member = new Member(); $member->Email = 'test1@test.com'; @@ -105,7 +101,7 @@ class MemberAuthenticatorTest extends SapphireTest $this->assertEmpty($tempID); // If the user logs in then they have a temp id - Injector::inst()->get(IdentityStore::class)->logIn($member, true, new HTTPRequest('GET', '/')); + Injector::inst()->get(IdentityStore::class)->logIn($member, true); $tempID = $member->TempIDHash; $this->assertNotEmpty($tempID); @@ -120,7 +116,7 @@ class MemberAuthenticatorTest extends SapphireTest $this->assertNotEmpty($result); $this->assertEquals($result->ID, $member->ID); - $this->assertEmpty($message); + $this->assertTrue($message->isValid()); // Test incorrect login $result = $authenticator->authenticate( @@ -132,9 +128,10 @@ class MemberAuthenticatorTest extends SapphireTest ); $this->assertEmpty($result); + $messages = $message->getMessages(); $this->assertEquals( _t('SilverStripe\\Security\\Member.ERRORWRONGCRED', 'The provided details don\'t seem to be correct. Please try again.'), - $message + $messages[0]['message'] ); } @@ -143,7 +140,7 @@ class MemberAuthenticatorTest extends SapphireTest */ public function testDefaultAdmin() { - $authenticator = new Authenticator(); + $authenticator = new MemberAuthenticator(); // Test correct login $result = $authenticator->authenticate( @@ -155,7 +152,7 @@ class MemberAuthenticatorTest extends SapphireTest ); $this->assertNotEmpty($result); $this->assertEquals($result->Email, Security::default_admin_username()); - $this->assertEmpty($message); + $this->assertTrue($message->isValid()); // Test incorrect login $result = $authenticator->authenticate( @@ -165,16 +162,17 @@ class MemberAuthenticatorTest extends SapphireTest ), $message ); + $messages = $message->getMessages(); $this->assertEmpty($result); $this->assertEquals( 'The provided details don\'t seem to be correct. Please try again.', - $message + $messages[0]['message'] ); } public function testDefaultAdminLockOut() { - $authenticator = new Authenticator(); + $authenticator = new MemberAuthenticator(); Config::inst()->update(Member::class, 'lock_out_after_incorrect_logins', 1); Config::inst()->update(Member::class, 'lock_out_delay_mins', 10); @@ -185,8 +183,7 @@ class MemberAuthenticatorTest extends SapphireTest [ 'Email' => 'admin', 'Password' => 'wrongpassword' - ], - $dummy + ] ); $this->assertFalse(Member::default_admin()->canLogin()->isValid()); diff --git a/tests/php/Security/MemberTest.php b/tests/php/Security/MemberTest.php index abbb5efc9..5d9f5b931 100644 --- a/tests/php/Security/MemberTest.php +++ b/tests/php/Security/MemberTest.php @@ -11,7 +11,6 @@ use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DB; use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\Security\Member; -use SilverStripe\Security\MemberAuthenticator; use SilverStripe\Security\Security; use SilverStripe\Security\MemberPassword; use SilverStripe\Security\Group; @@ -875,7 +874,7 @@ class MemberTest extends FunctionalTest { $m1 = $this->objFromFixture(Member::class, 'grouplessmember'); - Injector::inst()->get(IdentityStore::class)->logIn($m1, true, new HTTPRequest('GET', '/')); + Injector::inst()->get(IdentityStore::class)->logIn($m1, true); $hashes = RememberLoginHash::get()->filter('MemberID', $m1->ID); $this->assertEquals($hashes->count(), 1); @@ -891,7 +890,7 @@ class MemberTest extends FunctionalTest */ $m1 = $this->objFromFixture(Member::class, 'noexpiry'); - Injector::inst()->get(IdentityStore::class)->logIn($m1, true, new HTTPRequest('GET', '/')); + Injector::inst()->get(IdentityStore::class)->logIn($m1, true); $firstHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->first(); $this->assertNotNull($firstHash); @@ -970,7 +969,7 @@ class MemberTest extends FunctionalTest * @var Member $m1 */ $m1 = $this->objFromFixture(Member::class, 'noexpiry'); - Injector::inst()->get(IdentityStore::class)->logIn($m1, true, new HTTPRequest('GET', '/')); + Injector::inst()->get(IdentityStore::class)->logIn($m1, true); $firstHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->first(); $this->assertNotNull($firstHash); @@ -1029,10 +1028,10 @@ class MemberTest extends FunctionalTest $m1 = $this->objFromFixture(Member::class, 'noexpiry'); // First device - Injector::inst()->get(IdentityStore::class)->logIn($m1, true, new HTTPRequest('GET', '/')); + Injector::inst()->get(IdentityStore::class)->logIn($m1, true); Cookie::set('alc_device', null); // Second device - Injector::inst()->get(IdentityStore::class)->logIn($m1, true, new HTTPRequest('GET', '/')); + Injector::inst()->get(IdentityStore::class)->logIn($m1, true); // Hash of first device $firstHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->first(); @@ -1105,7 +1104,7 @@ class MemberTest extends FunctionalTest // Logging out from any device when all login hashes should be removed RememberLoginHash::config()->update('logout_across_devices', true); - Injector::inst()->get(IdentityStore::class)->logIn($m1, true, new HTTPRequest('GET', '/')); + Injector::inst()->get(IdentityStore::class)->logIn($m1, true); $response = $this->get('Security/logout', $this->session()); $this->assertEquals( RememberLoginHash::get()->filter('MemberID', $m1->ID)->count(), @@ -1423,17 +1422,17 @@ class MemberTest extends FunctionalTest /** @var Member $adminMember */ $adminMember = $this->objFromFixture(Member::class, 'admin'); - $memberID = Member::actAs($adminMember, function () { - return Member::currentUserID(); + $member = Member::actAs($adminMember, function () { + return Security::getCurrentUser(); }); - $this->assertEquals($adminMember->ID, $memberID); + $this->assertEquals($adminMember->ID, $member->ID); // Check nesting - $memberID = Member::actAs($adminMember, function () { + $member = Member::actAs($adminMember, function () { return Member::actAs(null, function () { - return Member::currentUserID(); + return Security::getCurrentUser(); }); }); - $this->assertEmpty($memberID); + $this->assertEmpty($member); } } diff --git a/tests/php/Security/SecurityTest.php b/tests/php/Security/SecurityTest.php index 1b21b6c6d..adbf3ffc4 100644 --- a/tests/php/Security/SecurityTest.php +++ b/tests/php/Security/SecurityTest.php @@ -2,18 +2,16 @@ namespace SilverStripe\Security\Tests; -use PhpConsole\Auth; +use SilverStripe\Dev\Debug; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\ORM\FieldType\DBClassName; use SilverStripe\ORM\DB; use SilverStripe\ORM\ValidationResult; -use SilverStripe\Security\Authenticator; use SilverStripe\Security\LoginAttempt; use SilverStripe\Security\Member; -use SilverStripe\Security\MemberAuthenticator; +use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator; use SilverStripe\Security\Security; -use SilverStripe\Security\Permission; use SilverStripe\Core\Config\Config; use SilverStripe\Core\Convert; use SilverStripe\Dev\FunctionalTest; @@ -48,13 +46,9 @@ class SecurityTest extends FunctionalTest protected function setUp() { - // This test assumes that MemberAuthenticator is present and the default - // $this->priorAuthenticators = Authenticator::get_authenticators(); - // $this->priorDefaultAuthenticator = Authenticator::get_default_authenticator(); - // Set to an empty array of authenticators to enable the default - Config::modify()->set(Authenticator::class, 'authenticators', []); - Config::modify()->set(Authenticator::class, 'default_authenticator', MemberAuthenticator::class); + Config::modify()->set(MemberAuthenticator::class, 'authenticators', []); + Config::modify()->set(MemberAuthenticator::class, 'default_authenticator', MemberAuthenticator::class); // And that the unique identified field is 'Email' $this->priorUniqueIdentifierField = Member::config()->unique_identifier_field; @@ -233,7 +227,7 @@ class SecurityTest extends FunctionalTest /* View the Security/login page */ $response = $this->get(Config::inst()->get(Security::class, 'login_url')); - $items = $this->cssParser()->getBySelector('#LoginForm_LoginForm input.action'); + $items = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm input.action'); /* We have only 1 input, one to allow the user to log in as someone else */ $this->assertEquals(count($items), 1, 'There is 1 input, allowing the user to log in as someone else.'); @@ -242,7 +236,7 @@ class SecurityTest extends FunctionalTest /* Submit the form, using only the logout action and a hidden field for the authenticator */ $response = $this->submitForm( - 'LoginForm_LoginForm', + 'MemberLoginForm_LoginForm', null, array( 'action_logout' => 1, @@ -267,7 +261,7 @@ class SecurityTest extends FunctionalTest /* Attempt to get into the admin section */ $response = $this->get(Config::inst()->get(Security::class, 'login_url')); - $items = $this->cssParser()->getBySelector('#LoginForm_LoginForm input.text'); + $items = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm input.text'); /* We have 2 text inputs - one for email, and another for the password */ $this->assertEquals(count($items), 2, 'There are 2 inputs - one for email, another for password'); @@ -286,11 +280,11 @@ class SecurityTest extends FunctionalTest $this->get(Config::inst()->get(Security::class, 'login_url')); $items = $this ->cssParser() - ->getBySelector('#LoginForm_LoginForm #LoginForm_LoginForm_Email'); + ->getBySelector('#MemberLoginForm_LoginForm #MemberLoginForm_LoginForm_Email'); $this->assertEquals(1, count($items)); $this->assertEmpty((string)$items[0]->attributes()->value); $this->assertEquals('off', (string)$items[0]->attributes()->autocomplete); - $form = $this->cssParser()->getBySelector('#LoginForm_LoginForm'); + $form = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm'); $this->assertEquals(1, count($form)); $this->assertEquals('off', (string)$form[0]->attributes()->autocomplete); @@ -300,11 +294,11 @@ class SecurityTest extends FunctionalTest $this->get(Config::inst()->get(Security::class, 'login_url')); $items = $this ->cssParser() - ->getBySelector('#LoginForm_LoginForm #LoginForm_LoginForm_Email'); + ->getBySelector('#MemberLoginForm_LoginForm #MemberLoginForm_LoginForm_Email'); $this->assertEquals(1, count($items)); $this->assertEquals('myuser@silverstripe.com', (string)$items[0]->attributes()->value); $this->assertNotEquals('off', (string)$items[0]->attributes()->autocomplete); - $form = $this->cssParser()->getBySelector('#LoginForm_LoginForm'); + $form = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm'); $this->assertEquals(1, count($form)); $this->assertNotEquals('off', (string)$form[0]->attributes()->autocomplete); } @@ -482,11 +476,11 @@ class SecurityTest extends FunctionalTest Member::config()->lock_out_delay_mins = 15; // Login with a wrong password for more than the defined threshold - for ($i = 1; $i <= Member::config()->lock_out_after_incorrect_logins+1; $i++) { + for ($i = 1; $i <= (Member::config()->lock_out_after_incorrect_logins+1); $i++) { $this->doTestLoginForm('testuser@example.com', 'incorrectpassword'); $member = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test')); - if ($i < Member::config()->lock_out_after_incorrect_logins) { + if ($i < Member::config()->get('lock_out_after_incorrect_logins')) { $this->assertNull( $member->LockedOutUntil, 'User does not have a lockout time set if under threshold for failed attempts' @@ -505,18 +499,16 @@ class SecurityTest extends FunctionalTest 'User has a lockout time set after too many failed attempts' ); } - - $msg = _t( - 'SilverStripe\\Security\\Member.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' => Member::config()->lock_out_delay_mins) - ); - if ($i > Member::config()->lock_out_after_incorrect_logins) { - $this->assertHasMessage($msg); - } } + $msg = _t( + 'SilverStripe\\Security\\Member.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' => Member::config()->lock_out_delay_mins) + ); + $this->assertHasMessage($msg); + $this->doTestLoginForm('testuser@example.com', '1nitialPassword'); $this->assertNull( @@ -597,14 +589,14 @@ class SecurityTest extends FunctionalTest $attempt = DataObject::get_one( LoginAttempt::class, array( - '"LoginAttempt"."Email"' => 'testuser@example.com' + '"LoginAttempt"."Email"' => 'testuser@example.com' ) ); $this->assertTrue(is_object($attempt)); $member = DataObject::get_one( Member::class, array( - '"Member"."Email"' => 'testuser@example.com' + '"Member"."Email"' => 'testuser@example.com' ) ); $this->assertEquals($attempt->Status, 'Failure'); @@ -696,7 +688,7 @@ class SecurityTest extends FunctionalTest $this->get(Config::inst()->get(Security::class, 'login_url')); return $this->submitForm( - "LoginForm_LoginForm", + "MemberLoginForm_LoginForm", null, array( 'Email' => $email, @@ -750,7 +742,7 @@ class SecurityTest extends FunctionalTest */ protected function getValidationResult() { - $result = $this->session()->inst_get('FormInfo.LoginForm_LoginForm.result'); + $result = $this->session()->inst_get('FormInfo.MemberLoginForm_LoginForm.result'); if ($result) { return unserialize($result); } From 5fce3308b4f2242b23bb30d974f55ae91a7a0ddb Mon Sep 17 00:00:00 2001 From: Simon Erkelens Date: Thu, 8 Jun 2017 19:12:28 +1200 Subject: [PATCH 7/9] Move LostPasswordHandler in to it's own class. - Moved the Authenticators from statics to normal - Moved MemberLoginForm methods to the getFormFields as they make more sense there - Did some spring-cleaning on the LostPasswordHandler - Removed the BuildResponse from ChangePasswordHandler after spring cleaning --- .../ChangePasswordHandler.php | 87 +++++++++---------- .../MemberAuthenticator/LostPasswordForm.php | 45 ++++++++++ .../LostPasswordHandler.php | 67 +++++++------- .../MemberAuthenticator/MemberLoginForm.php | 30 ++++--- src/Security/Security.php | 16 ++-- 5 files changed, 141 insertions(+), 104 deletions(-) create mode 100644 src/Security/MemberAuthenticator/LostPasswordForm.php diff --git a/src/Security/MemberAuthenticator/ChangePasswordHandler.php b/src/Security/MemberAuthenticator/ChangePasswordHandler.php index 0bc6e8369..a6e706099 100644 --- a/src/Security/MemberAuthenticator/ChangePasswordHandler.php +++ b/src/Security/MemberAuthenticator/ChangePasswordHandler.php @@ -59,7 +59,7 @@ class ChangePasswordHandler extends RequestHandler * Handle the change password request * @todo this could use some spring cleaning * - * @return HTTPResponse|DBHTMLText + * @return array|HTTPResponse */ public function changepassword() { @@ -91,7 +91,10 @@ class ChangePasswordHandler extends RequestHandler ); // Subsequent request after the "first load with hash" (see previous if clause). - return $this->buildResponse($message); + return [ + 'Content' => $message, + 'Form' => $this->changePasswordForm() + ]; } if (Security::getCurrentUser()) { @@ -104,29 +107,32 @@ class ChangePasswordHandler extends RequestHandler ) . '

' ); - return $this->buildResponse($message); + return [ + 'Content' => $message, + 'Form' => $this->changePasswordForm() + ]; } // Show a friendly message saying the login token has expired if ($token !== null && $member && !$member->validateAutoLoginToken($token)) { - $customisedController = Controller::curr()->customise( - array( - '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') - ] - ) + $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 $customisedController->renderWith('changepassword'); + return [ + 'Content' => $message, + ]; } // Someone attempted to go to changepassword without token or being logged in @@ -139,21 +145,6 @@ class ChangePasswordHandler extends RequestHandler ); } - /** - * @param DBField $message - * @return DBHTMLText - */ - protected function buildResponse($message) - { - $customisedController = Controller::curr()->customise( - [ - 'Content' => $message, - 'Form' => $this->changePasswordForm() - ] - ); - - return $customisedController->renderWith(Security::singleton()->getTemplatesFor('changepassword')); - } /** * @param Member $member @@ -204,9 +195,10 @@ class ChangePasswordHandler extends RequestHandler * Change the password * * @param array $data The user submitted data + * @param ChangePasswordForm $form * @return HTTPResponse */ - public function doChangePassword(array $data) + public function doChangePassword(array $data, $form) { $member = Security::getCurrentUser(); // The user was logged in, check the current password @@ -215,12 +207,12 @@ class ChangePasswordHandler extends RequestHandler !$member->checkPassword($data['OldPassword'])->isValid() ) ) { - $this->form->sessionMessage( + $form->sessionMessage( _t( 'SilverStripe\\Security\\Member.ERRORPASSWORDNOTMATCH', - "Your current password does not match, please try again" + 'Your current password does not match, please try again' ), - "bad" + 'bad' ); // redirect back to the form, instead of using redirectBack() which could send the user elsewhere. @@ -242,12 +234,12 @@ class ChangePasswordHandler extends RequestHandler // Check the new password if (empty($data['NewPassword1'])) { - $this->form->sessionMessage( + $form->sessionMessage( _t( 'SilverStripe\\Security\\Member.EMPTYNEWPASSWORD', "The new password can't be empty, please try again" ), - "bad" + 'bad' ); // redirect back to the form, instead of using redirectBack() which could send the user elsewhere. @@ -256,12 +248,12 @@ class ChangePasswordHandler extends RequestHandler // Fail if passwords do not match if ($data['NewPassword1'] !== $data['NewPassword2']) { - $this->form->sessionMessage( + $form->sessionMessage( _t( 'SilverStripe\\Security\\Member.ERRORNEWPASSWORD', - "You have entered your new password differently, try again" + 'You have entered your new password differently, try again' ), - "bad" + 'bad' ); // redirect back to the form, instead of using redirectBack() which could send the user elsewhere. @@ -271,7 +263,7 @@ class ChangePasswordHandler extends RequestHandler // Check if the new password is accepted $validationResult = $member->changePassword($data['NewPassword1']); if (!$validationResult->isValid()) { - $this->form->setSessionValidationResult($validationResult); + $form->setSessionValidationResult($validationResult); return $this->redirectBackToForm(); } @@ -303,10 +295,15 @@ class ChangePasswordHandler extends RequestHandler return $this->redirect($url); } + /** + * Something went wrong, go back to the changepassword + * + * @return HTTPResponse + */ public function redirectBackToForm() { // Redirect back to form - $url = $this->addBackURLParam(CMSSecurity::singleton()->Link('changepassword')); + $url = $this->addBackURLParam(Security::singleton()->Link('changepassword')); return $this->redirect($url); } 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 @@ +authenticatorClass, 'lostPasswordForm', - new FieldList( - new EmailField('Email', _t('SilverStripe\\Security\\Member.EMAIL', 'Email')) - ), - new FieldList( - new FormAction( - 'forgotPassword', - _t('SilverStripe\\Security\\Security.BUTTONSEND', 'Send me the password reset link') - ) - ), + null, + null, false ); } @@ -164,21 +157,6 @@ class LostPasswordHandler extends RequestHandler return $this->redirect($this->addBackURLParam($lostPasswordLink)); } - /** - * 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(); - } - /** * Forgot password form handler method. * Called when the user clicks on "I've lost my password". @@ -189,13 +167,14 @@ class LostPasswordHandler extends RequestHandler * * @skipUpgrade * @param array $data Submitted data + * @param LostPasswordForm $form * @return HTTPResponse */ - public function forgotPassword($data) + public function forgotPassword($data, $form) { // Ensure password is given if (empty($data['Email'])) { - $this->form->sessionMessage( + $form->sessionMessage( _t( 'SilverStripe\\Security\\Member.ENTEREMAIL', 'Please enter an email address to get a password reset link.' @@ -220,17 +199,7 @@ class LostPasswordHandler extends RequestHandler 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(); + $this->sendEmail($member, $token); } // Avoid information disclosure by displaying the same status, @@ -243,4 +212,26 @@ class LostPasswordHandler extends RequestHandler 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/MemberLoginForm.php b/src/Security/MemberAuthenticator/MemberLoginForm.php index 8f9c941dc..eb325cf45 100644 --- a/src/Security/MemberAuthenticator/MemberLoginForm.php +++ b/src/Security/MemberAuthenticator/MemberLoginForm.php @@ -74,6 +74,7 @@ class MemberLoginForm extends BaseLoginForm $checkCurrentUser = true ) { + $this->controller = $controller; $this->authenticator_class = $authenticatorClass; $customCSS = project() . '/css/member_login.css'; @@ -81,20 +82,17 @@ class MemberLoginForm extends BaseLoginForm Requirements::css($customCSS); } - if ($controller->request->getVar('BackURL')) { - $backURL = $controller->request->getVar('BackURL'); - } else { - $backURL = Session::get('BackURL'); - } - if ($checkCurrentUser && Security::getCurrentUser()) { // @todo find a more elegant way to handle this $logoutAction = Security::logout_url(); $fields = FieldList::create( - HiddenField::create("AuthenticationMethod", null, $this->authenticator_class, $this) + HiddenField::create('AuthenticationMethod', null, $this->authenticator_class, $this) ); $actions = FieldList::create( - FormAction::create("logout", _t('SilverStripe\\Security\\Member.BUTTONLOGINOTHER', "Log in as someone else")) + FormAction::create('logout', _t( + 'SilverStripe\\Security\\Member.BUTTONLOGINOTHER', + 'Log in as someone else' + )) ); } else { if (!$fields) { @@ -105,10 +103,6 @@ class MemberLoginForm extends BaseLoginForm } } - if (isset($backURL)) { - $fields->push(HiddenField::create('BackURL', 'BackURL', $backURL)); - } - // Reduce attack surface by enforcing POST requests $this->setFormMethod('POST', true); @@ -127,6 +121,12 @@ class MemberLoginForm extends BaseLoginForm */ protected function getFormFields() { + if ($this->controller->request->getVar('BackURL')) { + $backURL = $this->controller->request->getVar('BackURL'); + } else { + $backURL = Session::get('BackURL'); + } + $label = Member::singleton()->fieldLabel(Member::config()->unique_identifier_field); $fields = FieldList::create( HiddenField::create("AuthenticationMethod", null, $this->authenticator_class, $this), @@ -138,7 +138,7 @@ class MemberLoginForm extends BaseLoginForm ); $emailField->setAttribute('autofocus', 'true'); - if (Security::config()->remember_username) { + if (Security::config()->get('remember_username')) { $emailField->setValue(Session::get('SessionForms.MemberLoginForm.Email')); } else { // Some browsers won't respect this attribute unless it's added to the form @@ -160,6 +160,10 @@ class MemberLoginForm extends BaseLoginForm ); } + if (isset($backURL)) { + $fields->push(HiddenField::create('BackURL', 'BackURL', $backURL)); + } + return $fields; } diff --git a/src/Security/Security.php b/src/Security/Security.php index f028e68ba..b07b18fcb 100644 --- a/src/Security/Security.php +++ b/src/Security/Security.php @@ -214,7 +214,7 @@ class Security extends Controller implements TemplateGlobalProvider /** * @var Authenticator[] available authenticators */ - private static $authenticators = []; + private $authenticators = []; /** * @var Member Currently logged in user (if available) @@ -224,17 +224,17 @@ class Security extends Controller implements TemplateGlobalProvider /** * @return array */ - public static function getAuthenticators() + public function getAuthenticators() { - return self::$authenticators; + return $this->authenticators; } /** * @param array|Authenticator $authenticators */ - public static function setAuthenticators(array $authenticators) + public function setAuthenticators(array $authenticators) { - self::$authenticators = $authenticators; + $this->authenticators = $authenticators; } /** @@ -274,7 +274,7 @@ class Security extends Controller implements TemplateGlobalProvider */ protected function getAuthenticator($name = 'default') { - $authenticators = static::$authenticators; + $authenticators = $this->authenticators; if (isset($authenticators[$name])) { return $authenticators[$name]; @@ -291,7 +291,7 @@ class Security extends Controller implements TemplateGlobalProvider */ public function getApplicableAuthenticators($service = Authenticator::LOGIN) { - $authenticators = static::$authenticators; + $authenticators = $this->authenticators; /** @var Authenticator $class */ foreach ($authenticators as $name => $class) { @@ -312,7 +312,7 @@ class Security extends Controller implements TemplateGlobalProvider */ public function hasAuthenticator($authenticator) { - $authenticators = static::$authenticators; + $authenticators = $this->authenticators; return !empty($authenticators[$authenticator]); } From 62753b3cb114c87543d5520af67acc343055a499 Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Fri, 9 Jun 2017 15:07:35 +1200 Subject: [PATCH 8/9] Cleanup and RequestFilter refactor --- _config/security.yml | 37 +++--- src/ORM/Connect/Database.php | 11 ++ src/Security/AuthenticationHandler.php | 4 +- src/Security/AuthenticationRequestFilter.php | 94 +++----------- src/Security/Authenticator.php | 14 +- src/Security/BasicAuth.php | 1 - src/Security/CMSSecurity.php | 8 +- src/Security/Group.php | 44 ++++--- src/Security/GroupCsvBulkLoader.php | 8 +- src/Security/IdentityStore.php | 3 - src/Security/LoginForm.php | 1 - src/Security/Member.php | 50 +++---- .../MemberAuthenticator/CMSLoginHandler.php | 1 - .../ChangePasswordForm.php | 10 +- .../ChangePasswordHandler.php | 8 +- .../CookieAuthenticationHandler.php | 122 +++++++++--------- .../MemberAuthenticator/LoginHandler.php | 8 +- .../MemberAuthenticator/LogoutHandler.php | 3 - .../LostPasswordHandler.php | 11 +- .../MemberAuthenticator.php | 8 +- .../MemberAuthenticator/MemberLoginForm.php | 22 ++-- .../SessionAuthenticationHandler.php | 34 ++--- src/Security/Member_GroupSet.php | 2 +- src/Security/Member_Validator.php | 2 +- src/Security/PasswordEncryptor.php | 2 +- src/Security/Permission.php | 7 +- src/Security/PermissionCheckboxSetField.php | 7 +- src/Security/RememberLoginHash.php | 9 +- src/Security/RequestAuthenticationHandler.php | 83 ++++++++++++ src/Security/Security.php | 50 ++++--- src/Security/SecurityToken.php | 8 +- 31 files changed, 337 insertions(+), 335 deletions(-) create mode 100644 src/Security/RequestAuthenticationHandler.php diff --git a/_config/security.yml b/_config/security.yml index 72b8cd1a4..ef8c59fc1 100644 --- a/_config/security.yml +++ b/_config/security.yml @@ -1,16 +1,7 @@ --- -Name: coresecurity +Name: coreauthentication --- -SilverStripe\Security\MemberAuthenticator\MemberLoginForm: - required_fields: - - Email - - Password - SilverStripe\Core\Injector\Injector: - SilverStripe\Control\RequestProcessor: - properties: - filters: - - %$SilverStripe\Security\AuthenticationRequestFilter SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler: properties: SessionVariable: loggedInAs @@ -19,14 +10,26 @@ SilverStripe\Core\Injector\Injector: TokenCookieName: alc_enc DeviceCookieName: alc_device CascadeInTo: %$SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler - SilverStripe\Security\IdentityStore: - class: SilverStripe\Security\AuthenticationRequestFilter + 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: + Authenticators: default: %$SilverStripe\Security\MemberAuthenticator\MemberAuthenticator cms: %$SilverStripe\Security\MemberAuthenticator\CMSMemberAuthenticator -SilverStripe\Security\AuthenticationRequestFilter: - handlers: - session: SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler - alc: SilverStripe\Security\MemberAuthenticator\CookieAuthenticationHandler + SilverStripe\Security\IdentityStore: %$SilverStripe\Security\AuthenticationHandler diff --git a/src/ORM/Connect/Database.php b/src/ORM/Connect/Database.php index 4396d884a..ab6d56572 100644 --- a/src/ORM/Connect/Database.php +++ b/src/ORM/Connect/Database.php @@ -490,6 +490,17 @@ abstract class Database */ abstract public function datetimeDifferenceClause($date1, $date2); + /** + * String operator for concatenation of strings + * + * @return string + */ + public function concatOperator() + { + // @todo Make ' + ' in mssql + return ' || '; + } + /** * Returns true if this database supports collations * diff --git a/src/Security/AuthenticationHandler.php b/src/Security/AuthenticationHandler.php index 64cb88774..c6e532880 100644 --- a/src/Security/AuthenticationHandler.php +++ b/src/Security/AuthenticationHandler.php @@ -3,9 +3,7 @@ namespace SilverStripe\Security; use SilverStripe\Control\HTTPRequest; -use SilverStripe\Control\HTTPResponse; use SilverStripe\ORM\ValidationException; -use SilverStripe\Security\Member; /** * An AuthenticationHandler is responsible for providing an identity (in the form of a Member object) for @@ -15,7 +13,7 @@ use SilverStripe\Security\Member; * request it should *not* attempt to redirect the visitor to a log-in from or 3rd party handler, as that * is the responsibiltiy of other systems. */ -interface AuthenticationHandler +interface AuthenticationHandler extends IdentityStore { /** * Given the current request, authenticate the request for non-session authorization (outside the CMS). diff --git a/src/Security/AuthenticationRequestFilter.php b/src/Security/AuthenticationRequestFilter.php index 743d582e3..f4f68c847 100644 --- a/src/Security/AuthenticationRequestFilter.php +++ b/src/Security/AuthenticationRequestFilter.php @@ -2,54 +2,40 @@ namespace SilverStripe\Security; -use SilverStripe\Control\HTTPResponse_Exception; -use SilverStripe\Control\RequestFilter; use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPResponse; +use SilverStripe\Control\HTTPResponse_Exception; +use SilverStripe\Control\RequestFilter; use SilverStripe\Control\Session; -use SilverStripe\Dev\Debug; -use SilverStripe\ORM\DataModel; use SilverStripe\Core\Config\Configurable; -use SilverStripe\Core\Injector\Injector; +use SilverStripe\ORM\DataModel; use SilverStripe\ORM\ValidationException; -class AuthenticationRequestFilter implements RequestFilter, IdentityStore +class AuthenticationRequestFilter implements RequestFilter { - use Configurable; /** - * @var array|AuthenticationHandler[] + * @var AuthenticationHandler */ - protected $handlers; + protected $authenticationHandler; /** - * This method currently uses a fallback as loading the handlers via YML has proven unstable - * - * @return array|AuthenticationHandler[] + * @return AuthenticationHandler */ - protected function getHandlers() + public function getAuthenticationHandler() { - if (is_array($this->handlers)) { - return $this->handlers; - } - - return array_map( - function ($identifier) { - return Injector::inst()->get($identifier); - }, - static::config()->get('handlers') - ); + return $this->authenticationHandler; } /** - * Set an associative array of handlers - * - * @param array|AuthenticationHandler[] $handlers + * @param AuthenticationHandler $authenticationHandler + * @return $this */ - public function setHandlers($handlers) + public function setAuthenticationHandler(AuthenticationHandler $authenticationHandler) { - $this->handlers = $handlers; + $this->authenticationHandler = $authenticationHandler; + return $this; } /** @@ -64,16 +50,9 @@ class AuthenticationRequestFilter implements RequestFilter, IdentityStore public function preRequest(HTTPRequest $request, Session $session, DataModel $model) { try { - /** @var AuthenticationHandler $handler */ - foreach ($this->getHandlers() as $name => $handler) { - // @todo Update requestfilter logic to allow modification of initial response - // in order to add cookies, etc - $member = $handler->authenticateRequest($request); - if ($member) { - Security::setCurrentUser($member); - break; - } - } + $this + ->getAuthenticationHandler() + ->authenticateRequest($request); } catch (ValidationException $e) { throw new HTTPResponse_Exception( "Bad log-in details: " . $e->getMessage(), @@ -93,43 +72,4 @@ class AuthenticationRequestFilter implements RequestFilter, IdentityStore public function postRequest(HTTPRequest $request, HTTPResponse $response, DataModel $model) { } - - /** - * Log into the identity-store handlers attached to this request filter - * - * @param Member $member - * @param bool $persistent - * @param HTTPRequest $request - * @return HTTPResponse|void - */ - public function logIn(Member $member, $persistent = false, HTTPRequest $request = null) - { - $member->beforeMemberLoggedIn(); - - foreach ($this->getHandlers() as $handler) { - if ($handler instanceof IdentityStore) { - $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 - * @return HTTPResponse|void - */ - public function logOut(HTTPRequest $request = null) - { - foreach ($this->getHandlers() as $handler) { - if ($handler instanceof IdentityStore) { - $handler->logOut($request); - } - } - - Security::setCurrentUser(null); - } } diff --git a/src/Security/Authenticator.php b/src/Security/Authenticator.php index 5077361da..fe2d8a52a 100644 --- a/src/Security/Authenticator.php +++ b/src/Security/Authenticator.php @@ -2,12 +2,9 @@ namespace SilverStripe\Security; -use SilverStripe\Core\Config\Configurable; -use SilverStripe\Core\Extensible; -use SilverStripe\Core\Injector\Injectable; -use SilverStripe\Control\Controller; -use SilverStripe\Forms\Form; use SilverStripe\ORM\ValidationResult; +use SilverStripe\Security\MemberAuthenticator\LoginHandler; +use SilverStripe\Security\MemberAuthenticator\LogoutHandler; /** * Abstract base class for an authentication method @@ -47,6 +44,7 @@ interface Authenticator * be merged into a default controller. * * @param string $link The base link to use for this RequestHandler + * @return LoginHandler */ public function getLoginHandler($link); @@ -56,7 +54,7 @@ interface Authenticator * The default URL of the RequestHandler should log the user out immediately and destroy the session. * * @param string $link The base link to use for this RequestHandler - * @return mixed + * @return LogoutHandler */ public function getLogOutHandler($link); @@ -75,7 +73,7 @@ interface Authenticator /** - * @param $link + * @param string $link * @return mixed */ public function getLostPasswordHandler($link); @@ -87,5 +85,5 @@ interface Authenticator * @param ValidationResult $result A validationresult which is either valid or contains the error message(s) * @return Member The matched member, or null if the authentication fails */ - public function authenticate($data, &$result); + public function authenticate($data, &$result = null); } diff --git a/src/Security/BasicAuth.php b/src/Security/BasicAuth.php index 768ad6f94..12f6bc9e8 100644 --- a/src/Security/BasicAuth.php +++ b/src/Security/BasicAuth.php @@ -9,7 +9,6 @@ use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\HTTPResponse_Exception; use SilverStripe\Core\Config\Configurable; use SilverStripe\Dev\SapphireTest; - use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator; /** diff --git a/src/Security/CMSSecurity.php b/src/Security/CMSSecurity.php index 97b5ffd9a..540417a45 100644 --- a/src/Security/CMSSecurity.php +++ b/src/Security/CMSSecurity.php @@ -3,14 +3,12 @@ namespace SilverStripe\Security; use SilverStripe\Admin\AdminRootController; +use SilverStripe\Control\Controller; +use SilverStripe\Control\Director; use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\Session; use SilverStripe\Core\Convert; -use SilverStripe\Control\Director; -use SilverStripe\Control\Controller; -use SilverStripe\Core\Injector\Injector; use SilverStripe\ORM\FieldType\DBField; -use SilverStripe\Security\MemberAuthenticator\CMSMemberAuthenticator; use SilverStripe\View\Requirements; /** @@ -194,7 +192,7 @@ PHP $backURLs = array( $this->getRequest()->requestVar('BackURL'), Session::get('BackURL'), - Director::absoluteURL(AdminRootController::config()->url_base, true), + Director::absoluteURL(AdminRootController::config()->get('url_base'), true), ); $backURL = null; foreach ($backURLs as $backURL) { diff --git a/src/Security/Group.php b/src/Security/Group.php index 6970a1249..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; } /** 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 index 259db694b..6a07f1653 100644 --- a/src/Security/IdentityStore.php +++ b/src/Security/IdentityStore.php @@ -4,7 +4,6 @@ namespace SilverStripe\Security; use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPResponse; -use SilverStripe\Core\Config\Configurable; /** * Represents an authentication handler that can have identities logged into & out of it. @@ -19,7 +18,6 @@ interface IdentityStore * @param Member $member The member to log in. * @param Boolean $persistent boolean If set to true, the login may persist beyond the current session. * @param HTTPRequest $request The request of the visitor that is logging in, to get, for example, cookies. - * @return HTTPResponse $response The response object to modify, if needed. */ public function logIn(Member $member, $persistent = false, HTTPRequest $request = null); @@ -27,7 +25,6 @@ interface IdentityStore * Log any logged-in member out of this identity store. * * @param HTTPRequest $request The request of the visitor that is logging out, to get, for example, cookies. - * @return HTTPResponse $response The response object to modify, if needed. */ public function logOut(HTTPRequest $request = null); } 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 21a5439e7..e7895b665 100644 --- a/src/Security/Member.php +++ b/src/Security/Member.php @@ -3,19 +3,16 @@ namespace SilverStripe\Security; use IntlDateFormatter; +use InvalidArgumentException; use SilverStripe\Admin\LeftAndMain; use SilverStripe\CMS\Controllers\CMSMain; use SilverStripe\Control\Controller; -use SilverStripe\Control\Cookie; 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\Debug; use SilverStripe\Dev\Deprecation; -use SilverStripe\Dev\SapphireTest; use SilverStripe\Dev\TestMailer; use SilverStripe\Forms\ConfirmedPasswordField; use SilverStripe\Forms\DropdownField; @@ -23,7 +20,6 @@ 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; @@ -31,13 +27,10 @@ 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 @@ -60,6 +53,7 @@ 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 { @@ -482,9 +476,10 @@ class Member extends DataObject 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(); } @@ -756,7 +751,7 @@ class Member extends DataObject * * @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) { @@ -837,7 +832,7 @@ class Member extends DataObject // 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 = [ @@ -890,7 +885,7 @@ class Member extends DataObject $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 ); @@ -1013,7 +1008,7 @@ class Member extends DataObject } 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) { @@ -1081,10 +1076,17 @@ class Member extends DataObject */ 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 -----------------------------------// @@ -1133,8 +1135,6 @@ class Member extends DataObject */ 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()->get('title_format'); @@ -1151,7 +1151,7 @@ class Member extends DataObject } $sepSQL = Convert::raw2sql($format['sep'], true); - + $op = DB::get_conn()->concatOperator(); return "(" . join(" $op $sepSQL $op ", $columnsWithTablename) . ")"; } @@ -1305,6 +1305,7 @@ class Member extends DataObject $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()); } @@ -1332,7 +1333,7 @@ class Member extends DataObject return ArrayList::create()->map(); } - if (!$groups || $groups->Count() == 0) { + if (count($groups) == 0) { $perms = array('ADMIN', 'CMS_ACCESS_AssetAdmin'); if (class_exists(CMSMain::class)) { @@ -1673,12 +1674,13 @@ class Member extends DataObject */ 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; + 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; } @@ -1692,7 +1694,7 @@ class Member extends DataObject */ 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(); diff --git a/src/Security/MemberAuthenticator/CMSLoginHandler.php b/src/Security/MemberAuthenticator/CMSLoginHandler.php index cb27878a1..bd588c231 100644 --- a/src/Security/MemberAuthenticator/CMSLoginHandler.php +++ b/src/Security/MemberAuthenticator/CMSLoginHandler.php @@ -5,7 +5,6 @@ namespace SilverStripe\Security\MemberAuthenticator; use SilverStripe\Control\HTTPResponse; use SilverStripe\Core\Convert; use SilverStripe\Security\CMSSecurity; -use SilverStripe\Security\Member; use SilverStripe\Security\Security; class CMSLoginHandler extends LoginHandler diff --git a/src/Security/MemberAuthenticator/ChangePasswordForm.php b/src/Security/MemberAuthenticator/ChangePasswordForm.php index 155ae00b4..5969c1f46 100644 --- a/src/Security/MemberAuthenticator/ChangePasswordForm.php +++ b/src/Security/MemberAuthenticator/ChangePasswordForm.php @@ -2,14 +2,14 @@ namespace SilverStripe\Security\MemberAuthenticator; -use SilverStripe\Control\Session; use SilverStripe\Control\RequestHandler; +use SilverStripe\Control\Session; use SilverStripe\Forms\FieldList; -use SilverStripe\Forms\FormField; -use SilverStripe\Forms\PasswordField; -use SilverStripe\Forms\FormAction; -use SilverStripe\Forms\HiddenField; use SilverStripe\Forms\Form; +use SilverStripe\Forms\FormAction; +use SilverStripe\Forms\FormField; +use SilverStripe\Forms\HiddenField; +use SilverStripe\Forms\PasswordField; use SilverStripe\Security\Security; /** diff --git a/src/Security/MemberAuthenticator/ChangePasswordHandler.php b/src/Security/MemberAuthenticator/ChangePasswordHandler.php index a6e706099..8023b0d2a 100644 --- a/src/Security/MemberAuthenticator/ChangePasswordHandler.php +++ b/src/Security/MemberAuthenticator/ChangePasswordHandler.php @@ -4,18 +4,16 @@ namespace SilverStripe\Security\MemberAuthenticator; use SilverStripe\Control\Controller; -use SilverStripe\Control\RequestHandler; -use SilverStripe\Core\Injector\Injector; use SilverStripe\Control\HTTPResponse; +use SilverStripe\Control\RequestHandler; use SilverStripe\Control\Session; +use SilverStripe\Core\Injector\Injector; use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\ORM\FieldType\DBField; -use SilverStripe\ORM\FieldType\DBHTMLText; use SilverStripe\Security\Authenticator; -use SilverStripe\Security\CMSSecurity; +use SilverStripe\Security\IdentityStore; use SilverStripe\Security\Member; use SilverStripe\Security\Security; -use SilverStripe\Security\IdentityStore; class ChangePasswordHandler extends RequestHandler { diff --git a/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php b/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php index ca1085c6a..500b73e8d 100644 --- a/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php +++ b/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php @@ -2,20 +2,19 @@ namespace SilverStripe\Security\MemberAuthenticator; -use SilverStripe\Control\HTTPResponse; -use SilverStripe\Security\Member; +use SilverStripe\Control\Cookie; use SilverStripe\Control\HTTPRequest; -use SilverStripe\Security\AuthenticationHandler as AuthenticationHandlerInterface; +use SilverStripe\ORM\FieldType\DBDatetime; +use SilverStripe\Security\AuthenticationHandler; use SilverStripe\Security\IdentityStore; +use SilverStripe\Security\Member; use SilverStripe\Security\RememberLoginHash; use SilverStripe\Security\Security; -use SilverStripe\ORM\FieldType\DBDatetime; -use SilverStripe\Control\Cookie; /** * Authenticate a member pased on a session cookie */ -class CookieAuthenticationHandler implements AuthenticationHandlerInterface, IdentityStore +class CookieAuthenticationHandler implements AuthenticationHandler { /** @@ -47,11 +46,12 @@ class CookieAuthenticationHandler implements AuthenticationHandlerInterface, Ide * Set the name of the cookie used to track this device * * @param string $deviceCookieName - * @return null + * @return $this */ public function setDeviceCookieName($deviceCookieName) { $this->deviceCookieName = $deviceCookieName; + return $this; } /** @@ -68,10 +68,12 @@ class CookieAuthenticationHandler implements AuthenticationHandlerInterface, Ide * 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; } /** @@ -88,16 +90,17 @@ class CookieAuthenticationHandler implements AuthenticationHandlerInterface, Ide * Set the name of the cookie used to store an login token * * @param IdentityStore $cascadeInTo - * @return null + * @return $this */ public function setCascadeLogInTo(IdentityStore $cascadeInTo) { $this->cascadeInTo = $cascadeInTo; + return $this; } /** * @param HTTPRequest $request - * @return null|Member + * @return Member */ public function authenticateRequest(HTTPRequest $request) { @@ -115,71 +118,65 @@ class CookieAuthenticationHandler implements AuthenticationHandlerInterface, Ide 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 = 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; - } - } + $rememberLoginHash = RememberLoginHash::get() + ->filter(array( + 'MemberID' => $member->ID, + 'DeviceID' => $deviceID, + 'Hash' => $hash + ))->first(); + if (!$rememberLoginHash) { + return null; } - if ($member) { - 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 - if ($rememberLoginHash) { - $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; + // 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 - * @return HTTPResponse|void */ public function logIn(Member $member, $persistent = false, HTTPRequest $request = null) { @@ -218,8 +215,7 @@ class CookieAuthenticationHandler implements AuthenticationHandlerInterface, Ide } /** - * @param HTTPRequest|null $request - * @return HTTPResponse|void + * @param HTTPRequest $request */ public function logOut(HTTPRequest $request = null) { diff --git a/src/Security/MemberAuthenticator/LoginHandler.php b/src/Security/MemberAuthenticator/LoginHandler.php index a344d64c4..74e8b1425 100644 --- a/src/Security/MemberAuthenticator/LoginHandler.php +++ b/src/Security/MemberAuthenticator/LoginHandler.php @@ -5,14 +5,14 @@ namespace SilverStripe\Security\MemberAuthenticator; use SilverStripe\Control\Controller; use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPResponse; -use SilverStripe\Control\Session; use SilverStripe\Control\RequestHandler; +use SilverStripe\Control\Session; +use SilverStripe\Core\Injector\Injector; use SilverStripe\ORM\ValidationResult; use SilverStripe\Security\Authenticator; -use SilverStripe\Security\Security; -use SilverStripe\Security\Member; -use SilverStripe\Core\Injector\Injector; use SilverStripe\Security\IdentityStore; +use SilverStripe\Security\Member; +use SilverStripe\Security\Security; /** * Handle login requests from MemberLoginForm diff --git a/src/Security/MemberAuthenticator/LogoutHandler.php b/src/Security/MemberAuthenticator/LogoutHandler.php index 2c51899f8..21b2a4381 100644 --- a/src/Security/MemberAuthenticator/LogoutHandler.php +++ b/src/Security/MemberAuthenticator/LogoutHandler.php @@ -2,13 +2,10 @@ namespace SilverStripe\Security\MemberAuthenticator; -use SilverStripe\Control\Cookie; use SilverStripe\Control\RequestHandler; -use SilverStripe\Control\Session; use SilverStripe\Core\Injector\Injector; use SilverStripe\Security\IdentityStore; use SilverStripe\Security\Member; -use SilverStripe\Security\RememberLoginHash; use SilverStripe\Security\Security; /** diff --git a/src/Security/MemberAuthenticator/LostPasswordHandler.php b/src/Security/MemberAuthenticator/LostPasswordHandler.php index dfab4b4dd..0aa7bc401 100644 --- a/src/Security/MemberAuthenticator/LostPasswordHandler.php +++ b/src/Security/MemberAuthenticator/LostPasswordHandler.php @@ -5,19 +5,12 @@ namespace SilverStripe\Security\MemberAuthenticator; use SilverStripe\Control\Controller; use SilverStripe\Control\Email\Email; use SilverStripe\Control\HTTPResponse; -use SilverStripe\Control\Session; use SilverStripe\Control\RequestHandler; -use SilverStripe\Core\Injector\Injector; +use SilverStripe\Core\Convert; use SilverStripe\Forms\Form; -use SilverStripe\ORM\ValidationResult; -use SilverStripe\Forms\FieldList; -use SilverStripe\Forms\EmailField; -use SilverStripe\Forms\FormAction; -use SilverStripe\Security\IdentityStore; +use SilverStripe\ORM\FieldType\DBField; use SilverStripe\Security\Member; use SilverStripe\Security\Security; -use SilverStripe\Core\Convert; -use SilverStripe\ORM\FieldType\DBField; /** * Handle login requests from MemberLoginForm diff --git a/src/Security/MemberAuthenticator/MemberAuthenticator.php b/src/Security/MemberAuthenticator/MemberAuthenticator.php index 879c118bd..d40f0b37c 100644 --- a/src/Security/MemberAuthenticator/MemberAuthenticator.php +++ b/src/Security/MemberAuthenticator/MemberAuthenticator.php @@ -2,14 +2,14 @@ namespace SilverStripe\Security\MemberAuthenticator; +use InvalidArgumentException; use SilverStripe\Control\Controller; use SilverStripe\Control\Session; use SilverStripe\ORM\ValidationResult; -use InvalidArgumentException; use SilverStripe\Security\Authenticator; -use SilverStripe\Security\Security; -use SilverStripe\Security\Member; use SilverStripe\Security\LoginAttempt; +use SilverStripe\Security\Member; +use SilverStripe\Security\Security; /** * Authenticator for the default "member" method @@ -162,7 +162,7 @@ class MemberAuthenticator implements Authenticator } /** - * @param $link + * @param string $link * @return LostPasswordHandler */ public function getLostPasswordHandler($link) diff --git a/src/Security/MemberAuthenticator/MemberLoginForm.php b/src/Security/MemberAuthenticator/MemberLoginForm.php index eb325cf45..ef61dfc4f 100644 --- a/src/Security/MemberAuthenticator/MemberLoginForm.php +++ b/src/Security/MemberAuthenticator/MemberLoginForm.php @@ -5,20 +5,19 @@ namespace SilverStripe\Security\MemberAuthenticator; use SilverStripe\Control\Director; use SilverStripe\Control\RequestHandler; use SilverStripe\Control\Session; -use SilverStripe\Control\Controller; -use SilverStripe\Forms\HiddenField; +use SilverStripe\Forms\CheckboxField; use SilverStripe\Forms\FieldList; use SilverStripe\Forms\FormAction; -use SilverStripe\Forms\TextField; -use SilverStripe\Forms\PasswordField; -use SilverStripe\Forms\CheckboxField; +use SilverStripe\Forms\HiddenField; use SilverStripe\Forms\LiteralField; +use SilverStripe\Forms\PasswordField; use SilverStripe\Forms\RequiredFields; +use SilverStripe\Forms\TextField; use SilverStripe\ORM\ValidationResult; -use SilverStripe\Security\Member; -use SilverStripe\Security\Security; -use SilverStripe\Security\RememberLoginHash; use SilverStripe\Security\LoginForm as BaseLoginForm; +use SilverStripe\Security\Member; +use SilverStripe\Security\RememberLoginHash; +use SilverStripe\Security\Security; use SilverStripe\View\Requirements; /** @@ -42,9 +41,14 @@ class MemberLoginForm extends BaseLoginForm /** * Required fields for validation + * + * @config * @var array */ - private static $required_fields; + private static $required_fields = [ + 'Email', + 'Password', + ]; /** * Constructor diff --git a/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php b/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php index 4c0bac4bc..e88c68489 100644 --- a/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php +++ b/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php @@ -3,21 +3,17 @@ namespace SilverStripe\Security\MemberAuthenticator; use SilverStripe\Control\Cookie; -use SilverStripe\Control\HTTPResponse; -use SilverStripe\ORM\DataObject; -use SilverStripe\Security\Member; +use SilverStripe\Control\Director; use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\Session; -use SilverStripe\Control\Director; use SilverStripe\Security\AuthenticationHandler; -use SilverStripe\Security\IdentityStore; +use SilverStripe\Security\Member; /** * Authenticate a member pased on a session cookie */ -class SessionAuthenticationHandler implements AuthenticationHandler, IdentityStore +class SessionAuthenticationHandler implements AuthenticationHandler { - /** * @var string */ @@ -44,27 +40,26 @@ class SessionAuthenticationHandler implements AuthenticationHandler, IdentitySto } /** - * @inherit * @param HTTPRequest $request - * @return null|DataObject|Member + * @return Member */ public function authenticateRequest(HTTPRequest $request) { - if ($id = Session::get($this->getSessionVariable())) { - // If ID is a bad ID it will be treated as if the user is not logged in, rather than throwing a - // ValidationException - return Member::get()->byID($id); + // 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; } - - return null; + /** @var Member $member */ + $member = Member::get()->byID($id); + return $member; } /** - * @inherit * @param Member $member * @param bool $persistent - * @param HTTPRequest|null $request - * @return HTTPResponse|void + * @param HTTPRequest $request */ public function logIn(Member $member, $persistent = false, HTTPRequest $request = null) { @@ -103,8 +98,7 @@ class SessionAuthenticationHandler implements AuthenticationHandler, IdentitySto } /** - * @param HTTPRequest|null $request - * @return HTTPResponse|void + * @param HTTPRequest $request */ public function logOut(HTTPRequest $request = null) { 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 433339a3a..bb6d8d381 100644 --- a/src/Security/Permission.php +++ b/src/Security/Permission.php @@ -4,12 +4,11 @@ namespace SilverStripe\Security; use SilverStripe\Core\ClassInfo; use SilverStripe\Core\Resettable; -use SilverStripe\Dev\Debug; 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; @@ -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 b07b18fcb..16ae137e8 100644 --- a/src/Security/Security.php +++ b/src/Security/Security.php @@ -2,25 +2,26 @@ 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\Session; use SilverStripe\Control\RequestHandler; +use SilverStripe\Control\Session; use SilverStripe\Core\ClassInfo; use SilverStripe\Core\Convert; +use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\Deprecation; use SilverStripe\Dev\TestOnly; use SilverStripe\Forms\Form; use SilverStripe\ORM\ArrayList; use SilverStripe\ORM\DataModel; -use SilverStripe\ORM\DB; use SilverStripe\ORM\DataObject; +use SilverStripe\ORM\DB; use SilverStripe\ORM\FieldType\DBField; use SilverStripe\ORM\FieldType\DBHTMLText; use SilverStripe\ORM\ValidationResult; @@ -222,7 +223,7 @@ class Security extends Controller implements TemplateGlobalProvider protected static $currentUser; /** - * @return array + * @return Authenticator[] */ public function getAuthenticators() { @@ -230,16 +231,13 @@ class Security extends Controller implements TemplateGlobalProvider } /** - * @param array|Authenticator $authenticators + * @param Authenticator[] $authenticators */ public function setAuthenticators(array $authenticators) { $this->authenticators = $authenticators; } - /** - * @inheritdoc - */ protected function init() { parent::init(); @@ -257,9 +255,6 @@ class Security extends Controller implements TemplateGlobalProvider } } - /** - * @inheritdoc - */ public function index() { return $this->httpError(404); // no-op @@ -287,15 +282,15 @@ class Security extends Controller implements TemplateGlobalProvider * Get all registered authenticators * * @param int $service The type of service that is requested - * @return array Return an array of Authenticator objects + * @return Authenticator[] Return an array of Authenticator objects */ public function getApplicableAuthenticators($service = Authenticator::LOGIN) { $authenticators = $this->authenticators; - /** @var Authenticator $class */ - foreach ($authenticators as $name => $class) { - if (!($class->supportedServices() & $service)) { + /** @var Authenticator $authenticator */ + foreach ($authenticators as $name => $authenticator) { + if (!($authenticator->supportedServices() & $service)) { unset($authenticators[$name]); } } @@ -468,8 +463,10 @@ class Security extends Controller implements TemplateGlobalProvider Deprecation::notice('5.0.0', 'Now handled by delegateToMultipleHandlers'); return array_map( - function ($authenticator) { - return [$authenticator->getLoginHandler($this->Link())->loginForm()]; + function (Authenticator $authenticator) { + return [ + $authenticator->getLoginHandler($this->Link())->loginForm() + ]; }, $this->getApplicableAuthenticators() ); @@ -601,16 +598,14 @@ 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 * 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(); @@ -713,8 +708,7 @@ class Security extends Controller implements TemplateGlobalProvider $link = $this->Link('login'); // Delegate to a single handler - Security/login//... - if ($authName && $this->hasAuthenticator($authName) - ) { + if ($authName && $this->hasAuthenticator($authName)) { if ($request) { $request->shift(); } @@ -733,7 +727,7 @@ class Security extends Controller implements TemplateGlobalProvider array_walk( $handlers, - function (&$auth, $name) use ($link) { + function (Authenticator &$auth, $name) use ($link) { $auth = $auth->getLoginHandler(Controller::join_links($link, $name)); } ); @@ -766,7 +760,7 @@ class Security extends Controller implements TemplateGlobalProvider // Process each of the handlers $results = array_map( - function ($handler) { + function (RequestHandler $handler) { return $handler->handleRequest($this->getRequest(), DataModel::inst()); }, $handlers @@ -1200,6 +1194,8 @@ class Security extends Controller implements TemplateGlobalProvider /** * For the database_is_ready call to return a certain value - used for testing + * + * @param bool $isReady */ public static function force_database_is_ready($isReady) { @@ -1220,7 +1216,7 @@ class Security extends Controller implements TemplateGlobalProvider /** * Set to true to ignore access to disallowed actions, rather than returning permission failure * Note that this is just a flag that other code needs to check with Security::ignore_disallowed_actions() - * @param $flag True or false + * @param bool $flag True or false */ public static function set_ignore_disallowed_actions($flag) { diff --git a/src/Security/SecurityToken.php b/src/Security/SecurityToken.php index 6ca092e8e..d54c659f1 100644 --- a/src/Security/SecurityToken.php +++ b/src/Security/SecurityToken.php @@ -2,11 +2,11 @@ namespace SilverStripe\Security; +use SilverStripe\Control\Controller; use SilverStripe\Control\HTTPRequest; +use SilverStripe\Control\Session; use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\Injector\Injectable; -use SilverStripe\Control\Session; -use SilverStripe\Control\Controller; use SilverStripe\Forms\FieldList; use SilverStripe\Forms\HiddenField; use SilverStripe\View\TemplateGlobalProvider; @@ -61,11 +61,11 @@ class SecurityToken implements TemplateGlobalProvider protected $name = null; /** - * @param $name + * @param string $name */ public function __construct($name = null) { - $this->name = ($name) ? $name : self::get_default_name(); + $this->name = $name ?: self::get_default_name(); } /** From d89bd15330e8386925000b519b0e6a2f71237148 Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Fri, 9 Jun 2017 16:25:40 +1200 Subject: [PATCH 9/9] Move authentication hooks to SapphireTest --- src/Dev/FunctionalTest.php | 31 ------------------------------- src/Dev/SapphireTest.php | 25 ++++++++++++++++++++++++- tests/php/Security/MemberTest.php | 7 ++++++- 3 files changed, 30 insertions(+), 33 deletions(-) diff --git a/src/Dev/FunctionalTest.php b/src/Dev/FunctionalTest.php index cef88598d..53c20bd6f 100644 --- a/src/Dev/FunctionalTest.php +++ b/src/Dev/FunctionalTest.php @@ -398,37 +398,6 @@ class FunctionalTest extends SapphireTest $this->assertTrue($expectedMatches == $actuals, $message); } - /** - * Log in as the given member - * - * @param Member|int|string $member The ID, fixture codename, or Member object of the member that you want to log in - */ - public function logInAs($member) - { - if (is_numeric($member)) { - $member = DataObject::get_by_id(Member::class, $member); - } elseif (!is_object($member)) { - $member = $this->objFromFixture('SilverStripe\\Security\\Member', $member); - } - - $this->logIn($member); - } - - /** - * Log out the member - * - */ - public function logOut() - { - $this->session()->inst_clear('loggedInAs'); - Security::setCurrentUser(null); - } - - public function logIn($member) - { - Security::setCurrentUser($member); - } - /** * Use the draft (stage) site for testing. * This is helpful if you're not testing publication functionality and don't want "stage management" cluttering diff --git a/src/Dev/SapphireTest.php b/src/Dev/SapphireTest.php index f557f5a40..095de81c7 100644 --- a/src/Dev/SapphireTest.php +++ b/src/Dev/SapphireTest.php @@ -1251,10 +1251,33 @@ class SapphireTest extends PHPUnit_Framework_TestCase $this->cache_generatedMembers[$permCode] = $member; } - Injector::inst()->get(IdentityStore::class)->logIn($member); + $this->logInAs($member); return $member->ID; } + /** + * Log in as the given member + * + * @param Member|int|string $member The ID, fixture codename, or Member object of the member that you want to log in + */ + public function logInAs($member) + { + if (is_numeric($member)) { + $member = DataObject::get_by_id(Member::class, $member); + } elseif (!is_object($member)) { + $member = $this->objFromFixture(Member::class, $member); + } + Injector::inst()->get(IdentityStore::class)->logIn($member); + } + + /** + * Log out the current user + */ + public function logOut() + { + Injector::inst()->get(IdentityStore::class)->logOut(); + } + /** * Cache for logInWithPermission() */ diff --git a/tests/php/Security/MemberTest.php b/tests/php/Security/MemberTest.php index 5d9f5b931..4420a814c 100644 --- a/tests/php/Security/MemberTest.php +++ b/tests/php/Security/MemberTest.php @@ -11,6 +11,7 @@ use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DB; use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\Security\Member; +use SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler; use SilverStripe\Security\Security; use SilverStripe\Security\MemberPassword; use SilverStripe\Security\Group; @@ -1072,7 +1073,11 @@ class MemberTest extends FunctionalTest ); $this->assertContains($message, $response->getBody()); - $this->logOut(); + // Test that removing session but not cookie keeps user + /** @var SessionAuthenticationHandler $sessionHandler */ + $sessionHandler = Injector::inst()->get(SessionAuthenticationHandler::class); + $sessionHandler->logOut(); + Security::setCurrentUser(null); // Accessing the login page from the second device $response = $this->get(