diff --git a/_config.php b/_config.php index 80a3b2e13..8aaf823c7 100644 --- a/_config.php +++ b/_config.php @@ -12,12 +12,6 @@ use SilverStripe\View\Parsers\ShortcodeParser; * Here you can make different settings for the Framework module (the core * module). * - * For example you can register the authentication methods you wish to use - * on your site, e.g. to register the OpenID authentication method type - * - * - * Authenticator::register_authenticator('OpenIDAuthenticator'); - * */ ShortcodeParser::get('default') diff --git a/_config/security.yml b/_config/security.yml index 10051c588..ef8c59fc1 100644 --- a/_config/security.yml +++ b/_config/security.yml @@ -1,4 +1,35 @@ -SilverStripe\Security\MemberLoginForm: - required_fields: - - Email - - Password \ No newline at end of file +--- +Name: coreauthentication +--- +SilverStripe\Core\Injector\Injector: + SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler: + properties: + SessionVariable: loggedInAs + SilverStripe\Security\MemberAuthenticator\CookieAuthenticationHandler: + properties: + TokenCookieName: alc_enc + DeviceCookieName: alc_device + CascadeInTo: %$SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler + SilverStripe\Security\AuthenticationHandler: + class: SilverStripe\Security\RequestAuthenticationHandler + properties: + Handlers: + session: %$SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler + alc: %$SilverStripe\Security\MemberAuthenticator\CookieAuthenticationHandler +--- +Name: coresecurity +--- +SilverStripe\Core\Injector\Injector: + SilverStripe\Security\AuthenticationRequestFilter: + properties: + AuthenticationHandler: %$SilverStripe\Security\AuthenticationHandler + SilverStripe\Control\RequestProcessor: + properties: + filters: + - %$SilverStripe\Security\AuthenticationRequestFilter + SilverStripe\Security\Security: + properties: + Authenticators: + default: %$SilverStripe\Security\MemberAuthenticator\MemberAuthenticator + cms: %$SilverStripe\Security\MemberAuthenticator\CMSMemberAuthenticator + SilverStripe\Security\IdentityStore: %$SilverStripe\Security\AuthenticationHandler diff --git a/docs/en/02_Developer_Guides/00_Model/05_Extending_DataObjects.md b/docs/en/02_Developer_Guides/00_Model/05_Extending_DataObjects.md index c1519bb8b..f27c6778c 100644 --- a/docs/en/02_Developer_Guides/00_Model/05_Extending_DataObjects.md +++ b/docs/en/02_Developer_Guides/00_Model/05_Extending_DataObjects.md @@ -30,7 +30,7 @@ Example: Disallow creation of new players if the currently logged-in player is n public function onBeforeWrite() { // check on first write action, aka "database row creation" (ID-property is not set) if(!$this->isInDb()) { - $currentPlayer = Member::currentUser(); + $currentPlayer = Security::getCurrentUser(); if(!$currentPlayer->IsTeamManager()) { user_error('Player-creation not allowed', E_USER_ERROR); diff --git a/docs/en/02_Developer_Guides/00_Model/07_Permissions.md b/docs/en/02_Developer_Guides/00_Model/07_Permissions.md index 49f8d4082..28205497e 100644 --- a/docs/en/02_Developer_Guides/00_Model/07_Permissions.md +++ b/docs/en/02_Developer_Guides/00_Model/07_Permissions.md @@ -9,7 +9,7 @@ checks. Often it makes sense to centralize those checks on the model, regardless The API provides four methods for this purpose: `canEdit()`, `canCreate()`, `canView()` and `canDelete()`. Since they're PHP methods, they can contain arbitrary logic matching your own requirements. They can optionally receive -a `$member` argument, and default to the currently logged in member (through `Member::currentUser()`). +a `$member` argument, and default to the currently logged in member (through `Security::getCurrentUser()`).
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/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/Dev/FunctionalTest.php b/src/Dev/FunctionalTest.php index ca50d9d8f..53c20bd6f 100644 --- a/src/Dev/FunctionalTest.php +++ b/src/Dev/FunctionalTest.php @@ -5,8 +5,10 @@ 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; use SilverStripe\Security\SecurityToken; use SilverStripe\View\SSViewer; use PHPUnit_Framework_AssertionFailedError; @@ -104,6 +106,8 @@ class FunctionalTest extends SapphireTest // basis. BasicAuth::protect_entire_site(false); + $this->logOut(); + SecurityToken::disable(); } @@ -394,24 +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_object($member)) { - $memberID = $member->ID; - } elseif (is_numeric($member)) { - $memberID = $member; - } else { - $memberID = $this->idFromFixture('SilverStripe\\Security\\Member', $member); - } - - $this->session()->inst_set('loggedInAs', $memberID); - } - /** * 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 6485836cd..095de81c7 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; @@ -276,7 +277,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(); @@ -1250,10 +1251,33 @@ class SapphireTest extends PHPUnit_Framework_TestCase $this->cache_generatedMembers[$permCode] = $member; } - $member->logIn(); + $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/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/FormRequestHandler.php b/src/Forms/FormRequestHandler.php index a2115e8e0..f1f82606d 100644 --- a/src/Forms/FormRequestHandler.php +++ b/src/Forms/FormRequestHandler.php @@ -228,23 +228,23 @@ 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. 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/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/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/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/AuthenticationHandler.php b/src/Security/AuthenticationHandler.php new file mode 100644 index 000000000..c6e532880 --- /dev/null +++ b/src/Security/AuthenticationHandler.php @@ -0,0 +1,40 @@ + + * 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..f4f68c847 --- /dev/null +++ b/src/Security/AuthenticationRequestFilter.php @@ -0,0 +1,75 @@ +authenticationHandler; + } + + /** + * @param AuthenticationHandler $authenticationHandler + * @return $this + */ + public function setAuthenticationHandler(AuthenticationHandler $authenticationHandler) + { + $this->authenticationHandler = $authenticationHandler; + return $this; + } + + /** + * 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 { + $this + ->getAuthenticationHandler() + ->authenticateRequest($request); + } catch (ValidationException $e) { + throw new HTTPResponse_Exception( + "Bad log-in details: " . $e->getMessage(), + 400 + ); + } + } + + /** + * No-op + * + * @param HTTPRequest $request + * @param HTTPResponse $response + * @param DataModel $model + * @return bool|void + */ + public function postRequest(HTTPRequest $request, HTTPResponse $response, DataModel $model) + { + } +} diff --git a/src/Security/Authenticator.php b/src/Security/Authenticator.php index 66bbc7843..fe2d8a52a 100644 --- a/src/Security/Authenticator.php +++ b/src/Security/Authenticator.php @@ -2,11 +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 @@ -16,125 +14,76 @@ 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 RequestHandler 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 string $link The base link to use for this RequestHandler + * @return LoginHandler */ - public static function get_cms_login_form(Controller $controller) - { - } + public function getLoginHandler($link); /** - * Determine if this authenticator supports in-cms reauthentication + * Return the RequestHandler to manage the log-out process. * - * @return bool - */ - public static function supports_cms() - { - return false; - } - - /** - * Check if a given authenticator is registered + * The default URL of the RequestHandler should log the user out immediately and destroy the session. * - * @param string $authenticator Name of the authenticator class to check - * @return bool Returns TRUE if the authenticator is registered, FALSE - * otherwise. + * @param string $link The base link to use for this RequestHandler + * @return LogoutHandler */ - 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); - } - + public function getLogOutHandler($link); /** - * Get all registered authenticators + * Return RequestHandler to manage the change-password process. * - * @return array Returns an array with the class names of all registered - * authenticators. + * The default URL of the RequetHandler should return the initial change-password 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 */ - public static function get_authenticators() - { - $authenticators = self::config()->get('authenticators'); - $default = self::config()->get('default_authenticator'); + public function getChangePasswordHandler($link); - 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; - } /** - * @return string + * @param string $link + * @return mixed */ - 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 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 = null); } diff --git a/src/Security/BasicAuth.php b/src/Security/BasicAuth.php index 1645f0909..12f6bc9e8 100644 --- a/src/Security/BasicAuth.php +++ b/src/Security/BasicAuth.php @@ -2,12 +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\SapphireTest; +use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator; /** * Provides an interface to HTTP basic authentication. @@ -41,24 +43,29 @@ 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. * * 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) - { - $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; } @@ -71,25 +78,37 @@ 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'])) { - $member = MemberAuthenticator::authenticate(array( - 'Email' => $_SERVER['PHP_AUTH_USER'], - 'Password' => $_SERVER['PHP_AUTH_PW'], - ), null); + + if ($request->getHeader('PHP_AUTH_USER') && $request->getHeader('PHP_AUTH_PW')) { + /** @var MemberAuthenticator $authenticator */ + $authenticators = Security::singleton()->getApplicableAuthenticators(Authenticator::LOGIN); + + 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 instanceof 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 @@ -97,10 +116,20 @@ class BasicAuth $response = new HTTPResponse(null, 401); $response->addHeader('WWW-Authenticate', "Basic realm=\"$realm\""); - if (isset($_SERVER['PHP_AUTH_USER'])) { - $response->setBody(_t('SilverStripe\\Security\\BasicAuth.ERRORNOTREC', "That username / password isn't recognised")); + 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.")); + $response->setBody( + _t( + 'SilverStripe\\Security\\BasicAuth.ENTERINFO', + 'Please enter a username and password.' + ) + ); } // Exception is caught by RequestHandler->handleRequest() and will halt further execution @@ -113,8 +142,13 @@ class BasicAuth $response = new HTTPResponse(null, 401); $response->addHeader('WWW-Authenticate', "Basic realm=\"$realm\""); - if (isset($_SERVER['PHP_AUTH_USER'])) { - $response->setBody(_t('SilverStripe\\Security\\BasicAuth.ERRORNOTADMIN', "That user is not an administrator.")); + if ($request->getHeader('PHP_AUTH_USER')) { + $response->setBody( + _t( + 'SilverStripe\\Security\\BasicAuth.ERRORNOTADMIN', + 'That user is not an administrator.' + ) + ); } // Exception is caught by RequestHandler->handleRequest() and will halt further execution @@ -146,9 +180,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); + static::config()->set('entire_site_protected', $protect); + static::config()->set('entire_site_protected_code', $code); + static::config()->set('entire_site_protected_message', $message); } /** @@ -160,9 +194,16 @@ class BasicAuth */ public static function protect_site_if_necessary() { - $config = Config::forClass('SilverStripe\\Security\\BasicAuth'); - if ($config->entire_site_protected) { - self::requireLogin($config->entire_site_protected_message, $config->entire_site_protected_code, false); + $config = static::config(); + $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/CMSMemberLoginForm.php b/src/Security/CMSMemberLoginForm.php index 4f1c602a1..197240037 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 48b6ff31d..540417a45 100644 --- a/src/Security/CMSSecurity.php +++ b/src/Security/CMSSecurity.php @@ -3,11 +3,11 @@ namespace SilverStripe\Security; use SilverStripe\Admin\AdminRootController; -use SilverStripe\Control\HTTPResponse; -use SilverStripe\Core\Convert; -use SilverStripe\Control\Director; use SilverStripe\Control\Controller; +use SilverStripe\Control\Director; +use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\Session; +use SilverStripe\Core\Convert; use SilverStripe\ORM\FieldType\DBField; use SilverStripe\View\Requirements; @@ -22,6 +22,7 @@ class CMSSecurity extends Security ); private static $allowed_actions = array( + 'login', 'LoginForm', 'success' ); @@ -41,12 +42,27 @@ class CMSSecurity extends Security Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/client/dist/js/vendor.js'); } + public function login($request = null, $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 function getApplicableAuthenticators($service = Authenticator::CMS_LOGIN) + { + return parent::getApplicableAuthenticators($service); + } + /** * Get known logged out member * @@ -57,6 +73,7 @@ class CMSSecurity extends Security if ($tempid = $this->getRequest()->requestVar('tempid')) { return Member::member_from_tempid($tempid); } + return null; } @@ -78,7 +95,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'); } @@ -129,6 +146,7 @@ setTimeout(function(){top.location.href = "$loginURLJS";}, 0); PHP ); $this->setResponse($response); + return $response; } @@ -142,19 +160,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 +168,11 @@ 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) { - // Supported if at least one authenticator is supported - if ($authenticator::supports_cms()) { - return true; - } - } - return false; - } - - public function LoginForm() - { - $authenticator = $this->getAuthenticator(); - if ($authenticator && $authenticator::supports_cms()) { - return $authenticator::get_cms_login_form($this); - } - user_error('Passed invalid authentication method', E_USER_ERROR); + return count(Security::singleton()->getApplicableAuthenticators(Authenticator::CMS_LOGIN)) > 0; } /** @@ -195,7 +183,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(); } @@ -204,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) { @@ -217,7 +205,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/ChangePasswordForm.php b/src/Security/ChangePasswordForm.php deleted file mode 100644 index e2db15e14..000000000 --- a/src/Security/ChangePasswordForm.php +++ /dev/null @@ -1,65 +0,0 @@ -getBackURL() ?: Session::get('BackURL'); - - if (!$fields) { - $fields = new FieldList(); - - // Security/changepassword?h=XXX redirects to Security/changepassword - // without GET parameter to avoid potential HTTP referer leakage. - // In this case, a user is not logged in, and no 'old password' should be necessary. - if (Member::currentUser()) { - $fields->push(new PasswordField("OldPassword", _t('SilverStripe\\Security\\Member.YOUROLDPASSWORD', "Your old password"))); - } - - $fields->push(new PasswordField("NewPassword1", _t('SilverStripe\\Security\\Member.NEWPASSWORD', "New Password"))); - $fields->push(new PasswordField("NewPassword2", _t('SilverStripe\\Security\\Member.CONFIRMNEWPASSWORD', "Confirm New Password"))); - } - if (!$actions) { - $actions = new FieldList( - new FormAction("doChangePassword", _t('SilverStripe\\Security\\Member.BUTTONCHANGEPASSWORD', "Change Password")) - ); - } - - if ($backURL) { - $fields->push(new HiddenField('BackURL', false, $backURL)); - } - - parent::__construct($controller, $name, $fields, $actions); - } - - /** - * @return ChangePasswordHandler - */ - protected function buildRequestHandler() - { - return ChangePasswordHandler::create($this); - } -} diff --git a/src/Security/ChangePasswordHandler.php b/src/Security/ChangePasswordHandler.php deleted file mode 100644 index ccc9cd326..000000000 --- a/src/Security/ChangePasswordHandler.php +++ /dev/null @@ -1,103 +0,0 @@ -checkPassword($data['OldPassword'])->isValid() - )) { - $this->form->sessionMessage( - _t('SilverStripe\\Security\\Member.ERRORPASSWORDNOTMATCH', "Your current password does not match, please try again"), - "bad" - ); - // redirect back to the form, instead of using redirectBack() which could send the user elsewhere. - return $this->redirectBackToForm(); - } - - if (!$member) { - if (Session::get('AutoLoginHash')) { - $member = Member::member_from_autologinhash(Session::get('AutoLoginHash')); - } - - // The user is not logged in and no valid auto login hash is available - if (!$member) { - Session::clear('AutoLoginHash'); - return $this->redirect($this->addBackURLParam(Security::singleton()->Link('login'))); - } - } - - // Check the new password - if (empty($data['NewPassword1'])) { - $this->form->sessionMessage( - _t('SilverStripe\\Security\\Member.EMPTYNEWPASSWORD', "The new password can't be empty, please try again"), - "bad" - ); - - // redirect back to the form, instead of using redirectBack() which could send the user elsewhere. - return $this->redirectBackToForm(); - } - - // Fail if passwords do not match - if ($data['NewPassword1'] !== $data['NewPassword2']) { - $this->form->sessionMessage( - _t('SilverStripe\\Security\\Member.ERRORNEWPASSWORD', "You have entered your new password differently, try again"), - "bad" - ); - // redirect back to the form, instead of using redirectBack() which could send the user elsewhere. - return $this->redirectBackToForm(); - } - - // Check if the new password is accepted - $validationResult = $member->changePassword($data['NewPassword1']); - if (!$validationResult->isValid()) { - $this->form->setSessionValidationResult($validationResult); - return $this->redirectBackToForm(); - } - - // Clear locked out status - $member->LockedOutUntil = null; - $member->FailedLoginCount = null; - $member->write(); - - if ($member->canLogIn()->isValid()) { - $member->logIn(); - } - - // TODO Add confirmation message to login redirect - Session::clear('AutoLoginHash'); - - // Redirect to backurl - $backURL = $this->getBackURL(); - if ($backURL) { - return $this->redirect($backURL); - } - - // Redirect to default location - the login form saying "You are logged in as..." - $url = Security::singleton()->Link('login'); - return $this->redirect($url); - } - - public function redirectBackToForm() - { - // Redirect back to form - $url = $this->addBackURLParam(CMSSecurity::singleton()->Link('changepassword')); - return $this->redirect($url); - } -} diff --git a/src/Security/Group.php b/src/Security/Group.php index 204f1a077..333ead2d5 100755 --- a/src/Security/Group.php +++ b/src/Security/Group.php @@ -4,24 +4,24 @@ namespace SilverStripe\Security; use SilverStripe\Admin\SecurityAdmin; use SilverStripe\Core\Convert; -use SilverStripe\Forms\Form; -use SilverStripe\Forms\GridField\GridFieldAddExistingAutocompleter; -use SilverStripe\Forms\GridField\GridFieldDetailForm; -use SilverStripe\Forms\TextField; use SilverStripe\Forms\DropdownField; -use SilverStripe\Forms\TextareaField; -use SilverStripe\Forms\Tab; -use SilverStripe\Forms\TabSet; use SilverStripe\Forms\FieldList; -use SilverStripe\Forms\LiteralField; -use SilverStripe\Forms\ListboxField; -use SilverStripe\Forms\HiddenField; -use SilverStripe\Forms\HTMLEditor\HTMLEditorConfig; -use SilverStripe\Forms\GridField\GridFieldConfig_RelationEditor; +use SilverStripe\Forms\Form; +use SilverStripe\Forms\GridField\GridField; +use SilverStripe\Forms\GridField\GridFieldAddExistingAutocompleter; use SilverStripe\Forms\GridField\GridFieldButtonRow; +use SilverStripe\Forms\GridField\GridFieldConfig_RelationEditor; +use SilverStripe\Forms\GridField\GridFieldDetailForm; use SilverStripe\Forms\GridField\GridFieldExportButton; use SilverStripe\Forms\GridField\GridFieldPrintButton; -use SilverStripe\Forms\GridField\GridField; +use SilverStripe\Forms\HiddenField; +use SilverStripe\Forms\HTMLEditor\HTMLEditorConfig; +use SilverStripe\Forms\ListboxField; +use SilverStripe\Forms\LiteralField; +use SilverStripe\Forms\Tab; +use SilverStripe\Forms\TabSet; +use SilverStripe\Forms\TextareaField; +use SilverStripe\Forms\TextField; use SilverStripe\ORM\ArrayList; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataQuery; @@ -29,7 +29,6 @@ use SilverStripe\ORM\HasManyList; use SilverStripe\ORM\Hierarchy\Hierarchy; use SilverStripe\ORM\ManyManyList; use SilverStripe\ORM\UnsavedRelationList; -use SilverStripe\View\Requirements; /** * A security group. @@ -95,6 +94,7 @@ class Group extends DataObject $doSet = new ArrayList(); $children = Group::get()->filter("ParentID", $this->ID); + /** @var Group $child */ foreach ($children as $child) { $doSet->push($child); $doSet->merge($child->getAllChildren()); @@ -159,7 +159,7 @@ class Group extends DataObject $detailForm = $config->getComponentByType(GridFieldDetailForm::class); $detailForm ->setValidator(Member_Validator::create()) - ->setItemEditFormCallback(function ($form, $component) use ($group) { + ->setItemEditFormCallback(function ($form) use ($group) { /** @var Form $form */ $record = $form->getRecord(); $groupsField = $form->Fields()->dataFieldByName('DirectGroups'); @@ -369,9 +369,9 @@ class Group extends DataObject { $parent = $this; $items = []; - while (isset($parent) && $parent instanceof Group) { + while ($parent instanceof Group) { $items[] = $parent->ID; - $parent = $parent->Parent; + $parent = $parent->getParent(); } return $items; } @@ -395,12 +395,14 @@ class Group extends DataObject ->sort('"Sort"'); } + /** + * @return string + */ public function getTreeTitle() { - if ($this->hasMethod('alternateTreeTitle')) { - return $this->alternateTreeTitle(); - } - return htmlspecialchars($this->Title, ENT_QUOTES); + $title = htmlspecialchars($this->Title, ENT_QUOTES); + $this->extend('updateTreeTitle', $title); + return $title; } /** @@ -476,7 +478,7 @@ class Group extends DataObject public function canEdit($member = null) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } // extended access checks @@ -512,7 +514,7 @@ class Group extends DataObject public function canView($member = null) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } // extended access checks @@ -534,7 +536,7 @@ class Group extends DataObject public function canDelete($member = null) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } // extended access checks diff --git a/src/Security/GroupCsvBulkLoader.php b/src/Security/GroupCsvBulkLoader.php index 8abd7f3e9..182efeade 100644 --- a/src/Security/GroupCsvBulkLoader.php +++ b/src/Security/GroupCsvBulkLoader.php @@ -2,8 +2,8 @@ namespace SilverStripe\Security; -use SilverStripe\ORM\DataObject; use SilverStripe\Dev\CsvBulkLoader; +use SilverStripe\ORM\DataObject; /** * @todo Migrate Permission->Arg and Permission->Type values @@ -15,12 +15,8 @@ class GroupCsvBulkLoader extends CsvBulkLoader 'Code' => 'Code', ); - public function __construct($objectClass = null) + public function __construct($objectClass = Group::class) { - if (!$objectClass) { - $objectClass = 'SilverStripe\\Security\\Group'; - } - parent::__construct($objectClass); } diff --git a/src/Security/IdentityStore.php b/src/Security/IdentityStore.php new file mode 100644 index 000000000..6a07f1653 --- /dev/null +++ b/src/Security/IdentityStore.php @@ -0,0 +1,30 @@ +canEditMultiple($ids, Member::currentUser(), false); + $this->canEditMultiple($ids, Security::getCurrentUser(), false); break; case self::VIEW: - $this->canViewMultiple($ids, Member::currentUser(), false); + $this->canViewMultiple($ids, Security::getCurrentUser(), false); break; case self::DELETE: - $this->canDeleteMultiple($ids, Member::currentUser(), false); + $this->canDeleteMultiple($ids, Security::getCurrentUser(), false); break; default: throw new InvalidArgumentException("Invalid permission type $permission"); diff --git a/src/Security/LoginForm.php b/src/Security/LoginForm.php index 2bb6d60ad..c106a97c3 100644 --- a/src/Security/LoginForm.php +++ b/src/Security/LoginForm.php @@ -2,7 +2,6 @@ namespace SilverStripe\Security; -use SilverStripe\Core\Injector\Injector; use SilverStripe\Forms\FieldList; use SilverStripe\Forms\Form; diff --git a/src/Security/Member.php b/src/Security/Member.php index 6dba32a06..e7895b665 100644 --- a/src/Security/Member.php +++ b/src/Security/Member.php @@ -3,16 +3,16 @@ namespace SilverStripe\Security; use IntlDateFormatter; +use InvalidArgumentException; use SilverStripe\Admin\LeftAndMain; use SilverStripe\CMS\Controllers\CMSMain; -use SilverStripe\Control\Cookie; +use SilverStripe\Control\Controller; use SilverStripe\Control\Director; use SilverStripe\Control\Email\Email; use SilverStripe\Control\Email\Mailer; -use SilverStripe\Control\Session; use SilverStripe\Core\Convert; use SilverStripe\Core\Injector\Injector; -use SilverStripe\Dev\SapphireTest; +use SilverStripe\Dev\Deprecation; use SilverStripe\Dev\TestMailer; use SilverStripe\Forms\ConfirmedPasswordField; use SilverStripe\Forms\DropdownField; @@ -20,20 +20,17 @@ use SilverStripe\Forms\FieldList; use SilverStripe\Forms\HTMLEditor\HTMLEditorConfig; use SilverStripe\Forms\ListboxField; use SilverStripe\i18n\i18n; -use SilverStripe\MSSQL\MSSQLDatabase; use SilverStripe\ORM\ArrayList; +use SilverStripe\ORM\DataList; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DB; use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\ORM\HasManyList; use SilverStripe\ORM\ManyManyList; -use SilverStripe\ORM\SS_List; use SilverStripe\ORM\Map; +use SilverStripe\ORM\SS_List; use SilverStripe\ORM\ValidationException; use SilverStripe\ORM\ValidationResult; -use SilverStripe\View\SSViewer; -use SilverStripe\View\TemplateGlobalProvider; -use DateTime; /** * The member class which represents the users of the system @@ -56,30 +53,31 @@ use DateTime; * @property int $FailedLoginCount * @property string $DateFormat * @property string $TimeFormat + * @property string $SetPassword Pseudo-DB field for temp storage. Not emitted to DB */ -class Member extends DataObject implements TemplateGlobalProvider +class Member extends DataObject { private static $db = array( - 'FirstName' => 'Varchar', - 'Surname' => 'Varchar', - 'Email' => 'Varchar(254)', // See RFC 5321, Section 4.5.3.1.3. (256 minus the < and > character) - 'TempIDHash' => 'Varchar(160)', // Temporary id used for cms re-authentication - 'TempIDExpired' => 'Datetime', // Expiry of temp login - 'Password' => 'Varchar(160)', - 'AutoLoginHash' => 'Varchar(160)', // Used to auto-login the user on password reset - 'AutoLoginExpired' => 'Datetime', + 'FirstName' => 'Varchar', + 'Surname' => 'Varchar', + 'Email' => 'Varchar(254)', // See RFC 5321, Section 4.5.3.1.3. (256 minus the < and > character) + 'TempIDHash' => 'Varchar(160)', // Temporary id used for cms re-authentication + 'TempIDExpired' => 'Datetime', // Expiry of temp login + 'Password' => 'Varchar(160)', + 'AutoLoginHash' => 'Varchar(160)', // Used to auto-login the user on password reset + 'AutoLoginExpired' => 'Datetime', // This is an arbitrary code pointing to a PasswordEncryptor instance, // not an actual encryption algorithm. // Warning: Never change this field after its the first password hashing without // providing a new cleartext password as well. 'PasswordEncryption' => "Varchar(50)", - 'Salt' => 'Varchar(50)', - 'PasswordExpiry' => 'Date', - 'LockedOutUntil' => 'Datetime', - 'Locale' => 'Varchar(6)', + 'Salt' => 'Varchar(50)', + 'PasswordExpiry' => 'Date', + 'LockedOutUntil' => 'Datetime', + 'Locale' => 'Varchar(6)', // handled in registerFailedLogin(), only used if $lock_out_after_incorrect_logins is set - 'FailedLoginCount' => 'Int', + 'FailedLoginCount' => 'Int', ); private static $belongs_many_many = array( @@ -87,7 +85,7 @@ class Member extends DataObject implements TemplateGlobalProvider ); private static $has_many = array( - 'LoggedPasswords' => MemberPassword::class, + 'LoggedPasswords' => MemberPassword::class, 'RememberLoginHashes' => RememberLoginHash::class, ); @@ -191,6 +189,12 @@ class Member extends DataObject implements TemplateGlobalProvider */ private static $password_expiry_days = null; + /** + * @config + * @var bool enable or disable logging of previously used passwords. See {@link onAfterWrite} + */ + private static $password_logging_enabled = true; + /** * @config * @var Int Number of incorrect logins after which @@ -276,7 +280,7 @@ class Member extends DataObject implements TemplateGlobalProvider // Find member /** @skipUpgrade */ - $admin = Member::get() + $admin = static::get() ->filter('Email', Security::default_admin_username()) ->first(); if (!$admin) { @@ -284,7 +288,7 @@ class Member extends DataObject implements TemplateGlobalProvider // persistent logins in the database. See Security::setDefaultAdmin(). // Set 'Email' to identify this as the default admin $admin = Member::create(); - $admin->FirstName = _t(__CLASS__.'.DefaultAdminFirstname', 'Default Admin'); + $admin->FirstName = _t(__CLASS__ . '.DefaultAdminFirstname', 'Default Admin'); $admin->Email = Security::default_admin_username(); $admin->write(); } @@ -323,14 +327,15 @@ class Member extends DataObject implements TemplateGlobalProvider // Check a password is set on this member if (empty($this->Password) && $this->exists()) { - $result->addError(_t(__CLASS__.'.NoPassword', 'There is no password on this member.')); + $result->addError(_t(__CLASS__ . '.NoPassword', 'There is no password on this member.')); + return $result; } $e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption); if (!$e->check($this->Password, $password, $this->Salt, $this)) { $result->addError(_t( - __CLASS__.'.ERRORWRONGCRED', + __CLASS__ . '.ERRORWRONGCRED', 'The provided details don\'t seem to be correct. Please try again.' )); } @@ -364,16 +369,17 @@ class Member extends DataObject implements TemplateGlobalProvider if ($this->isLockedOut()) { $result->addError( _t( - __CLASS__.'.ERRORLOCKEDOUT2', + __CLASS__ . '.ERRORLOCKEDOUT2', 'Your account has been temporarily disabled because of too many failed attempts at ' . 'logging in. Please try again in {count} minutes.', null, - array('count' => $this->config()->lock_out_delay_mins) + array('count' => static::config()->get('lock_out_delay_mins')) ) ); } $this->extend('canLogIn', $result); + return $result; } @@ -387,36 +393,10 @@ class Member extends DataObject implements TemplateGlobalProvider if (!$this->LockedOutUntil) { return false; } + return DBDatetime::now()->getTimestamp() < $this->dbObject('LockedOutUntil')->getTimestamp(); } - /** - * Regenerate the session_id. - * This wrapper is here to make it easier to disable calls to session_regenerate_id(), should you need to. - * They have caused problems in certain - * quirky problems (such as using the Windmill 0.3.6 proxy). - */ - public static function session_regenerate_id() - { - if (!self::config()->session_regenerate_id) { - return; - } - - // This can be called via CLI during testing. - if (Director::is_cli()) { - return; - } - - $file = ''; - $line = ''; - - // @ is to supress win32 warnings/notices when session wasn't cleaned up properly - // There's nothing we can do about this, because it's an operating system function! - if (!headers_sent($file, $line)) { - @session_regenerate_id(true); - } - } - /** * Set a {@link PasswordValidator} object to use to validate member's passwords. * @@ -443,63 +423,48 @@ class Member extends DataObject implements TemplateGlobalProvider if (!$this->PasswordExpiry) { return false; } + return strtotime(date('Y-m-d')) >= strtotime($this->PasswordExpiry); } /** - * Logs this member in + * @deprecated 5.0.0 Use Security::setCurrentUser() or IdentityStore::logIn() * - * @param bool $remember If set to TRUE, the member will be logged in automatically the next time. */ - public function logIn($remember = false) + public function logIn() { + Deprecation::notice( + '5.0.0', + 'This method is deprecated and only logs in for the current request. Please use Security::setCurrentUser($user) or an IdentityStore' + ); + Security::setCurrentUser($this); + } + + /** + * Called before a member is logged in via session/cookie/etc + */ + public function beforeMemberLoggedIn() + { + // @todo Move to middleware on the AuthenticationRequestFilter IdentityStore $this->extend('beforeMemberLoggedIn'); + } - self::session_regenerate_id(); - - Session::set("loggedInAs", $this->ID); - // This lets apache rules detect whether the user has logged in - if (Member::config()->login_marker_cookie) { - Cookie::set(Member::config()->login_marker_cookie, 1, 0); - } - - if (Security::config()->autologin_enabled) { - // Cleans up any potential previous hash for this member on this device - if ($alcDevice = Cookie::get('alc_device')) { - RememberLoginHash::get()->filter('DeviceID', $alcDevice)->removeAll(); - } - if ($remember) { - $rememberLoginHash = RememberLoginHash::generate($this); - $tokenExpiryDays = RememberLoginHash::config()->uninherited('token_expiry_days'); - $deviceExpiryDays = RememberLoginHash::config()->uninherited('device_expiry_days'); - Cookie::set( - 'alc_enc', - $this->ID . ':' . $rememberLoginHash->getToken(), - $tokenExpiryDays, - null, - null, - null, - true - ); - Cookie::set('alc_device', $rememberLoginHash->DeviceID, $deviceExpiryDays, null, null, null, true); - } else { - Cookie::set('alc_enc', null); - Cookie::set('alc_device', null); - Cookie::force_expiry('alc_enc'); - Cookie::force_expiry('alc_device'); - } - } + /** + * Called after a member is logged in via session/cookie/etc + */ + public function afterMemberLoggedIn() + { // Clear the incorrect log-in count $this->registerSuccessfulLogin(); - $this->LockedOutUntil = null; + $this->LockedOutUntil = null; $this->regenerateTempID(); $this->write(); // Audit logging hook - $this->extend('memberLoggedIn'); + $this->extend('afterMemberLoggedIn'); } /** @@ -511,9 +476,10 @@ class Member extends DataObject implements TemplateGlobalProvider public function regenerateTempID() { $generator = new RandomGenerator(); + $lifetime = self::config()->get('temp_id_lifetime'); $this->TempIDHash = $generator->randomToken('sha1'); - $this->TempIDExpired = self::config()->temp_id_lifetime - ? date('Y-m-d H:i:s', strtotime(DBDatetime::now()->getValue()) + self::config()->temp_id_lifetime) + $this->TempIDExpired = $lifetime + ? date('Y-m-d H:i:s', strtotime(DBDatetime::now()->getValue()) + $lifetime) : null; $this->write(); } @@ -523,15 +489,20 @@ class Member extends DataObject implements TemplateGlobalProvider * has a database record of the same ID. If there is * no logged in user, FALSE is returned anyway. * + * @deprecated Not needed anymore, as it returns Security::getCurrentUser(); + * * @return boolean TRUE record found FALSE no record found */ public static function logged_in_session_exists() { - if ($id = Member::currentUserID()) { - if ($member = DataObject::get_by_id(Member::class, $id)) { - if ($member->exists()) { - return true; - } + Deprecation::notice( + '5.0.0', + 'This method is deprecated and now does not add value. Please use Security::getCurrentUser()' + ); + + if ($member = Security::getCurrentUser()) { + if ($member && $member->exists()) { + return true; } } @@ -539,125 +510,21 @@ class Member extends DataObject implements TemplateGlobalProvider } /** - * Log the user in if the "remember login" cookie is set - * - * The remember login token will be changed on every successful - * auto-login. - */ - public static function autoLogin() - { - // Don't bother trying this multiple times - if (!class_exists(SapphireTest::class, false) || !SapphireTest::is_running_test()) { - self::$_already_tried_to_auto_log_in = true; - } - - if (!Security::config()->autologin_enabled - || strpos(Cookie::get('alc_enc'), ':') === false - || Session::get("loggedInAs") - || !Security::database_is_ready() - ) { - return; - } - - if (strpos(Cookie::get('alc_enc'), ':') && Cookie::get('alc_device') && !Session::get("loggedInAs")) { - list($uid, $token) = explode(':', Cookie::get('alc_enc'), 2); - - if (!$uid || !$token) { - return; - } - - $deviceID = Cookie::get('alc_device'); - - /** @var Member $member */ - $member = Member::get()->byID($uid); - - /** @var RememberLoginHash $rememberLoginHash */ - $rememberLoginHash = null; - - // check if autologin token matches - if ($member) { - $hash = $member->encryptWithUserSettings($token); - $rememberLoginHash = RememberLoginHash::get() - ->filter(array( - 'MemberID' => $member->ID, - 'DeviceID' => $deviceID, - 'Hash' => $hash - ))->first(); - if (!$rememberLoginHash) { - $member = null; - } else { - // Check for expired token - $expiryDate = new DateTime($rememberLoginHash->ExpiryDate); - $now = DBDatetime::now(); - $now = new DateTime($now->Rfc2822()); - if ($now > $expiryDate) { - $member = null; - } - } - } - - if ($member) { - self::session_regenerate_id(); - Session::set("loggedInAs", $member->ID); - // This lets apache rules detect whether the user has logged in - if (Member::config()->login_marker_cookie) { - Cookie::set(Member::config()->login_marker_cookie, 1, 0, null, null, false, true); - } - - if ($rememberLoginHash) { - $rememberLoginHash->renew(); - $tokenExpiryDays = RememberLoginHash::config()->uninherited('token_expiry_days'); - Cookie::set( - 'alc_enc', - $member->ID . ':' . $rememberLoginHash->getToken(), - $tokenExpiryDays, - null, - null, - false, - true - ); - } - - $member->write(); - - // Audit logging hook - $member->extend('memberAutoLoggedIn'); - } - } - } - - /** + * @deprecated Use Security::setCurrentUser(null) or an IdentityStore * Logs this member out. */ public function logOut() { + Deprecation::notice( + '5.0.0', + 'This method is deprecated and now does not persist. Please use Security::setCurrentUser(null) or an IdenityStore' + ); + $this->extend('beforeMemberLoggedOut'); - Session::clear("loggedInAs"); - if (Member::config()->login_marker_cookie) { - Cookie::set(Member::config()->login_marker_cookie, null, 0); - } - - Session::destroy(); - - $this->extend('memberLoggedOut'); - - // Clears any potential previous hashes for this member - RememberLoginHash::clear($this, Cookie::get('alc_device')); - - Cookie::set('alc_enc', null); // // Clear the Remember Me cookie - Cookie::force_expiry('alc_enc'); - Cookie::set('alc_device', null); - Cookie::force_expiry('alc_device'); - - // Switch back to live in order to avoid infinite loops when - // redirecting to the login screen (if this login screen is versioned) - Session::clear('readingMode'); - - $this->write(); - + Injector::inst()->get(IdentityStore::class)->logOut(Controller::curr()->getRequest()); // Audit logging hook - $this->extend('memberLoggedOut'); + $this->extend('afterMemberLoggedOut'); } /** @@ -681,6 +548,7 @@ class Member extends DataObject implements TemplateGlobalProvider // We assume we have PasswordEncryption and Salt available here. $e = PasswordEncryptor::create_for_algorithm($this->PasswordEncryption); + return $e->encrypt($string, $this->Salt); } @@ -723,6 +591,7 @@ class Member extends DataObject implements TemplateGlobalProvider { $hash = $this->encryptWithUserSettings($autologinToken); $member = self::member_from_autologinhash($hash, false); + return (bool)$member; } @@ -738,13 +607,13 @@ class Member extends DataObject implements TemplateGlobalProvider public static function member_from_autologinhash($hash, $login = false) { /** @var Member $member */ - $member = Member::get()->filter([ - 'AutoLoginHash' => $hash, + $member = static::get()->filter([ + 'AutoLoginHash' => $hash, 'AutoLoginExpired:GreaterThan' => DBDatetime::now()->getValue(), ])->first(); if ($login && $member) { - $member->logIn(); + Injector::inst()->get(IdentityStore::class)->logIn($member); } return $member; @@ -758,11 +627,12 @@ class Member extends DataObject implements TemplateGlobalProvider */ public static function member_from_tempid($tempid) { - $members = Member::get() + $members = static::get() ->filter('TempIDHash', $tempid); // Exclude expired - if (static::config()->temp_id_lifetime) { + if (static::config()->get('temp_id_lifetime')) { + /** @var DataList|Member[] $members */ $members = $members->filter('TempIDExpired:GreaterThan', DBDatetime::now()->getValue()); } @@ -773,6 +643,8 @@ class Member extends DataObject implements TemplateGlobalProvider * Returns the fields for the member form - used in the registration/profile module. * It should return fields that are editable by the admin and the logged-in user. * + * @todo possibly move this to an extension + * * @return FieldList Returns a {@link FieldList} containing the fields for * the member form. */ @@ -788,11 +660,12 @@ class Member extends DataObject implements TemplateGlobalProvider i18n::getSources()->getKnownLocales() )); - $fields->removeByName(static::config()->hidden_fields); + $fields->removeByName(static::config()->get('hidden_fields')); $fields->removeByName('FailedLoginCount'); $this->extend('updateMemberFormFields', $fields); + return $fields; } @@ -805,7 +678,7 @@ class Member extends DataObject implements TemplateGlobalProvider { $editingPassword = $this->isInDB(); $label = $editingPassword - ? _t(__CLASS__.'.EDIT_PASSWORD', 'New Password') + ? _t(__CLASS__ . '.EDIT_PASSWORD', 'New Password') : $this->fieldLabel('Password'); /** @var ConfirmedPasswordField $password */ $password = ConfirmedPasswordField::create( @@ -817,12 +690,13 @@ class Member extends DataObject implements TemplateGlobalProvider ); // If editing own password, require confirmation of existing - if ($editingPassword && $this->ID == Member::currentUserID()) { + if ($editingPassword && $this->ID == Security::getCurrentUser()->ID) { $password->setRequireExistingPassword(true); } $password->setCanBeEmpty(true); $this->extend('updateMemberPasswordField', $password); + return $password; } @@ -850,24 +724,20 @@ class Member extends DataObject implements TemplateGlobalProvider /** * Returns the current logged in user * + * @deprecated 5.0.0 use Security::getCurrentUser() + * * @return Member */ public static function currentUser() { - $id = Member::currentUserID(); + Deprecation::notice( + '5.0.0', + 'This method is deprecated. Please use Security::getCurrentUser() or an IdentityStore' + ); - if ($id) { - return DataObject::get_by_id(Member::class, $id); - } + return Security::getCurrentUser(); } - /** - * Allow override of the current user ID - * - * @var int|null Set to null to fallback to session, or an explicit ID - */ - protected static $overrideID = null; - /** * Temporarily act as the specified user, limited to a $callback, but * without logging in as that user. @@ -881,49 +751,52 @@ class Member extends DataObject implements TemplateGlobalProvider * * @param Member|null|int $member Member or member ID to log in as. * Set to null or 0 to act as a logged out user. - * @param $callback + * @param callable $callback */ public static function actAs($member, $callback) { - $id = ($member instanceof Member ? $member->ID : $member) ?: 0; - $previousID = static::$overrideID; - static::$overrideID = $id; + $previousUser = Security::getCurrentUser(); + + // Transform ID to member + if (is_numeric($member)) { + $member = DataObject::get_by_id(Member::class, $member); + } + Security::setCurrentUser($member); + try { return $callback(); } finally { - static::$overrideID = $previousID; + Security::setCurrentUser($previousUser); } } /** * Get the ID of the current logged in user * + * @deprecated 5.0.0 use Security::getCurrentUser() + * * @return int Returns the ID of the current logged in user or 0. */ public static function currentUserID() { - if (isset(static::$overrideID)) { - return static::$overrideID; - } + Deprecation::notice( + '5.0.0', + 'This method is deprecated. Please use Security::getCurrentUser() or an IdentityStore' + ); - $id = Session::get("loggedInAs"); - if (!$id && !self::$_already_tried_to_auto_log_in) { - self::autoLogin(); - $id = Session::get("loggedInAs"); + if ($member = Security::getCurrentUser()) { + return $member->ID; + } else { + return 0; } - - return is_numeric($id) ? $id : 0; } - private static $_already_tried_to_auto_log_in = false; - - - /* - * Generate a random password, with randomiser to kick in if there's no words file on the - * filesystem. - * - * @return string Returns a random password. - */ + /** + * Generate a random password, with randomiser to kick in if there's no words file on the + * filesystem. + * + * @return string Returns a random password. + */ public static function create_new_password() { $words = Security::config()->uninherited('word_list'); @@ -932,16 +805,17 @@ class Member extends DataObject implements TemplateGlobalProvider $words = file($words); list($usec, $sec) = explode(' ', microtime()); - srand($sec + ((float) $usec * 100000)); + mt_srand($sec + ((float)$usec * 100000)); - $word = trim($words[rand(0, sizeof($words)-1)]); - $number = rand(10, 999); + $word = trim($words[random_int(0, count($words) - 1)]); + $number = random_int(10, 999); return $word . $number; } else { - $random = rand(); + $random = mt_rand(); $string = md5($random); $output = substr($string, 0, 8); + return $output; } } @@ -958,7 +832,7 @@ class Member extends DataObject implements TemplateGlobalProvider // If a member with the same "unique identifier" already exists with a different ID, don't allow merging. // Note: This does not a full replacement for safeguards in the controller layer (e.g. in a registration form), // but rather a last line of defense against data inconsistencies. - $identifierField = Member::config()->unique_identifier_field; + $identifierField = Member::config()->get('unique_identifier_field'); if ($this->$identifierField) { // Note: Same logic as Member_Validator class $filter = [ @@ -971,12 +845,12 @@ class Member extends DataObject implements TemplateGlobalProvider if ($existingRecord) { throw new ValidationException(_t( - __CLASS__.'.ValidationIdentifierFailed', + __CLASS__ . '.ValidationIdentifierFailed', 'Can\'t overwrite existing member #{id} with identical identifier ({name} = {value}))', 'Values in brackets show "fieldname = value", usually denoting an existing email address', array( - 'id' => $existingRecord->ID, - 'name' => $identifierField, + 'id' => $existingRecord->ID, + 'name' => $identifierField, 'value' => $this->$identifierField ) )); @@ -985,16 +859,17 @@ class Member extends DataObject implements TemplateGlobalProvider // We don't send emails out on dev/tests sites to prevent accidentally spamming users. // However, if TestMailer is in use this isn't a risk. + // @todo some developers use external tools, so emailing might be a good idea anyway if ((Director::isLive() || Injector::inst()->get(Mailer::class) instanceof TestMailer) && $this->isChanged('Password') && $this->record['Password'] - && $this->config()->notify_password_change + && static::config()->get('notify_password_change') ) { Email::create() ->setHTMLTemplate('SilverStripe\\Control\\Email\\ChangePasswordEmail') ->setData($this) ->setTo($this->Email) - ->setSubject(_t(__CLASS__.'.SUBJECTPASSWORDCHANGED', "Your password has been changed", 'Email subject')) + ->setSubject(_t(__CLASS__ . '.SUBJECTPASSWORDCHANGED', "Your password has been changed", 'Email subject')) ->send(); } @@ -1002,7 +877,7 @@ class Member extends DataObject implements TemplateGlobalProvider // Note that this only works with cleartext passwords, as we can't rehash // existing passwords. if ((!$this->ID && $this->Password) || $this->isChanged('Password')) { - //reset salt so that it gets regenerated - this will invalidate any persistant login cookies + //reset salt so that it gets regenerated - this will invalidate any persistent login cookies // or other information encrypted with this Member's settings (see self::encryptWithUserSettings) $this->Salt = ''; // Password was changed: encrypt the password according the settings @@ -1010,7 +885,7 @@ class Member extends DataObject implements TemplateGlobalProvider $this->Password, // this is assumed to be cleartext $this->Salt, ($this->PasswordEncryption) ? - $this->PasswordEncryption : Security::config()->password_encryption_algorithm, + $this->PasswordEncryption : Security::config()->get('password_encryption_algorithm'), $this ); @@ -1022,8 +897,8 @@ class Member extends DataObject implements TemplateGlobalProvider // If we haven't manually set a password expiry if (!$this->isChanged('PasswordExpiry')) { // then set it for us - if (self::config()->password_expiry_days) { - $this->PasswordExpiry = date('Y-m-d', time() + 86400 * self::config()->password_expiry_days); + if (static::config()->get('password_expiry_days')) { + $this->PasswordExpiry = date('Y-m-d', time() + 86400 * static::config()->get('password_expiry_days')); } else { $this->PasswordExpiry = null; } @@ -1044,7 +919,7 @@ class Member extends DataObject implements TemplateGlobalProvider Permission::reset(); - if ($this->isChanged('Password')) { + if ($this->isChanged('Password') && static::config()->get('password_logging_enabled')) { MemberPassword::log($this); } } @@ -1068,6 +943,7 @@ class Member extends DataObject implements TemplateGlobalProvider $password->delete(); $password->destroy(); } + return $this; } @@ -1086,9 +962,10 @@ class Member extends DataObject implements TemplateGlobalProvider } // If there are no admin groups in this set then it's ok - $adminGroups = Permission::get_groups_by_permission('ADMIN'); - $adminGroupIDs = ($adminGroups) ? $adminGroups->column('ID') : array(); - return count(array_intersect($ids, $adminGroupIDs)) == 0; + $adminGroups = Permission::get_groups_by_permission('ADMIN'); + $adminGroupIDs = ($adminGroups) ? $adminGroups->column('ID') : array(); + + return count(array_intersect($ids, $adminGroupIDs)) == 0; } @@ -1131,7 +1008,7 @@ class Member extends DataObject implements TemplateGlobalProvider } elseif ($group instanceof Group) { $groupCheckObj = $group; } else { - user_error('Member::inGroup(): Wrong format for $group parameter', E_USER_ERROR); + throw new InvalidArgumentException('Member::inGroup(): Wrong format for $group parameter'); } if (!$groupCheckObj) { @@ -1199,10 +1076,17 @@ class Member extends DataObject implements TemplateGlobalProvider */ public static function set_title_columns($columns, $sep = ' ') { + Deprecation::notice('5.0', 'Use Member.title_format config instead'); if (!is_array($columns)) { $columns = array($columns); } - self::config()->title_format = array('columns' => $columns, 'sep' => $sep); + self::config()->set( + 'title_format', + [ + 'columns' => $columns, + 'sep' => $sep + ] + ); } //------------------- HELPER METHODS -----------------------------------// @@ -1219,13 +1103,14 @@ class Member extends DataObject implements TemplateGlobalProvider */ public function getTitle() { - $format = $this->config()->title_format; + $format = static::config()->get('title_format'); if ($format) { $values = array(); foreach ($format['columns'] as $col) { $values[] = $this->getField($col); } - return join($format['sep'], $values); + + return implode($format['sep'], $values); } if ($this->getField('ID') === 0) { return $this->getField('Surname'); @@ -1250,25 +1135,24 @@ class Member extends DataObject implements TemplateGlobalProvider */ public static function get_title_sql() { - // This should be abstracted to SSDatabase concatOperator or similar. - $op = (DB::get_conn() instanceof MSSQLDatabase) ? " + " : " || "; // Get title_format with fallback to default - $format = static::config()->title_format; + $format = static::config()->get('title_format'); if (!$format) { $format = [ 'columns' => ['Surname', 'FirstName'], - 'sep' => ' ', + 'sep' => ' ', ]; } - $columnsWithTablename = array(); + $columnsWithTablename = array(); foreach ($format['columns'] as $column) { $columnsWithTablename[] = static::getSchema()->sqlColumnForField(__CLASS__, $column); } $sepSQL = Convert::raw2sql($format['sep'], true); - return "(".join(" $op $sepSQL $op ", $columnsWithTablename).")"; + $op = DB::get_conn()->concatOperator(); + return "(" . join(" $op $sepSQL $op ", $columnsWithTablename) . ")"; } @@ -1339,6 +1223,7 @@ class Member extends DataObject implements TemplateGlobalProvider if ($locale) { return $locale; } + return i18n::get_locale(); } @@ -1415,16 +1300,18 @@ class Member extends DataObject implements TemplateGlobalProvider // No groups, return all Members if (!$groupIDList) { - return Member::get()->sort(array('Surname'=>'ASC', 'FirstName'=>'ASC'))->map(); + return static::get()->sort(array('Surname' => 'ASC', 'FirstName' => 'ASC'))->map(); } $membersList = new ArrayList(); // This is a bit ineffective, but follow the ORM style + /** @var Group $group */ foreach (Group::get()->byIDs($groupIDList) as $group) { $membersList->merge($group->Members()); } $membersList->removeDuplicates('ID'); + return $membersList->map(); } @@ -1446,7 +1333,7 @@ class Member extends DataObject implements TemplateGlobalProvider return ArrayList::create()->map(); } - if (!$groups || $groups->Count() == 0) { + if (count($groups) == 0) { $perms = array('ADMIN', 'CMS_ACCESS_AssetAdmin'); if (class_exists(CMSMain::class)) { @@ -1479,7 +1366,7 @@ class Member extends DataObject implements TemplateGlobalProvider } /** @skipUpgrade */ - $members = Member::get() + $members = static::get() ->innerJoin("Group_Members", '"Group_Members"."MemberID" = "Member"."ID"') ->innerJoin("Group", '"Group"."ID" = "Group_Members"."GroupID"'); if ($groupIDList) { @@ -1539,12 +1426,12 @@ class Member extends DataObject implements TemplateGlobalProvider $mainFields->replaceField('Locale', new DropdownField( "Locale", - _t(__CLASS__.'.INTERFACELANG', "Interface Language", 'Language of the CMS'), + _t(__CLASS__ . '.INTERFACELANG', "Interface Language", 'Language of the CMS'), i18n::getSources()->getKnownLocales() )); - $mainFields->removeByName($this->config()->hidden_fields); + $mainFields->removeByName(static::config()->get('hidden_fields')); - if (! $this->config()->lock_out_after_incorrect_logins) { + if (!static::config()->get('lock_out_after_incorrect_logins')) { $mainFields->removeByName('FailedLoginCount'); } @@ -1570,7 +1457,7 @@ class Member extends DataObject implements TemplateGlobalProvider ->setSource($groupsMap) ->setAttribute( 'data-placeholder', - _t(__CLASS__.'.ADDGROUP', 'Add group', 'Placeholder text for a dropdown') + _t(__CLASS__ . '.ADDGROUP', 'Add group', 'Placeholder text for a dropdown') ) ); @@ -1609,21 +1496,22 @@ class Member extends DataObject implements TemplateGlobalProvider { $labels = parent::fieldLabels($includerelations); - $labels['FirstName'] = _t(__CLASS__.'.FIRSTNAME', 'First Name'); - $labels['Surname'] = _t(__CLASS__.'.SURNAME', 'Surname'); + $labels['FirstName'] = _t(__CLASS__ . '.FIRSTNAME', 'First Name'); + $labels['Surname'] = _t(__CLASS__ . '.SURNAME', 'Surname'); /** @skipUpgrade */ - $labels['Email'] = _t(__CLASS__.'.EMAIL', 'Email'); - $labels['Password'] = _t(__CLASS__.'.db_Password', 'Password'); - $labels['PasswordExpiry'] = _t(__CLASS__.'.db_PasswordExpiry', 'Password Expiry Date', 'Password expiry date'); - $labels['LockedOutUntil'] = _t(__CLASS__.'.db_LockedOutUntil', 'Locked out until', 'Security related date'); - $labels['Locale'] = _t(__CLASS__.'.db_Locale', 'Interface Locale'); + $labels['Email'] = _t(__CLASS__ . '.EMAIL', 'Email'); + $labels['Password'] = _t(__CLASS__ . '.db_Password', 'Password'); + $labels['PasswordExpiry'] = _t(__CLASS__ . '.db_PasswordExpiry', 'Password Expiry Date', 'Password expiry date'); + $labels['LockedOutUntil'] = _t(__CLASS__ . '.db_LockedOutUntil', 'Locked out until', 'Security related date'); + $labels['Locale'] = _t(__CLASS__ . '.db_Locale', 'Interface Locale'); if ($includerelations) { $labels['Groups'] = _t( - __CLASS__.'.belongs_many_many_Groups', + __CLASS__ . '.belongs_many_many_Groups', 'Groups', 'Security Groups this member belongs to' ); } + return $labels; } @@ -1639,7 +1527,7 @@ class Member extends DataObject implements TemplateGlobalProvider { //get member if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } //check for extensions, we do this first as they can overrule everything $extended = $this->extendedCan(__FUNCTION__, $member); @@ -1655,6 +1543,7 @@ class Member extends DataObject implements TemplateGlobalProvider if ($this->ID == $member->ID) { return true; } + //standard check return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin'); } @@ -1670,7 +1559,7 @@ class Member extends DataObject implements TemplateGlobalProvider { //get member if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } //check for extensions, we do this first as they can overrule everything $extended = $this->extendedCan(__FUNCTION__, $member); @@ -1691,9 +1580,11 @@ class Member extends DataObject implements TemplateGlobalProvider if ($this->ID == $member->ID) { return true; } + //standard check return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin'); } + /** * Users can edit their own record. * Otherwise they'll need ADMIN or CMS_ACCESS_SecurityAdmin permissions @@ -1704,7 +1595,7 @@ class Member extends DataObject implements TemplateGlobalProvider public function canDelete($member = null) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } //check for extensions, we do this first as they can overrule everything $extended = $this->extendedCan(__FUNCTION__, $member); @@ -1726,10 +1617,11 @@ class Member extends DataObject implements TemplateGlobalProvider // this is a hack because what this should do is to stop a user // deleting a member who has more privileges (e.g. a non-Admin deleting an Admin) if (Permission::checkMember($this, 'ADMIN')) { - if (! Permission::checkMember($member, 'ADMIN')) { + if (!Permission::checkMember($member, 'ADMIN')) { return false; } } + //standard check return Permission::checkMember($member, 'CMS_ACCESS_SecurityAdmin'); } @@ -1782,13 +1674,14 @@ class Member extends DataObject implements TemplateGlobalProvider */ public function registerFailedLogin() { - if (self::config()->lock_out_after_incorrect_logins) { + $lockOutAfterCount = self::config()->get('lock_out_after_incorrect_logins'); + if ($lockOutAfterCount) { // Keep a tally of the number of failed log-ins so that we can lock people out $this->FailedLoginCount = $this->FailedLoginCount + 1; - if ($this->FailedLoginCount >= self::config()->lock_out_after_incorrect_logins) { - $lockoutMins = self::config()->lock_out_delay_mins; - $this->LockedOutUntil = date('Y-m-d H:i:s', DBDatetime::now()->getTimestamp() + $lockoutMins*60); + if ($this->FailedLoginCount >= $lockOutAfterCount) { + $lockoutMins = self::config()->get('lock_out_delay_mins'); + $this->LockedOutUntil = date('Y-m-d H:i:s', DBDatetime::now()->getTimestamp() + $lockoutMins * 60); $this->FailedLoginCount = 0; } } @@ -1801,7 +1694,7 @@ class Member extends DataObject implements TemplateGlobalProvider */ public function registerSuccessfulLogin() { - if (self::config()->lock_out_after_incorrect_logins) { + if (self::config()->get('lock_out_after_incorrect_logins')) { // Forgive all past login failures $this->FailedLoginCount = 0; $this->write(); @@ -1834,12 +1727,4 @@ class Member extends DataObject implements TemplateGlobalProvider // If can't find a suitable editor, just default to cms return $currentName ? $currentName : 'cms'; } - - public static function get_template_global_variables() - { - return array( - 'CurrentMember' => 'currentUser', - 'currentUser', - ); - } } diff --git a/src/Security/MemberAuthenticator.php b/src/Security/MemberAuthenticator.php deleted file mode 100644 index 85431bb78..000000000 --- a/src/Security/MemberAuthenticator.php +++ /dev/null @@ -1,219 +0,0 @@ - - */ -class MemberAuthenticator extends Authenticator -{ - - /** - * Contains encryption algorithm identifiers. - * If set, will migrate to new precision-safe password hashing - * upon login. See http://open.silverstripe.org/ticket/3004 - * - * @var array - */ - private static $migrate_legacy_hashes = array( - 'md5' => 'md5_v2.4', - 'sha1' => 'sha1_v2.4' - ); - - /** - * Attempt to find and authenticate member if possible from the given data - * - * @param array $data - * @param Form $form - * @param bool &$success Success flag - * @return Member Found member, regardless of successful login - */ - protected static function authenticate_member($data, $form, &$success) - { - // Default success to false - $success = false; - - // Attempt to identify by temporary ID - $member = null; - $email = null; - if (!empty($data['tempid'])) { - // Find user by tempid, in case they are re-validating an existing session - $member = Member::member_from_tempid($data['tempid']); - if ($member) { - $email = $member->Email; - } - } - - // Otherwise, get email from posted value instead - /** @skipUpgrade */ - if (!$member && !empty($data['Email'])) { - $email = $data['Email']; - } - - // Check default login (see Security::setDefaultAdmin()) - $asDefaultAdmin = $email === Security::default_admin_username(); - if ($asDefaultAdmin) { - // If logging is as default admin, ensure record is setup correctly - $member = Member::default_admin(); - $success = !$member->isLockedOut() && Security::check_default_admin($email, $data['Password']); - //protect against failed login - if ($success) { - return $member; - } - } - - // Attempt to identify user by email - if (!$member && $email) { - // Find user by email - $member = Member::get() - ->filter(Member::config()->unique_identifier_field, $email) - ->first(); - } - - // Validate against member if possible - if ($member && !$asDefaultAdmin) { - $result = $member->checkPassword($data['Password']); - $success = $result->isValid(); - } else { - $result = ValidationResult::create()->addError(_t( - 'SilverStripe\\Security\\Member.ERRORWRONGCRED', - 'The provided details don\'t seem to be correct. Please try again.' - )); - } - - // Emit failure to member and form (if available) - if (!$success) { - if ($member) { - $member->registerFailedLogin(); - } - if ($form) { - $form->setSessionValidationResult($result, true); - } - } else { - if ($member) { - $member->registerSuccessfulLogin(); - } - } - - return $member; - } - - /** - * Log login attempt - * TODO We could handle this with an extension - * - * @param array $data - * @param Member $member - * @param bool $success - */ - protected static function record_login_attempt($data, $member, $success) - { - if (!Security::config()->login_recording) { - return; - } - - // Check email is valid - /** @skipUpgrade */ - $email = isset($data['Email']) ? $data['Email'] : null; - if (is_array($email)) { - throw new InvalidArgumentException("Bad email passed to MemberAuthenticator::authenticate(): $email"); - } - - $attempt = new LoginAttempt(); - if ($success) { - // successful login (member is existing with matching password) - $attempt->MemberID = $member->ID; - $attempt->Status = 'Success'; - - // Audit logging hook - $member->extend('authenticated'); - } else { - // Failed login - we're trying to see if a user exists with this email (disregarding wrong passwords) - $attempt->Status = 'Failure'; - if ($member) { - // Audit logging hook - $attempt->MemberID = $member->ID; - $member->extend('authenticationFailed'); - } else { - // Audit logging hook - Member::singleton()->extend('authenticationFailedUnknownUser', $data); - } - } - - $attempt->Email = $email; - $attempt->IP = Controller::curr()->getRequest()->getIP(); - $attempt->write(); - } - - /** - * Method to authenticate an user - * - * @param array $data Raw data to authenticate the user - * @param Form $form Optional: If passed, better error messages can be - * produced by using - * {@link Form::sessionMessage()} - * @return bool|Member Returns FALSE if authentication fails, otherwise - * the member object - * @see Security::setDefaultAdmin() - */ - public static function authenticate($data, Form $form = null) - { - // Find authenticated member - $member = static::authenticate_member($data, $form, $success); - - // Optionally record every login attempt as a {@link LoginAttempt} object - static::record_login_attempt($data, $member, $success); - - // Legacy migration to precision-safe password hashes. - // A login-event with cleartext passwords is the only time - // when we can rehash passwords to a different hashing algorithm, - // bulk-migration doesn't work due to the nature of hashing. - // See PasswordEncryptor_LegacyPHPHash class. - if ($success && $member && isset(self::$migrate_legacy_hashes[$member->PasswordEncryption])) { - $member->Password = $data['Password']; - $member->PasswordEncryption = self::$migrate_legacy_hashes[$member->PasswordEncryption]; - $member->write(); - } - - if ($success) { - Session::clear('BackURL'); - } - - return $success ? $member : null; - } - - - /** - * Method that creates the login form for this authentication method - * - * @param Controller $controller The parent controller, necessary to create the - * appropriate form action tag - * @return Form Returns the login form to use with this authentication - * method - */ - public static function get_login_form(Controller $controller) - { - /** @skipUpgrade */ - return MemberLoginForm::create($controller, self::class, "LoginForm"); - } - - public static function get_cms_login_form(Controller $controller) - { - /** @skipUpgrade */ - return CMSMemberLoginForm::create($controller, self::class, "LoginForm"); - } - - public static function supports_cms() - { - // Don't automatically support subclasses of MemberAuthenticator - return get_called_class() === __CLASS__; - } -} diff --git a/src/Security/CMSMemberLoginHandler.php b/src/Security/MemberAuthenticator/CMSLoginHandler.php similarity index 79% rename from src/Security/CMSMemberLoginHandler.php rename to src/Security/MemberAuthenticator/CMSLoginHandler.php index cec76bc2a..bd588c231 100644 --- a/src/Security/CMSMemberLoginHandler.php +++ b/src/Security/MemberAuthenticator/CMSLoginHandler.php @@ -1,27 +1,28 @@ performLogin($data)) { - return $this->logInUserAndRedirect($data); - } + private static $allowed_actions = [ + 'LoginForm' + ]; - return $this->redirectBackToForm(); + /** + * Return the CMSMemberLoginForm form + */ + public function loginForm() + { + return CMSMemberLoginForm::create( + $this, + get_class($this->authenticator), + 'LoginForm' + ); } public function redirectBackToForm() @@ -75,13 +76,12 @@ PHP /** * Send user to the right location after login * - * @param array $data * @return HTTPResponse */ - protected function logInUserAndRedirect($data) + protected function redirectAfterSuccessfulLogin() { // Check password expiry - if (Member::currentUser()->isPasswordExpired()) { + if (Security::getCurrentUser()->isPasswordExpired()) { // Redirect the user to the external password change form if necessary return $this->redirectToChangePassword(); } diff --git a/src/Security/MemberAuthenticator/CMSMemberAuthenticator.php b/src/Security/MemberAuthenticator/CMSMemberAuthenticator.php new file mode 100644 index 000000000..5f3cd3403 --- /dev/null +++ b/src/Security/MemberAuthenticator/CMSMemberAuthenticator.php @@ -0,0 +1,45 @@ +Email; + } + } + + return parent::authenticateMember($data, $result, $member); + } + + /** + * @param string $link + * @return CMSLoginHandler + */ + public function getLoginHandler($link) + { + return CMSLoginHandler::create($link, $this); + } +} diff --git a/src/Security/MemberAuthenticator/ChangePasswordForm.php b/src/Security/MemberAuthenticator/ChangePasswordForm.php new file mode 100644 index 000000000..5969c1f46 --- /dev/null +++ b/src/Security/MemberAuthenticator/ChangePasswordForm.php @@ -0,0 +1,81 @@ +getBackURL() ?: Session::get('BackURL'); + + if (!$fields) { + $fields = $this->getFormFields(); + } + if (!$actions) { + $actions = $this->getFormActions(); + } + + if ($backURL) { + $fields->push(HiddenField::create('BackURL', false, $backURL)); + } + + parent::__construct($controller, $name, $fields, $actions); + } + + /** + * @return FieldList + */ + protected function getFormFields() + { + $fields = FieldList::create(); + + // Security/changepassword?h=XXX redirects to Security/changepassword + // without GET parameter to avoid potential HTTP referer leakage. + // In this case, a user is not logged in, and no 'old password' should be necessary. + if (Security::getCurrentUser()) { + $fields->push(PasswordField::create('OldPassword', _t('SilverStripe\\Security\\Member.YOUROLDPASSWORD', 'Your old password'))); + } + + $fields->push(PasswordField::create('NewPassword1', _t('SilverStripe\\Security\\Member.NEWPASSWORD', 'New Password'))); + $fields->push(PasswordField::create('NewPassword2', _t('SilverStripe\\Security\\Member.CONFIRMNEWPASSWORD', 'Confirm New Password'))); + + return $fields; + } + + /** + * @return FieldList + */ + protected function getFormActions() + { + $actions = FieldList::create( + FormAction::create( + 'doChangePassword', + _t('SilverStripe\\Security\\Member.BUTTONCHANGEPASSWORD', 'Change Password') + ) + ); + + return $actions; + } +} diff --git a/src/Security/MemberAuthenticator/ChangePasswordHandler.php b/src/Security/MemberAuthenticator/ChangePasswordHandler.php new file mode 100644 index 000000000..8023b0d2a --- /dev/null +++ b/src/Security/MemberAuthenticator/ChangePasswordHandler.php @@ -0,0 +1,308 @@ + 'changepassword', + ]; + + /** + * @param string $link The URL to recreate this request handler + * @param MemberAuthenticator $authenticator + */ + public function __construct($link, MemberAuthenticator $authenticator) + { + $this->link = $link; + $this->authenticator = $authenticator; + parent::__construct(); + } + + /** + * Handle the change password request + * @todo this could use some spring cleaning + * + * @return array|HTTPResponse + */ + public function changepassword() + { + $request = $this->getRequest(); + + // Extract the member from the URL. + /** @var Member $member */ + $member = null; + if ($request->getVar('m') !== null) { + $member = Member::get()->filter(['ID' => (int)$request->getVar('m')])->first(); + } + $token = $request->getVar('t'); + + // Check whether we are merely changin password, or resetting. + if ($token !== null && $member && $member->validateAutoLoginToken($token)) { + $this->setSessionToken($member, $token); + + // Redirect to myself, but without the hash in the URL + return $this->redirect($this->link); + } + + if (Session::get('AutoLoginHash')) { + $message = DBField::create_field( + 'HTMLFragment', + '

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

' + ); + + // Subsequent request after the "first load with hash" (see previous if clause). + return [ + 'Content' => $message, + 'Form' => $this->changePasswordForm() + ]; + } + + if (Security::getCurrentUser()) { + // Logged in user requested a password change form. + $message = DBField::create_field( + 'HTMLFragment', + '

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

' + ); + + return [ + 'Content' => $message, + 'Form' => $this->changePasswordForm() + ]; + } + // Show a friendly message saying the login token has expired + if ($token !== null && $member && !$member->validateAutoLoginToken($token)) { + $message = [ + 'Content' => DBField::create_field( + 'HTMLFragment', + _t( + 'SilverStripe\\Security\\Security.NOTERESETLINKINVALID', + '

The password reset link is invalid or expired.

' + . '

You can request a new one here or change your password after' + . ' you logged in.

', + [ + 'link1' => $this->link('lostpassword'), + 'link2' => $this->link('login') + ] + ) + ) + ]; + + return [ + 'Content' => $message, + ]; + } + + // Someone attempted to go to changepassword without token or being logged in + return Security::permissionFailure( + Controller::curr(), + _t( + 'SilverStripe\\Security\\Security.ERRORPASSWORDPERMISSION', + 'You must be logged in in order to change your password!' + ) + ); + } + + + /** + * @param Member $member + * @param string $token + */ + protected function setSessionToken($member, $token) + { + // if there is a current member, they should be logged out + if ($curMember = Security::getCurrentUser()) { + /** @var LogoutHandler $handler */ + Injector::inst()->get(IdentityStore::class)->logOut(); + } + + // Store the hash for the change password form. Will be unset after reload within the ChangePasswordForm. + Session::set('AutoLoginHash', $member->encryptWithUserSettings($token)); + } + + /** + * Return a link to this request handler. + * The link returned is supplied in the constructor + * @param null $action + * @return string + */ + public function link($action = null) + { + if ($action) { + return Controller::join_links($this->link, $action); + } + + return $this->link; + } + + /** + * Factory method for the lost password form + * + * @skipUpgrade + * @return ChangePasswordForm Returns the lost password form + */ + public function changePasswordForm() + { + return ChangePasswordForm::create( + $this, + 'ChangePasswordForm' + ); + } + + /** + * Change the password + * + * @param array $data The user submitted data + * @param ChangePasswordForm $form + * @return HTTPResponse + */ + public function doChangePassword(array $data, $form) + { + $member = Security::getCurrentUser(); + // The user was logged in, check the current password + if ($member && ( + empty($data['OldPassword']) || + !$member->checkPassword($data['OldPassword'])->isValid() + ) + ) { + $form->sessionMessage( + _t( + 'SilverStripe\\Security\\Member.ERRORPASSWORDNOTMATCH', + 'Your current password does not match, please try again' + ), + 'bad' + ); + + // redirect back to the form, instead of using redirectBack() which could send the user elsewhere. + return $this->redirectBackToForm(); + } + + if (!$member) { + if (Session::get('AutoLoginHash')) { + $member = Member::member_from_autologinhash(Session::get('AutoLoginHash')); + } + + // The user is not logged in and no valid auto login hash is available + if (!$member) { + Session::clear('AutoLoginHash'); + + return $this->redirect($this->addBackURLParam(Security::singleton()->Link('login'))); + } + } + + // Check the new password + if (empty($data['NewPassword1'])) { + $form->sessionMessage( + _t( + 'SilverStripe\\Security\\Member.EMPTYNEWPASSWORD', + "The new password can't be empty, please try again" + ), + 'bad' + ); + + // redirect back to the form, instead of using redirectBack() which could send the user elsewhere. + return $this->redirectBackToForm(); + } + + // Fail if passwords do not match + if ($data['NewPassword1'] !== $data['NewPassword2']) { + $form->sessionMessage( + _t( + 'SilverStripe\\Security\\Member.ERRORNEWPASSWORD', + 'You have entered your new password differently, try again' + ), + 'bad' + ); + + // redirect back to the form, instead of using redirectBack() which could send the user elsewhere. + return $this->redirectBackToForm(); + } + + // Check if the new password is accepted + $validationResult = $member->changePassword($data['NewPassword1']); + if (!$validationResult->isValid()) { + $form->setSessionValidationResult($validationResult); + + return $this->redirectBackToForm(); + } + + // Clear locked out status + $member->LockedOutUntil = null; + $member->FailedLoginCount = null; + // Clear the members login hashes + $member->AutoLoginHash = null; + $member->AutoLoginExpired = DBDatetime::create()->now(); + $member->write(); + + if ($member->canLogIn()->isValid()) { + Injector::inst()->get(IdentityStore::class)->logIn($member, false, $this->getRequest()); + } + + // TODO Add confirmation message to login redirect + Session::clear('AutoLoginHash'); + + // Redirect to backurl + $backURL = $this->getBackURL(); + if ($backURL) { + return $this->redirect($backURL); + } + + // Redirect to default location - the login form saying "You are logged in as..." + $url = Security::singleton()->Link('login'); + + return $this->redirect($url); + } + + /** + * Something went wrong, go back to the changepassword + * + * @return HTTPResponse + */ + public function redirectBackToForm() + { + // Redirect back to form + $url = $this->addBackURLParam(Security::singleton()->Link('changepassword')); + + return $this->redirect($url); + } +} diff --git a/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php b/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php new file mode 100644 index 000000000..500b73e8d --- /dev/null +++ b/src/Security/MemberAuthenticator/CookieAuthenticationHandler.php @@ -0,0 +1,245 @@ +deviceCookieName; + } + + /** + * Set the name of the cookie used to track this device + * + * @param string $deviceCookieName + * @return $this + */ + public function setDeviceCookieName($deviceCookieName) + { + $this->deviceCookieName = $deviceCookieName; + return $this; + } + + /** + * Get the name of the cookie used to store an login token + * + * @return string + */ + public function getTokenCookieName() + { + return $this->tokenCookieName; + } + + /** + * Set the name of the cookie used to store an login token + * + * @param string $tokenCookieName + * @return $this + */ + public function setTokenCookieName($tokenCookieName) + { + $this->tokenCookieName = $tokenCookieName; + return $this; + } + + /** + * Once a member is found by authenticateRequest() pass it to this identity store + * + * @return IdentityStore + */ + public function getCascadeLogInTo() + { + return $this->cascadeInTo; + } + + /** + * Set the name of the cookie used to store an login token + * + * @param IdentityStore $cascadeInTo + * @return $this + */ + public function setCascadeLogInTo(IdentityStore $cascadeInTo) + { + $this->cascadeInTo = $cascadeInTo; + return $this; + } + + /** + * @param HTTPRequest $request + * @return Member + */ + public function authenticateRequest(HTTPRequest $request) + { + $uidAndToken = Cookie::get($this->getTokenCookieName()); + $deviceID = Cookie::get($this->getDeviceCookieName()); + + // @todo Consider better placement of database_is_ready test + if ($deviceID === null || strpos($uidAndToken, ':') === false || !Security::database_is_ready()) { + return null; + } + + list($uid, $token) = explode(':', $uidAndToken, 2); + + if (!$uid || !$token) { + return null; + } + + // check if autologin token matches + /** @var Member $member */ + $member = Member::get()->byID($uid); + if (!$member) { + return null; + } + + $hash = $member->encryptWithUserSettings($token); + + /** @var RememberLoginHash $rememberLoginHash */ + $rememberLoginHash = RememberLoginHash::get() + ->filter(array( + 'MemberID' => $member->ID, + 'DeviceID' => $deviceID, + 'Hash' => $hash + ))->first(); + if (!$rememberLoginHash) { + return null; + } + + // Check for expired token + $expiryDate = new \DateTime($rememberLoginHash->ExpiryDate); + $now = DBDatetime::now(); + $now = new \DateTime($now->Rfc2822()); + if ($now > $expiryDate) { + return null; + } + + if ($this->cascadeInTo) { + // @todo look at how to block "regular login" triggers from happening here + // @todo deal with the fact that the Session::current_session() isn't correct here :-/ + $this->cascadeInTo->logIn($member, false, $request); + } + + // @todo Consider whether response should be part of logIn() as well + + // Renew the token + $rememberLoginHash->renew(); + $tokenExpiryDays = RememberLoginHash::config()->uninherited('token_expiry_days'); + Cookie::set( + $this->getTokenCookieName(), + $member->ID . ':' . $rememberLoginHash->getToken(), + $tokenExpiryDays, + null, + null, + false, + true + ); + + // Audit logging hook + $member->extend('memberAutoLoggedIn'); + + return $member; + } + + /** + * @param Member $member + * @param bool $persistent + * @param HTTPRequest $request + */ + public function logIn(Member $member, $persistent = false, HTTPRequest $request = null) + { + // Cleans up any potential previous hash for this member on this device + if ($alcDevice = Cookie::get($this->getDeviceCookieName())) { + RememberLoginHash::get()->filter('DeviceID', $alcDevice)->removeAll(); + } + + // Set a cookie for persistent log-ins + if ($persistent) { + $rememberLoginHash = RememberLoginHash::generate($member); + $tokenExpiryDays = RememberLoginHash::config()->uninherited('token_expiry_days'); + $deviceExpiryDays = RememberLoginHash::config()->uninherited('device_expiry_days'); + Cookie::set( + $this->getTokenCookieName(), + $member->ID . ':' . $rememberLoginHash->getToken(), + $tokenExpiryDays, + null, + null, + null, + true + ); + Cookie::set( + $this->getDeviceCookieName(), + $rememberLoginHash->DeviceID, + $deviceExpiryDays, + null, + null, + null, + true + ); + } else { + // Clear a cookie for non-persistent log-ins + $this->clearCookies(); + } + } + + /** + * @param HTTPRequest $request + */ + public function logOut(HTTPRequest $request = null) + { + $member = Security::getCurrentUser(); + if ($member) { + RememberLoginHash::clear($member, Cookie::get('alc_device')); + } + $this->clearCookies(); + + if ($this->cascadeInTo) { + $this->cascadeInTo->logOut($request); + } + + Security::setCurrentUser(null); + } + + /** + * Clear the cookies set for the user + */ + protected function clearCookies() + { + Cookie::set($this->getTokenCookieName(), null); + Cookie::set($this->getDeviceCookieName(), null); + Cookie::force_expiry($this->getTokenCookieName()); + Cookie::force_expiry($this->getDeviceCookieName()); + } +} diff --git a/src/Security/MemberAuthenticator/LoginHandler.php b/src/Security/MemberAuthenticator/LoginHandler.php new file mode 100644 index 000000000..74e8b1425 --- /dev/null +++ b/src/Security/MemberAuthenticator/LoginHandler.php @@ -0,0 +1,257 @@ + 'login', + ]; + + /** + * @var array + * @config + */ + private static $allowed_actions = [ + 'login', + 'LoginForm', + 'logout', + ]; + + /** + * @var string Called link on this handler + */ + private $link; + + /** + * @param string $link The URL to recreate this request handler + * @param MemberAuthenticator $authenticator The authenticator to use + */ + public function __construct($link, MemberAuthenticator $authenticator) + { + $this->link = $link; + $this->authenticator = $authenticator; + parent::__construct(); + } + + /** + * Return a link to this request handler. + * The link returned is supplied in the constructor + * @param null|string $action + * @return string + */ + public function link($action = null) + { + if ($action) { + return Controller::join_links($this->link, $action); + } + + return $this->link; + } + + /** + * URL handler for the log-in screen + * + * @return array + */ + public function login() + { + return [ + 'Form' => $this->loginForm(), + ]; + } + + /** + * Return the MemberLoginForm form + * + * @return MemberLoginForm + */ + public function loginForm() + { + return MemberLoginForm::create( + $this, + get_class($this->authenticator), + 'LoginForm' + ); + } + + /** + * Login form handler method + * + * This method is called when the user finishes the login flow + * + * @param array $data Submitted data + * @param MemberLoginForm $form + * @return HTTPResponse + */ + public function doLogin($data, $form) + { + $failureMessage = null; + + $this->extend('beforeLogin'); + // Successful login + if ($member = $this->checkLogin($data, $result)) { + $this->performLogin($member, $data, $form->getRequestHandler()->getRequest()); + // Allow operations on the member after successful login + $this->extend('afterLogin', $member); + + return $this->redirectAfterSuccessfulLogin(); + } + + $this->extend('failedLogin'); + + $message = implode("; ", array_map( + function ($message) { + return $message['message']; + }, + $result->getMessages() + )); + + $form->sessionMessage($message, 'bad'); + + // Failed login + + /** @skipUpgrade */ + if (array_key_exists('Email', $data)) { + $rememberMe = (isset($data['Remember']) && Security::config()->get('autologin_enabled') === true); + Session::set('SessionForms.MemberLoginForm.Email', $data['Email']); + Session::set('SessionForms.MemberLoginForm.Remember', $rememberMe); + } + + // Fail to login redirects back to form + return $form->getRequestHandler()->redirectBackToForm(); + } + + public function getReturnReferer() + { + return $this->link(); + } + + /** + * Login in the user and figure out where to redirect the browser. + * + * The $data has this format + * array( + * 'AuthenticationMethod' => 'MemberAuthenticator', + * 'Email' => 'sam@silverstripe.com', + * 'Password' => '1nitialPassword', + * 'BackURL' => 'test/link', + * [Optional: 'Remember' => 1 ] + * ) + * + * @return HTTPResponse + */ + protected function redirectAfterSuccessfulLogin() + { + Session::clear('SessionForms.MemberLoginForm.Email'); + Session::clear('SessionForms.MemberLoginForm.Remember'); + + $member = Security::getCurrentUser(); + if ($member->isPasswordExpired()) { + return $this->redirectToChangePassword(); + } + + // Absolute redirection URLs may cause spoofing + $backURL = $this->getBackURL(); + if ($backURL) { + return $this->redirect($backURL); + } + + // If a default login dest has been set, redirect to that. + $defaultLoginDest = Security::config()->get('default_login_dest'); + if ($defaultLoginDest) { + return $this->redirect($defaultLoginDest); + } + + // Redirect the user to the page where they came from + if ($member) { + // Welcome message + $message = _t( + 'SilverStripe\\Security\\Member.WELCOMEBACK', + 'Welcome Back, {firstname}', + ['firstname' => $member->FirstName] + ); + Security::singleton()->setLoginMessage($message, ValidationResult::TYPE_GOOD); + } + + // Redirect back + return $this->redirectBack(); + } + + /** + * Try to authenticate the user + * + * @param array $data Submitted data + * @param ValidationResult $result + * @return Member Returns the member object on successful authentication + * or NULL on failure. + */ + public function checkLogin($data, &$result) + { + $member = $this->authenticator->authenticate($data, $result); + if ($member instanceof Member) { + return $member; + } + + return null; + } + + /** + * Try to authenticate the user + * + * @param Member $member + * @param array $data Submitted data + * @param HTTPRequest $request + * @return Member Returns the member object on successful authentication + * or NULL on failure. + */ + public function performLogin($member, $data, $request) + { + /** IdentityStore */ + $rememberMe = (isset($data['Remember']) && Security::config()->get('autologin_enabled')); + Injector::inst()->get(IdentityStore::class)->logIn($member, $rememberMe, $request); + + return $member; + } + + /** + * Invoked if password is expired and must be changed + * + * @skipUpgrade + * @return HTTPResponse + */ + protected function redirectToChangePassword() + { + $cp = ChangePasswordForm::create($this, 'ChangePasswordForm'); + $cp->sessionMessage( + _t('SilverStripe\\Security\\Member.PASSWORDEXPIRED', 'Your password has expired. Please choose a new one.'), + 'good' + ); + $changedPasswordLink = Security::singleton()->Link('changepassword'); + + return $this->redirect($this->addBackURLParam($changedPasswordLink)); + } +} diff --git a/src/Security/MemberAuthenticator/LogoutHandler.php b/src/Security/MemberAuthenticator/LogoutHandler.php new file mode 100644 index 000000000..21b2a4381 --- /dev/null +++ b/src/Security/MemberAuthenticator/LogoutHandler.php @@ -0,0 +1,64 @@ + 'logout' + ]; + + /** + * @var array + */ + private static $allowed_actions = [ + 'logout' + ]; + + + /** + * Log out form handler method + * + * This method is called when the user clicks on "logout" on the form + * created when the parameter $checkCurrentUser of the + * {@link __construct constructor} was set to TRUE and the user was + * currently logged in. + * + * @return bool|Member + */ + public function logout() + { + $member = Security::getCurrentUser(); + + return $this->doLogOut($member); + } + + /** + * + * @param Member $member + * @return bool|Member Return a member if something goes wrong + */ + public function doLogOut($member) + { + if ($member instanceof Member) { + Injector::inst()->get(IdentityStore::class)->logOut($this->getRequest()); + } + + return true; + } +} diff --git a/src/Security/MemberAuthenticator/LostPasswordForm.php b/src/Security/MemberAuthenticator/LostPasswordForm.php new file mode 100644 index 000000000..395f93b73 --- /dev/null +++ b/src/Security/MemberAuthenticator/LostPasswordForm.php @@ -0,0 +1,45 @@ + 'passwordsent', + '' => 'lostpassword', + ]; + + /** + * Since the logout and dologin actions may be conditionally removed, it's necessary to ensure these + * remain valid actions regardless of the member login state. + * + * @var array + * @config + */ + private static $allowed_actions = [ + 'lostpassword', + 'LostPasswordForm', + 'passwordsent', + ]; + + private $link = null; + + /** + * @param string $link The URL to recreate this request handler + */ + public function __construct($link) + { + $this->link = $link; + parent::__construct(); + } + + /** + * Return a link to this request handler. + * The link returned is supplied in the constructor + * + * @param string $action + * @return string + */ + public function link($action = null) + { + if ($action) { + return Controller::join_links($this->link, $action); + } + + return $this->link; + } + + /** + * URL handler for the initial lost-password screen + * + * @return array + */ + public function lostpassword() + { + + $message = _t( + 'SilverStripe\\Security\\Security.NOTERESETPASSWORD', + 'Enter your e-mail address and we will send you a link with which you can reset your password' + ); + + return [ + 'Content' => DBField::create_field('HTMLFragment', "

$message

"), + 'Form' => $this->lostPasswordForm(), + ]; + } + + /** + * Show the "password sent" page, after a user has requested + * to reset their password. + * + * @return array + */ + public function passwordsent() + { + $request = $this->getRequest(); + $email = Convert::raw2xml(rawurldecode($request->param('EmailAddress')) . '.' . $request->getExtension()); + + $message = _t( + 'SilverStripe\\Security\\Security.PASSWORDSENTTEXT', + "Thank you! A reset link has been sent to '{email}', provided an account exists for this email" + . " address.", + ['email' => Convert::raw2xml($email)] + ); + + return [ + 'Title' => _t( + 'SilverStripe\\Security\\Security.PASSWORDSENTHEADER', + "Password reset link sent to '{email}'", + array('email' => $email) + ), + 'Content' => DBField::create_field('HTMLFragment', "

$message

"), + 'Email' => $email + ]; + } + + + /** + * Factory method for the lost password form + * + * @skipUpgrade + * @return Form Returns the lost password form + */ + public function lostPasswordForm() + { + return LostPasswordForm::create( + $this, + $this->authenticatorClass, + 'lostPasswordForm', + null, + null, + false + ); + } + + /** + * Redirect to password recovery form + * + * @return HTTPResponse + */ + public function redirectToLostPassword() + { + $lostPasswordLink = Security::singleton()->Link('lostpassword'); + + return $this->redirect($this->addBackURLParam($lostPasswordLink)); + } + + /** + * Forgot password form handler method. + * Called when the user clicks on "I've lost my password". + * Extensions can use the 'forgotPassword' method to veto executing + * the logic, by returning FALSE. In this case, the user will be redirected back + * to the form without further action. It is recommended to set a message + * in the form detailing why the action was denied. + * + * @skipUpgrade + * @param array $data Submitted data + * @param LostPasswordForm $form + * @return HTTPResponse + */ + public function forgotPassword($data, $form) + { + // Ensure password is given + if (empty($data['Email'])) { + $form->sessionMessage( + _t( + 'SilverStripe\\Security\\Member.ENTEREMAIL', + 'Please enter an email address to get a password reset link.' + ), + 'bad' + ); + + return $this->redirectToLostPassword(); + } + + // Find existing member + $field = Member::config()->get('unique_identifier_field'); + /** @var Member $member */ + $member = Member::get()->filter([$field => $data['Email']])->first(); + + // Allow vetoing forgot password requests + $results = $this->extend('forgotPassword', $member); + if ($results && is_array($results) && in_array(false, $results, true)) { + return $this->redirectToLostPassword(); + } + + if ($member) { + $token = $member->generateAutologinTokenAndStoreHash(); + + $this->sendEmail($member, $token); + } + + // Avoid information disclosure by displaying the same status, + // regardless wether the email address actually exists + $link = Controller::join_links( + $this->link('passwordsent'), + rawurlencode($data['Email']), + '/' + ); + + return $this->redirect($this->addBackURLParam($link)); + } + + /** + * Send the email to the member that requested a reset link + * @param Member $member + * @param string $token + * @return bool + */ + protected function sendEmail($member, $token) + { + /** @var Email $email */ + $email = Email::create() + ->setHTMLTemplate('SilverStripe\\Control\\Email\\ForgotPasswordEmail') + ->setData($member) + ->setSubject(_t( + 'SilverStripe\\Security\\Member.SUBJECTPASSWORDRESET', + "Your password reset link", + 'Email subject' + )) + ->addData('PasswordResetLink', Security::getPasswordResetLink($member, $token)) + ->setTo($member->Email); + return $email->send(); + } +} diff --git a/src/Security/MemberAuthenticator/MemberAuthenticator.php b/src/Security/MemberAuthenticator/MemberAuthenticator.php new file mode 100644 index 000000000..d40f0b37c --- /dev/null +++ b/src/Security/MemberAuthenticator/MemberAuthenticator.php @@ -0,0 +1,199 @@ + + * @author Simon Erkelens + */ +class MemberAuthenticator implements Authenticator +{ + + public function supportedServices() + { + // 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; + } + + /** + * @param array $data + * @param null|ValidationResult $result + * @return null|Member + */ + public function authenticate($data, &$result = null) + { + // Find authenticated member + $member = $this->authenticateMember($data, $result); + + // Optionally record every login attempt as a {@link LoginAttempt} object + $this->recordLoginAttempt($data, $member, $result->isValid()); + + if ($member) { + Session::clear('BackURL'); + } + + return $result->isValid() ? $member : null; + } + + /** + * Attempt to find and authenticate member if possible from the given data + * + * @param array $data Form submitted data + * @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, &$result = null, $member = null) + { + // Default success to false + $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 = Security::check_default_admin($email, $data['Password']); + $result = $member->canLogIn(); + //protect against failed login + 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." + )); + } + } + + // Attempt to identify user by email + if (!$member && $email) { + // Find user by email + /** @var Member $member */ + $member = Member::get() + ->filter([Member::config()->get('unique_identifier_field') => $email]) + ->first(); + } + + // Validate against member if possible + if ($member && !$asDefaultAdmin) { + $result = $member->checkPassword($data['Password']); + } + + // Emit failure to member and form (if available) + if (!$result->isValid()) { + if ($member) { + $member->registerFailedLogin(); + } + } else { + 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; + } + } + + return $member; + } + + /** + * Log login attempt + * TODO We could handle this with an extension + * + * @param array $data + * @param Member $member + * @param boolean $success + */ + protected function recordLoginAttempt($data, $member, $success) + { + if (!Security::config()->get('login_recording')) { + return; + } + + // Check email is valid + /** @skipUpgrade */ + $email = isset($data['Email']) ? $data['Email'] : null; + if (is_array($email)) { + throw new InvalidArgumentException("Bad email passed to MemberAuthenticator::authenticate(): $email"); + } + + $attempt = LoginAttempt::create(); + if ($success && $member) { + // successful login (member is existing with matching password) + $attempt->MemberID = $member->ID; + $attempt->Status = 'Success'; + + // Audit logging hook + $member->extend('authenticated'); + } else { + // Failed login - we're trying to see if a user exists with this email (disregarding wrong passwords) + $attempt->Status = 'Failure'; + if ($member) { + // Audit logging hook + $attempt->MemberID = $member->ID; + $member->extend('authenticationFailed'); + } else { + // Audit logging hook + Member::singleton()->extend('authenticationFailedUnknownUser', $data); + } + } + + $attempt->Email = $email; + $attempt->IP = Controller::curr()->getRequest()->getIP(); + $attempt->write(); + } + + /** + * @param string $link + * @return LostPasswordHandler + */ + public function getLostPasswordHandler($link) + { + return LostPasswordHandler::create($link, $this); + } + + /** + * @param string $link + * @return ChangePasswordHandler + */ + public function getChangePasswordHandler($link) + { + return ChangePasswordHandler::create($link, $this); + } + + /** + * @param string $link + * @return LoginHandler + */ + public function getLoginHandler($link) + { + return LoginHandler::create($link, $this); + } + + /** + * @param string $link + * @return LogoutHandler + */ + public function getLogoutHandler($link) + { + return LogoutHandler::create($link, $this); + } +} diff --git a/src/Security/MemberLoginForm.php b/src/Security/MemberAuthenticator/MemberLoginForm.php similarity index 81% rename from src/Security/MemberLoginForm.php rename to src/Security/MemberAuthenticator/MemberLoginForm.php index f584f7cc4..ef61dfc4f 100644 --- a/src/Security/MemberLoginForm.php +++ b/src/Security/MemberAuthenticator/MemberLoginForm.php @@ -1,19 +1,23 @@ controller = $controller; $this->authenticator_class = $authenticatorClass; $customCSS = project() . '/css/member_login.css'; @@ -76,18 +86,17 @@ class MemberLoginForm extends LoginForm Requirements::css($customCSS); } - if ($controller->request->getVar('BackURL')) { - $backURL = $controller->request->getVar('BackURL'); - } else { - $backURL = Session::get('BackURL'); - } - - if ($checkCurrentUser && Member::currentUser() && 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( - 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) { @@ -98,15 +107,14 @@ class MemberLoginForm extends LoginForm } } - if (isset($backURL)) { - $fields->push(HiddenField::create('BackURL', 'BackURL', $backURL)); - } - // Reduce attack surface by enforcing POST requests $this->setFormMethod('POST', true); parent::__construct($controller, $name, $fields, $actions); + if (isset($logoutAction)) { + $this->setFormAction($logoutAction); + } $this->setValidator(RequiredFields::create(self::config()->get('required_fields'))); } @@ -117,6 +125,12 @@ class MemberLoginForm extends LoginForm */ 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), @@ -128,14 +142,14 @@ class MemberLoginForm extends LoginForm ); $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 $this->setAttribute('autocomplete', 'off'); $emailField->setAttribute('autocomplete', 'off'); } - if (Security::config()->autologin_enabled) { + if (Security::config()->get('autologin_enabled')) { $fields->push( CheckboxField::create( "Remember", @@ -150,6 +164,10 @@ class MemberLoginForm extends LoginForm ); } + if (isset($backURL)) { + $fields->push(HiddenField::create('BackURL', 'BackURL', $backURL)); + } + return $fields; } @@ -161,7 +179,7 @@ class MemberLoginForm extends LoginForm protected function getFormActions() { $actions = FieldList::create( - FormAction::create('dologin', _t('SilverStripe\\Security\\Member.BUTTONLOGIN', "Log in")), + FormAction::create('doLogin', _t('SilverStripe\\Security\\Member.BUTTONLOGIN', "Log in")), LiteralField::create( 'forgotPassword', '

' @@ -177,7 +195,7 @@ class MemberLoginForm extends LoginForm parent::restoreFormState(); $forceMessage = Session::get('MemberLoginForm.force_message'); - if (($member = Member::currentUser()) && !$forceMessage) { + if (($member = Security::getCurrentUser()) && !$forceMessage) { $message = _t( 'SilverStripe\\Security\\Member.LOGGEDINAS', "You're logged in as {name}.", @@ -194,14 +212,6 @@ class MemberLoginForm extends LoginForm return $this; } - /** - * @return MemberLoginHandler - */ - protected function buildRequestHandler() - { - return MemberLoginHandler::create($this); - } - /** * The name of this login form, to display in the frontend * Replaces Authenticator::get_name() diff --git a/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php b/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php new file mode 100644 index 000000000..e88c68489 --- /dev/null +++ b/src/Security/MemberAuthenticator/SessionAuthenticationHandler.php @@ -0,0 +1,107 @@ +sessionVariable; + } + + /** + * Set the session variable name used to track member ID + * + * @param string $sessionVariable + */ + public function setSessionVariable($sessionVariable) + { + $this->sessionVariable = $sessionVariable; + } + + /** + * @param HTTPRequest $request + * @return Member + */ + public function authenticateRequest(HTTPRequest $request) + { + // If ID is a bad ID it will be treated as if the user is not logged in, rather than throwing a + // ValidationException + $id = Session::get($this->getSessionVariable()); + if (!$id) { + return null; + } + /** @var Member $member */ + $member = Member::get()->byID($id); + return $member; + } + + /** + * @param Member $member + * @param bool $persistent + * @param HTTPRequest $request + */ + public function logIn(Member $member, $persistent = false, HTTPRequest $request = null) + { + static::regenerateSessionId(); + Session::set($this->getSessionVariable(), $member->ID); + + // This lets apache rules detect whether the user has logged in + // @todo make this a setting on the authentication handler + if (Member::config()->get('login_marker_cookie')) { + Cookie::set(Member::config()->get('login_marker_cookie'), 1, 0); + } + } + + /** + * Regenerate the session_id. + */ + protected static function regenerateSessionId() + { + if (!Member::config()->get('session_regenerate_id')) { + return; + } + + // This can be called via CLI during testing. + if (Director::is_cli()) { + return; + } + + $file = ''; + $line = ''; + + // @ is to supress win32 warnings/notices when session wasn't cleaned up properly + // There's nothing we can do about this, because it's an operating system function! + if (!headers_sent($file, $line)) { + @session_regenerate_id(true); + } + } + + /** + * @param HTTPRequest $request + */ + public function logOut(HTTPRequest $request = null) + { + Session::clear($this->getSessionVariable()); + } +} diff --git a/src/Security/MemberLoginHandler.php b/src/Security/MemberLoginHandler.php deleted file mode 100644 index 0a4d2e241..000000000 --- a/src/Security/MemberLoginHandler.php +++ /dev/null @@ -1,240 +0,0 @@ -performLogin($data)) { - return $this->logInUserAndRedirect($data); - } - - /** @skipUpgrade */ - if (array_key_exists('Email', $data)) { - Session::set('SessionForms.MemberLoginForm.Email', $data['Email']); - Session::set('SessionForms.MemberLoginForm.Remember', isset($data['Remember'])); - } - - // Fail to login redirects back to form - return $this->redirectBackToForm(); - } - - /** - * Redirect to password recovery form - * - * @return HTTPResponse - */ - public function redirectToLostPassword() - { - $lostPasswordLink = Security::singleton()->Link('lostpassword'); - return $this->redirect($this->addBackURLParam($lostPasswordLink)); - } - - public function getReturnReferer() - { - // Home of login form is always this url - return Security::singleton()->Link('login'); - } - - /** - * Login in the user and figure out where to redirect the browser. - * - * The $data has this format - * array( - * 'AuthenticationMethod' => 'MemberAuthenticator', - * 'Email' => 'sam@silverstripe.com', - * 'Password' => '1nitialPassword', - * 'BackURL' => 'test/link', - * [Optional: 'Remember' => 1 ] - * ) - * - * @param array $data - * @return HTTPResponse - */ - protected function logInUserAndRedirect($data) - { - Session::clear('SessionForms.MemberLoginForm.Email'); - Session::clear('SessionForms.MemberLoginForm.Remember'); - - $member = Member::currentUser(); - if ($member->isPasswordExpired()) { - return $this->redirectToChangePassword(); - } - - // Absolute redirection URLs may cause spoofing - $backURL = $this->getBackURL(); - if ($backURL) { - return $this->redirect($backURL); - } - - // If a default login dest has been set, redirect to that. - $defaultLoginDest = Security::config()->get('default_login_dest'); - if ($defaultLoginDest) { - return $this->redirect($defaultLoginDest); - } - - // Redirect the user to the page where they came from - if ($member) { - if (!empty($data['Remember'])) { - Session::set('SessionForms.MemberLoginForm.Remember', '1'); - $member->logIn(true); - } else { - $member->logIn(); - } - - // Welcome message - $message = _t( - 'SilverStripe\\Security\\Member.WELCOMEBACK', - "Welcome Back, {firstname}", - ['firstname' => $member->FirstName] - ); - Security::setLoginMessage($message, ValidationResult::TYPE_GOOD); - } - - // Redirect back - return $this->redirectBack(); - } - - /** - * Log out form handler method - * - * This method is called when the user clicks on "logout" on the form - * created when the parameter $checkCurrentUser of the - * {@link __construct constructor} was set to TRUE and the user was - * currently logged in. - * - * @return HTTPResponse - */ - public function logout() - { - return Security::singleton()->logout(); - } - - /** - * Try to authenticate the user - * - * @param array $data Submitted data - * @return Member Returns the member object on successful authentication - * or NULL on failure. - */ - public function performLogin($data) - { - $member = call_user_func_array( - [$this->authenticator_class, 'authenticate'], - [$data, $this->form] - ); - if ($member) { - $member->LogIn(isset($data['Remember'])); - return $member; - } - - // No member, can't login - $this->extend('authenticationFailed', $data); - return null; - } - - /** - * Forgot password form handler method. - * Called when the user clicks on "I've lost my password". - * Extensions can use the 'forgotPassword' method to veto executing - * the logic, by returning FALSE. In this case, the user will be redirected back - * to the form without further action. It is recommended to set a message - * in the form detailing why the action was denied. - * - * @skipUpgrade - * @param array $data Submitted data - * @return HTTPResponse - */ - public function forgotPassword($data) - { - // Ensure password is given - if (empty($data['Email'])) { - $this->form->sessionMessage( - _t('SilverStripe\\Security\\Member.ENTEREMAIL', 'Please enter an email address to get a password reset link.'), - 'bad' - ); - return $this->redirectToLostPassword(); - } - - // Find existing member - /** @var Member $member */ - $member = Member::get()->filter("Email", $data['Email'])->first(); - - // Allow vetoing forgot password requests - $results = $this->extend('forgotPassword', $member); - if ($results && is_array($results) && in_array(false, $results, true)) { - return $this->redirectToLostPassword(); - } - - if ($member) { - $token = $member->generateAutologinTokenAndStoreHash(); - - Email::create() - ->setHTMLTemplate('SilverStripe\\Control\\Email\\ForgotPasswordEmail') - ->setData($member) - ->setSubject(_t('SilverStripe\\Security\\Member.SUBJECTPASSWORDRESET', "Your password reset link", 'Email subject')) - ->addData('PasswordResetLink', Security::getPasswordResetLink($member, $token)) - ->setTo($member->Email) - ->send(); - } - - // Avoid information disclosure by displaying the same status, - // regardless wether the email address actually exists - $link = Controller::join_links( - Security::singleton()->Link('passwordsent'), - rawurlencode($data['Email']), - '/' - ); - return $this->redirect($this->addBackURLParam($link)); - } - - /** - * Invoked if password is expired and must be changed - * - * @skipUpgrade - * @return HTTPResponse - */ - protected function redirectToChangePassword() - { - $cp = ChangePasswordForm::create($this->form->getController(), 'ChangePasswordForm'); - $cp->sessionMessage( - _t('SilverStripe\\Security\\Member.PASSWORDEXPIRED', 'Your password has expired. Please choose a new one.'), - 'good' - ); - $changedPasswordLink = Security::singleton()->Link('changepassword'); - return $this->redirect($this->addBackURLParam($changedPasswordLink)); - } -} diff --git a/src/Security/Member_GroupSet.php b/src/Security/Member_GroupSet.php index 4b1161b92..582cd2816 100644 --- a/src/Security/Member_GroupSet.php +++ b/src/Security/Member_GroupSet.php @@ -109,7 +109,7 @@ class Member_GroupSet extends ManyManyList { $id = $this->getForeignID(); if ($id) { - return DataObject::get_by_id('SilverStripe\\Security\\Member', $id); + return DataObject::get_by_id(Member::class, $id); } } } diff --git a/src/Security/Member_Validator.php b/src/Security/Member_Validator.php index 1db3d5b27..aa8244573 100644 --- a/src/Security/Member_Validator.php +++ b/src/Security/Member_Validator.php @@ -2,8 +2,8 @@ namespace SilverStripe\Security; -use SilverStripe\Forms\RequiredFields; use SilverStripe\Forms\GridField\GridFieldDetailForm_ItemRequest; +use SilverStripe\Forms\RequiredFields; /** * Member Validator diff --git a/src/Security/PasswordEncryptor.php b/src/Security/PasswordEncryptor.php index 79b4999e0..dc2bffe51 100644 --- a/src/Security/PasswordEncryptor.php +++ b/src/Security/PasswordEncryptor.php @@ -2,8 +2,8 @@ namespace SilverStripe\Security; -use SilverStripe\Core\Config\Config; use ReflectionClass; +use SilverStripe\Core\Config\Config; /** * Allows pluggable password encryption. diff --git a/src/Security/Permission.php b/src/Security/Permission.php index 8d5a663d9..bb6d8d381 100644 --- a/src/Security/Permission.php +++ b/src/Security/Permission.php @@ -6,9 +6,9 @@ use SilverStripe\Core\ClassInfo; use SilverStripe\Core\Resettable; use SilverStripe\Dev\TestOnly; use SilverStripe\i18n\i18nEntityProvider; -use SilverStripe\ORM\DB; -use SilverStripe\ORM\DataObject; use SilverStripe\ORM\ArrayList; +use SilverStripe\ORM\DataObject; +use SilverStripe\ORM\DB; use SilverStripe\ORM\SS_List; use SilverStripe\View\TemplateGlobalProvider; @@ -131,10 +131,10 @@ class Permission extends DataObject implements TemplateGlobalProvider, Resettabl public static function check($code, $arg = "any", $member = null, $strict = true) { if (!$member) { - if (!Member::currentUserID()) { + if (!Security::getCurrentUser()) { return false; } - $member = Member::currentUserID(); + $member = Security::getCurrentUser(); } return self::checkMember($member, $code, $arg, $strict); @@ -171,10 +171,9 @@ class Permission extends DataObject implements TemplateGlobalProvider, Resettabl public static function checkMember($member, $code, $arg = "any", $strict = true) { if (!$member) { - $memberID = $member = Member::currentUserID(); - } else { - $memberID = (is_object($member)) ? $member->ID : $member; + $member = Security::getCurrentUser(); } + $memberID = ($member instanceof Member) ? $member->ID : $member; if (!$memberID) { return false; @@ -347,7 +346,7 @@ class Permission extends DataObject implements TemplateGlobalProvider, Resettabl { // Default to current member, with session-caching if (!$memberID) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); if ($member && isset($_SESSION['Permission_groupList'][$member->ID])) { return $_SESSION['Permission_groupList'][$member->ID]; } @@ -459,7 +458,7 @@ class Permission extends DataObject implements TemplateGlobalProvider, Resettabl /** * Returns all members for a specific permission. * - * @param $code String|array Either a single permission code, or a list of permission codes + * @param string|array $code Either a single permission code, or a list of permission codes * @return SS_List Returns a set of member that have the specified * permission. */ diff --git a/src/Security/PermissionCheckboxSetField.php b/src/Security/PermissionCheckboxSetField.php index 0303f34d7..e7d17f45a 100644 --- a/src/Security/PermissionCheckboxSetField.php +++ b/src/Security/PermissionCheckboxSetField.php @@ -2,14 +2,13 @@ namespace SilverStripe\Security; +use InvalidArgumentException; use SilverStripe\Core\Config\Config; use SilverStripe\Forms\FormField; -use SilverStripe\ORM\DataObject; -use SilverStripe\ORM\SS_List; use SilverStripe\ORM\ArrayList; +use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataObjectInterface; -use SilverStripe\View\Requirements; -use InvalidArgumentException; +use SilverStripe\ORM\SS_List; /** * Shows a categorized list of available permissions (through {@link Permission::get_codes()}). diff --git a/src/Security/RememberLoginHash.php b/src/Security/RememberLoginHash.php index e98deb71c..9fda0df9a 100644 --- a/src/Security/RememberLoginHash.php +++ b/src/Security/RememberLoginHash.php @@ -2,10 +2,10 @@ namespace SilverStripe\Security; -use SilverStripe\ORM\FieldType\DBDatetime; -use SilverStripe\ORM\DataObject; -use DateTime; use DateInterval; +use DateTime; +use SilverStripe\ORM\DataObject; +use SilverStripe\ORM\FieldType\DBDatetime; /** * Persists a token associated with a device for users who opted for the "Remember Me" @@ -16,7 +16,8 @@ use DateInterval; * is discarded as well. * * @property string $DeviceID - * @property string $RememberLoginHash + * @property string $ExpiryDate + * @property string $Hash * @method Member Member() */ class RememberLoginHash extends DataObject diff --git a/src/Security/RequestAuthenticationHandler.php b/src/Security/RequestAuthenticationHandler.php new file mode 100644 index 000000000..ab2f7af72 --- /dev/null +++ b/src/Security/RequestAuthenticationHandler.php @@ -0,0 +1,83 @@ +handlers; + } + + /** + * Set an associative array of handlers + * + * @param AuthenticationHandler[] $handlers + * @return $this + */ + public function setHandlers(array $handlers) + { + $this->handlers = $handlers; + return $this; + } + + public function authenticateRequest(HTTPRequest $request) + { + /** @var AuthenticationHandler $handler */ + foreach ($this->getHandlers() as $name => $handler) { + // in order to add cookies, etc + $member = $handler->authenticateRequest($request); + if ($member) { + Security::setCurrentUser($member); + return; + } + } + } + /** + * Log into the identity-store handlers attached to this request filter + * + * @param Member $member + * @param bool $persistent + * @param HTTPRequest $request + */ + public function logIn(Member $member, $persistent = false, HTTPRequest $request = null) + { + $member->beforeMemberLoggedIn(); + + foreach ($this->getHandlers() as $handler) { + $handler->logIn($member, $persistent, $request); + } + + Security::setCurrentUser($member); + $member->afterMemberLoggedIn(); + } + + /** + * Log out of all the identity-store handlers attached to this request filter + * + * @param HTTPRequest $request + */ + public function logOut(HTTPRequest $request = null) + { + foreach ($this->getHandlers() as $handler) { + $handler->logOut($request); + } + + Security::setCurrentUser(null); + } +} diff --git a/src/Security/Security.php b/src/Security/Security.php index 13b4fe9af..16ae137e8 100644 --- a/src/Security/Security.php +++ b/src/Security/Security.php @@ -2,33 +2,32 @@ namespace SilverStripe\Security; -use Page; use LogicException; -use SilverStripe\CMS\Controllers\ContentController; +use Page; +use SilverStripe\CMS\Controllers\ModelAsController; use SilverStripe\Control\Controller; use SilverStripe\Control\Director; use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPResponse; +use SilverStripe\Control\HTTPResponse_Exception; +use SilverStripe\Control\RequestHandler; use SilverStripe\Control\Session; use SilverStripe\Core\ClassInfo; -use SilverStripe\Core\Config\Config; use SilverStripe\Core\Convert; +use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\Deprecation; use SilverStripe\Dev\TestOnly; -use SilverStripe\Forms\EmailField; -use SilverStripe\Forms\FieldList; use SilverStripe\Forms\Form; -use SilverStripe\Forms\FormAction; use SilverStripe\ORM\ArrayList; -use SilverStripe\ORM\DB; +use SilverStripe\ORM\DataModel; use SilverStripe\ORM\DataObject; +use SilverStripe\ORM\DB; use SilverStripe\ORM\FieldType\DBField; +use SilverStripe\ORM\FieldType\DBHTMLText; use SilverStripe\ORM\ValidationResult; use SilverStripe\View\ArrayData; use SilverStripe\View\SSViewer; use SilverStripe\View\TemplateGlobalProvider; -use Exception; -use SilverStripe\View\ViewableData_Customised; use Subsite; /** @@ -46,13 +45,10 @@ class Security extends Controller implements TemplateGlobalProvider 'passwordsent', 'changepassword', 'ping', - 'LoginForm', - 'ChangePasswordForm', - 'LostPasswordForm', ); /** - * Default user name. Only used in dev-mode by {@link setDefaultAdmin()} + * Default user name. {@link setDefaultAdmin()} * * @var string * @see setDefaultAdmin() @@ -60,7 +56,7 @@ class Security extends Controller implements TemplateGlobalProvider protected static $default_username; /** - * Default password. Only used in dev-mode by {@link setDefaultAdmin()} + * Default password. {@link setDefaultAdmin()} * * @var string * @see setDefaultAdmin() @@ -74,7 +70,7 @@ class Security extends Controller implements TemplateGlobalProvider * @config * @var bool */ - protected static $strict_path_checking = false; + private static $strict_path_checking = false; /** * The password encryption algorithm to use by default. @@ -96,7 +92,7 @@ class Security extends Controller implements TemplateGlobalProvider /** * Determine if login username may be remembered between login sessions - * If set to false this will disable autocomplete and prevent username persisting in the session + * If set to false this will disable auto-complete and prevent username persisting in the session * * @config * @var bool @@ -118,7 +114,7 @@ class Security extends Controller implements TemplateGlobalProvider private static $template = 'BlankPage'; /** - * Template thats used to render the pages. + * Template that is used to render the pages. * * @var string * @config @@ -157,7 +153,7 @@ class Security extends Controller implements TemplateGlobalProvider * * @var string */ - private static $login_url = "Security/login"; + private static $login_url = 'Security/login'; /** * The default logout URL @@ -166,7 +162,7 @@ class Security extends Controller implements TemplateGlobalProvider * * @var string */ - private static $logout_url = "Security/logout"; + private static $logout_url = 'Security/logout'; /** * The default lost password URL @@ -175,7 +171,7 @@ class Security extends Controller implements TemplateGlobalProvider * * @var string */ - private static $lost_password_url = "Security/lostpassword"; + private static $lost_password_url = 'Security/lostpassword'; /** * Value of X-Frame-Options header @@ -206,7 +202,7 @@ class Security extends Controller implements TemplateGlobalProvider * @var boolean If set to TRUE or FALSE, {@link database_is_ready()} * will always return FALSE. Used for unit testing. */ - static $force_database_is_ready = null; + protected static $force_database_is_ready; /** * When the database has once been verified as ready, it will not do the @@ -214,7 +210,107 @@ class Security extends Controller implements TemplateGlobalProvider * * @var bool */ - static $database_is_ready = false; + protected static $database_is_ready = false; + + /** + * @var Authenticator[] available authenticators + */ + private $authenticators = []; + + /** + * @var Member Currently logged in user (if available) + */ + protected static $currentUser; + + /** + * @return Authenticator[] + */ + public function getAuthenticators() + { + return $this->authenticators; + } + + /** + * @param Authenticator[] $authenticators + */ + public function setAuthenticators(array $authenticators) + { + $this->authenticators = $authenticators; + } + + protected function init() + { + parent::init(); + + // Prevent clickjacking, see https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options + $frameOptions = static::config()->get('frame_options'); + if ($frameOptions) { + $this->getResponse()->addHeader('X-Frame-Options', $frameOptions); + } + + // Prevent search engines from indexing the login page + $robotsTag = static::config()->get('robots_tag'); + if ($robotsTag) { + $this->getResponse()->addHeader('X-Robots-Tag', $robotsTag); + } + } + + public function index() + { + return $this->httpError(404); // no-op + } + + /** + * Get the selected authenticator for this request + * + * @param string $name The identifier of the authenticator in your config + * @return Authenticator Class name of Authenticator + * @throws LogicException + */ + protected function getAuthenticator($name = 'default') + { + $authenticators = $this->authenticators; + + if (isset($authenticators[$name])) { + return $authenticators[$name]; + } + + throw new LogicException('No valid authenticator found'); + } + + /** + * Get all registered authenticators + * + * @param int $service The type of service that is requested + * @return Authenticator[] Return an array of Authenticator objects + */ + public function getApplicableAuthenticators($service = Authenticator::LOGIN) + { + $authenticators = $this->authenticators; + + /** @var Authenticator $authenticator */ + foreach ($authenticators as $name => $authenticator) { + if (!($authenticator->supportedServices() & $service)) { + unset($authenticators[$name]); + } + } + + return $authenticators; + } + + /** + * Check if a given authenticator is registered + * + * @param string $authenticator The configured identifier of the authenicator + * @return bool Returns TRUE if the authenticator is registered, FALSE + * otherwise. + */ + public function hasAuthenticator($authenticator) + { + $authenticators = $this->authenticators; + + return !empty($authenticators[$authenticator]); + } /** * Register that we've had a permission failure trying to view the given page @@ -252,14 +348,19 @@ class Security extends Controller implements TemplateGlobalProvider if (Director::is_ajax()) { $response = ($controller) ? $controller->getResponse() : new HTTPResponse(); $response->setStatusCode(403); - if (!Member::currentUser()) { - $response->setBody(_t('SilverStripe\\CMS\\Controllers\\ContentController.NOTLOGGEDIN', 'Not logged in')); - $response->setStatusDescription(_t('SilverStripe\\CMS\\Controllers\\ContentController.NOTLOGGEDIN', 'Not logged in')); - // Tell the CMS to allow re-aunthentication + if (!static::getCurrentUser()) { + $response->setBody( + _t('SilverStripe\\CMS\\Controllers\\ContentController.NOTLOGGEDIN', 'Not logged in') + ); + $response->setStatusDescription( + _t('SilverStripe\\CMS\\Controllers\\ContentController.NOTLOGGEDIN', 'Not logged in') + ); + // Tell the CMS to allow re-authentication if (CMSSecurity::enabled()) { $response->addHeader('X-Reauthenticate', '1'); } } + return $response; } @@ -269,15 +370,15 @@ class Security extends Controller implements TemplateGlobalProvider $messageSet = $configMessageSet; } else { $messageSet = array( - 'default' => _t( + 'default' => _t( 'SilverStripe\\Security\\Security.NOTEPAGESECURED', "That page is secured. Enter your credentials below and we will send " - . "you right along." + . "you right along." ), 'alreadyLoggedIn' => _t( 'SilverStripe\\Security\\Security.ALREADYLOGGEDIN', "You don't have access to this page. If you have another account that " - . "can access that page, you can log in again below.", + . "can access that page, you can log in again below.", "%s will be replaced with a link to log in." ) ); @@ -288,7 +389,7 @@ class Security extends Controller implements TemplateGlobalProvider $messageSet = array('default' => $messageSet); } - $member = Member::currentUser(); + $member = static::getCurrentUser(); // Work out the right message to show if ($member && $member->exists()) { @@ -303,12 +404,8 @@ class Security extends Controller implements TemplateGlobalProvider $message = $messageSet['default']; } - // Somewhat hackish way to render a login form with an error message. - $me = new Security(); - $form = $me->LoginForm(); - $form->sessionMessage($message, ValidationResult::TYPE_WARNING); - Session::set('MemberLoginForm.force_message', 1); - $loginResponse = $me->login(); + static::singleton()->setLoginMessage($message, ValidationResult::TYPE_WARNING); + $loginResponse = static::singleton()->login(); if ($loginResponse instanceof HTTPResponse) { return $loginResponse; } @@ -322,7 +419,7 @@ class Security extends Controller implements TemplateGlobalProvider $message = $messageSet['default']; } - static::setLoginMessage($message, ValidationResult::TYPE_WARNING); + static::singleton()->setLoginMessage($message, ValidationResult::TYPE_WARNING); Session::set("BackURL", $_SERVER['REQUEST_URI']); @@ -336,79 +433,43 @@ class Security extends Controller implements TemplateGlobalProvider )); } - protected function init() + /** + * @param null|Member $currentUser + */ + public static function setCurrentUser($currentUser = null) { - parent::init(); - - // Prevent clickjacking, see https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options - $frameOptions = $this->config()->get('frame_options'); - if ($frameOptions) { - $this->getResponse()->addHeader('X-Frame-Options', $frameOptions); - } - - // Prevent search engines from indexing the login page - $robotsTag = $this->config()->get('robots_tag'); - if ($robotsTag) { - $this->getResponse()->addHeader('X-Robots-Tag', $robotsTag); - } - } - - public function index() - { - return $this->httpError(404); // no-op + self::$currentUser = $currentUser; } /** - * Get the selected authenticator for this request - * - * @return string Class name of Authenticator - * @throws LogicException + * @return null|Member */ - protected function getAuthenticator() + public static function getCurrentUser() { - $authenticator = $this->getRequest()->requestVar('AuthenticationMethod'); - if ($authenticator && Authenticator::is_registered($authenticator)) { - return $authenticator; - } elseif ($authenticator !== '' && Authenticator::is_registered(Authenticator::get_default_authenticator())) { - return Authenticator::get_default_authenticator(); - } - - throw new LogicException('No valid authenticator found'); - } - - /** - * Get the login form to process according to the submitted data - * - * @return Form - * @throws Exception - */ - public function LoginForm() - { - $authenticator = $this->getAuthenticator(); - if ($authenticator) { - return $authenticator::get_login_form($this); - } - throw new Exception('Passed invalid authentication method'); + return self::$currentUser; } /** * Get the login forms for all available authentication methods * + * @deprecated 5.0.0 Now handled by {@link static::delegateToMultipleHandlers} + * * @return array Returns an array of available login forms (array of Form * objects). * - * @todo Check how to activate/deactivate authentication methods */ - public function GetLoginForms() + public function getLoginForms() { - $forms = array(); + Deprecation::notice('5.0.0', 'Now handled by delegateToMultipleHandlers'); - $authenticators = Authenticator::get_authenticators(); - foreach ($authenticators as $authenticator) { - $forms[] = $authenticator::get_login_form($this); - } - - return $forms; + return array_map( + function (Authenticator $authenticator) { + return [ + $authenticator->getLoginHandler($this->Link())->loginForm() + ]; + }, + $this->getApplicableAuthenticators() + ); } @@ -436,6 +497,12 @@ class Security extends Controller implements TemplateGlobalProvider /** * Log the currently logged in user out * + * Logging out without ID-parameter in the URL, will log the user out of all applicable Authenticators. + * + * Adding an ID will only log the user out of that Authentication method. + * + * Logging out of Default will always completely log out the user. + * * @param bool $redirect Redirect the user back to where they came. * - If it's false, the code calling logout() is * responsible for sending the user where-ever @@ -444,14 +511,34 @@ class Security extends Controller implements TemplateGlobalProvider */ public function logout($redirect = true) { - $member = Member::currentUser(); - if ($member) { - $member->logOut(); + $this->extend('beforeMemberLoggedOut'); + $member = static::getCurrentUser(); + + if ($member) { // If we don't have a member, there's not much to log out. + /** @var array|Authenticator[] $authenticators */ + $authenticators = $this->getApplicableAuthenticators(Authenticator::LOGOUT); + + /** @var Authenticator[] $authenticator */ + foreach ($authenticators as $name => $authenticator) { + $handler = $authenticator->getLogOutHandler(Controller::join_links($this->Link(), 'logout')); + $this->delegateToHandler($handler, $name); + } + // In the rare case, but plausible with e.g. an external IdentityStore, the user is not logged out. + if (static::getCurrentUser() !== null) { + $this->extend('failureMemberLoggedOut', $authenticator); + + return $this->redirectBack(); + } + $this->extend('successMemberLoggedOut', $authenticator); + // Member is successfully logged out. Write possible changes to the database. + $member->write(); } + $this->extend('afterMemberLoggedOut'); if ($redirect && (!$this->getResponse()->isFinished())) { return $this->redirectBack(); } + return null; } @@ -484,10 +571,10 @@ class Security extends Controller implements TemplateGlobalProvider // This step is necessary in cases such as automatic redirection where a user is authenticated // upon landing on an SSL secured site and is automatically logged in, or some other case // where the user has permissions to continue but is not given the option. - if ($this->getRequest()->requestVar('BackURL') - && !$this->getLoginMessage() - && ($member = Member::currentUser()) + if (!$this->getLoginMessage() + && ($member = static::getCurrentUser()) && $member->exists() + && $this->getRequest()->requestVar('BackURL') ) { return $this->redirectBack(); } @@ -511,25 +598,24 @@ class Security extends Controller implements TemplateGlobalProvider // Create new instance of page holder /** @var Page $holderPage */ - $holderPage = new $pageClass; + $holderPage = Injector::inst()->create($pageClass); $holderPage->Title = $title; /** @skipUpgrade */ $holderPage->URLSegment = 'Security'; // Disable ID-based caching of the log-in page by making it a random number - $holderPage->ID = -1 * rand(1, 10000000); + $holderPage->ID = -1 * random_int(1, 10000000); - $controllerClass = $holderPage->getControllerName(); - /** @var ContentController $controller */ - $controller = $controllerClass::create($holderPage); + $controller = ModelAsController::controller_for($holderPage); $controller->setDataModel($this->model); $controller->doInit(); + return $controller; } /** * Combine the given forms into a formset with a tabbed interface * - * @param array $forms List of LoginForm instances + * @param array|Form[] $forms * @return string */ protected function generateLoginFormSet($forms) @@ -537,6 +623,7 @@ class Security extends Controller implements TemplateGlobalProvider $viewData = new ArrayData(array( 'Forms' => new ArrayList($forms), )); + return $viewData->renderWith( $this->getTemplatesFor('MultiAuthenticatorLogin') ); @@ -561,6 +648,7 @@ class Security extends Controller implements TemplateGlobalProvider if ($messageCast !== ValidationResult::CAST_HTML) { $message = Convert::raw2xml($message); } + return sprintf('

%s

', Convert::raw2att($messageType), $message); } @@ -571,14 +659,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); } /** @@ -586,7 +674,7 @@ class Security extends Controller implements TemplateGlobalProvider */ public static function clearLoginMessage() { - Session::clear("Security.Message"); + Session::clear('Security.Message'); } @@ -596,31 +684,151 @@ 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 null|HTTPRequest $request + * @param int $service + * @return HTTPResponse|string Returns the "login" page as HTML code. + * @throws HTTPResponse_Exception */ - public function login() + public function login($request = null, $service = Authenticator::LOGIN) { // Check pre-login process if ($response = $this->preLogin()) { return $response; } + $authName = null; - // Get response handler - $controller = $this->getResponseController(_t('SilverStripe\\Security\\Security.LOGIN', 'Log in')); + 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); + } + + array_walk( + $handlers, + function (Authenticator &$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 array|RequestHandler[] $handlers + * @param string $title The title of the form + * @param array $templates + * @return array|HTTPResponse|RequestHandler|DBHTMLText|string + */ + 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 (RequestHandler $handler) { + return $handler->handleRequest($this->getRequest(), 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 RequestHandler $handler + * @param string $title The title of the form + * @param array $templates + * @return array|HTTPResponse|RequestHandler|DBHTMLText|string + */ + protected function delegateToHandler(RequestHandler $handler, $title, array $templates = []) + { + $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 + 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 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|DBHTMLText + */ + 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,32 +836,22 @@ 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 + ]; + $fragments = 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() { - $member = BasicAuth::requireLogin("SilverStripe login", 'ADMIN'); - $member->logIn(); + $member = BasicAuth::requireLogin($this->getRequest(), 'SilverStripe login', 'ADMIN'); + static::setCurrentUser($member); } /** @@ -663,113 +861,20 @@ 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; + $handlers = []; + $authenticators = $this->getApplicableAuthenticators(Authenticator::RESET_PASSWORD); + /** @var Authenticator $authenticator */ + foreach ($authenticators as $authenticator) { + $handlers[] = $authenticator->getLostPasswordHandler( + Controller::join_links($this->Link(), 'lostpassword') + ); } - $message = _t( - 'SilverStripe\\Security\\Security.NOTERESETPASSWORD', - 'Enter your e-mail address and we will send you a link with which you can reset your password' + return $this->delegateToMultipleHandlers( + $handlers, + _t('SilverStripe\\Security\\Security.LOSTPASSWORDHEADER', 'Lost Password'), + $this->getTemplatesFor('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 - ); - } - - - /** - * 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. - * - * 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"; } /** @@ -786,88 +891,36 @@ 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 = Member::currentUser()) { - $curMember->logOut(); - } - - // 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 (Member::currentUser()) { - // 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') + ); } /** - * Factory method for the lost password form + * Create a link to the password reset form. * - * @skipUpgrade - * @return ChangePasswordForm Returns the lost password 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 function ChangePasswordForm() + public static function getPasswordResetLink($member, $autologinToken) { - return ChangePasswordForm::create($this, 'ChangePasswordForm'); + $autologinToken = urldecode($autologinToken); + + return static::singleton()->Link('changepassword') . "?m={$member->ID}&t=$autologinToken"; } /** @@ -880,6 +933,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, [ @@ -906,12 +960,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; @@ -919,19 +968,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(); @@ -953,6 +996,8 @@ class Security extends Controller implements TemplateGlobalProvider ->add($member); } + static::singleton()->extend('afterFindAdministrator'); + return $member; } @@ -987,6 +1032,7 @@ class Security extends Controller implements TemplateGlobalProvider self::$default_username = $username; self::$default_password = $password; + return true; } @@ -1075,8 +1121,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 ); @@ -1137,6 +1183,25 @@ 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 + * + * @param bool $isReady + */ + public static function force_database_is_ready($isReady) + { + self::$force_database_is_ready = $isReady; + } + /** * @config * @var string Set the default login dest @@ -1151,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) { @@ -1208,9 +1273,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" ); } } 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(); } /** diff --git a/src/View/ViewableData.php b/src/View/ViewableData.php index 9d3209d57..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]; } 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/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..8c6c5abaa 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::setCurrentUser(null); } $content = new CSSContentParser($this->gridField->FieldHolder()); diff --git a/tests/php/Security/BasicAuthTest.php b/tests/php/Security/BasicAuthTest.php index 4d2f37c33..031fd4438 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'; @@ -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; } @@ -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/MemberAuthenticatorTest.php b/tests/php/Security/MemberAuthenticatorTest.php index c00733b0f..699ccac8c 100644 --- a/tests/php/Security/MemberAuthenticatorTest.php +++ b/tests/php/Security/MemberAuthenticatorTest.php @@ -2,20 +2,20 @@ namespace SilverStripe\Security\Tests; -use SilverStripe\ORM\DataObject; +use SilverStripe\Core\Injector\Injector; +use SilverStripe\ORM\DataModel; use SilverStripe\ORM\FieldType\DBDatetime; -use SilverStripe\ORM\ValidationResult; -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; -use SilverStripe\Security\MemberLoginForm; -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 { @@ -41,59 +41,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,75 +56,83 @@ class MemberAuthenticatorTest extends SapphireTest public function testGenerateLoginForm() { + $authenticator = new MemberAuthenticator(); + $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', '/'), DataModel::inst()); + + $this->assertTrue(is_array($frontendResponse)); + $this->assertTrue(isset($frontendResponse['Form'])); + $this->assertTrue($frontendResponse['Form'] instanceof MemberLoginForm); + } + + public function testGenerateCMSLoginForm() + { + /** @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); } + /** * Test that a member can be authenticated via their temp id */ public function testAuthenticateByTempID() { + $authenticator = new CMSMemberAuthenticator(); + $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); // If the user logs in then they have a temp id - $member->logIn(true); + Injector::inst()->get(IdentityStore::class)->logIn($member, true); $tempID = $member->TempIDHash; $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->assertTrue($message->isValid()); // Test incorrect login - $form->clearMessage(); - $result = MemberAuthenticator::authenticate( + $result = $authenticator->authenticate( array( 'tempid' => $tempID, 'Password' => 'notmypassword' ), - $form + $message ); - $form->restoreFormState(); + $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.'), - $form->getMessage() + $messages[0]['message'] ); - $this->assertEquals(ValidationResult::TYPE_ERROR, $form->getMessageType()); - $this->assertEquals(ValidationResult::CAST_TEXT, $form->getMessageCast()); } /** @@ -185,64 +140,53 @@ class MemberAuthenticatorTest extends SapphireTest */ public function testDefaultAdmin() { - // Make form - $controller = new Security(); - /** - * @skipUpgrade -*/ - $form = new Form($controller, 'Form', new FieldList(), new FieldList()); + $authenticator = new MemberAuthenticator(); // 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->assertTrue($message->isValid()); // Test incorrect login - $form->clearMessage(); - $result = MemberAuthenticator::authenticate( + $result = $authenticator->authenticate( array( 'Email' => 'admin', 'Password' => 'notmypassword' ), - $form + $message ); - $form->restoreFormState(); + $messages = $message->getMessages(); $this->assertEmpty($result); $this->assertEquals( 'The provided details don\'t seem to be correct. Please try again.', - $form->getMessage() + $messages[0]['message'] ); - $this->assertEquals(ValidationResult::TYPE_ERROR, $form->getMessageType()); - $this->assertEquals(ValidationResult::CAST_TEXT, $form->getMessageCast()); } public function testDefaultAdminLockOut() { + $authenticator = new MemberAuthenticator(); + 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 + ] ); - $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 e03f77b6a..4420a814c 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; @@ -10,15 +11,17 @@ use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DB; use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\Security\Member; -use SilverStripe\Security\MemberAuthenticator; +use SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler; 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 { @@ -237,13 +240,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')) ); @@ -534,26 +537,24 @@ 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->logOut(); } public function testAuthorisedMembersCanManipulateOthersRecords() @@ -562,10 +563,12 @@ 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->logOut(); } public function testExtendedCan() @@ -664,12 +667,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 +722,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 +875,8 @@ class MemberTest extends FunctionalTest { $m1 = $this->objFromFixture(Member::class, 'grouplessmember'); - $m1->login(true); + Injector::inst()->get(IdentityStore::class)->logIn($m1, true); + $hashes = RememberLoginHash::get()->filter('MemberID', $m1->ID); $this->assertEquals($hashes->count(), 1); $firstHash = $hashes->first(); @@ -887,7 +891,8 @@ class MemberTest extends FunctionalTest */ $m1 = $this->objFromFixture(Member::class, 'noexpiry'); - $m1->logIn(true); + Injector::inst()->get(IdentityStore::class)->logIn($m1, true); + $firstHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->first(); $this->assertNotNull($firstHash); @@ -914,7 +919,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 +927,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 ) ); @@ -942,12 +947,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(), @@ -966,7 +970,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); $firstHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->first(); $this->assertNotNull($firstHash); @@ -996,7 +1000,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); @@ -1016,7 +1020,7 @@ class MemberTest extends FunctionalTest ) ); $this->assertNotContains($message, $response->getBody()); - $this->session()->inst_set('loggedInAs', null); + $this->logOut(); DBDatetime::clear_mock_now(); } @@ -1025,10 +1029,10 @@ class MemberTest extends FunctionalTest $m1 = $this->objFromFixture(Member::class, 'noexpiry'); // First device - $m1->login(true); + Injector::inst()->get(IdentityStore::class)->logIn($m1, true); Cookie::set('alc_device', null); // Second device - $m1->login(true); + Injector::inst()->get(IdentityStore::class)->logIn($m1, true); // Hash of first device $firstHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->first(); @@ -1069,7 +1073,11 @@ class MemberTest extends FunctionalTest ); $this->assertContains($message, $response->getBody()); - $this->session()->inst_set('loggedInAs', null); + // 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( @@ -1101,7 +1109,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); $response = $this->get('Security/logout', $this->session()); $this->assertEquals( RememberLoginHash::get()->filter('MemberID', $m1->ID)->count(), @@ -1156,8 +1164,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" ); } @@ -1362,12 +1370,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); } @@ -1376,7 +1384,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'); @@ -1415,21 +1423,21 @@ class MemberTest extends FunctionalTest */ public function testActAsUser() { - $this->assertNull(Member::currentUser()); + $this->assertNull(Security::getCurrentUser()); /** @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 a4d941f4c..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; @@ -74,8 +68,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; @@ -182,19 +176,19 @@ 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()); - $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'); @@ -228,7 +222,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')); @@ -245,8 +239,7 @@ class SecurityTest extends FunctionalTest 'MemberLoginForm_LoginForm', null, array( - 'AuthenticationMethod' => MemberAuthenticator::class, - 'action_dologout' => 1, + 'action_logout' => 1, ) ); @@ -255,7 +248,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() @@ -379,6 +372,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()); @@ -416,6 +411,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( @@ -436,7 +432,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'); @@ -461,6 +457,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')); @@ -479,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' @@ -502,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( @@ -533,7 +528,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++) { @@ -594,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'); @@ -648,9 +643,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 +659,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() @@ -703,7 +694,7 @@ class SecurityTest extends FunctionalTest 'Email' => $email, 'Password' => $password, 'AuthenticationMethod' => MemberAuthenticator::class, - 'action_dologin' => 1, + 'action_doLogin' => 1, ) ); } 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' );