silverstripe-framework/security/Security.php

860 lines
26 KiB
PHP
Raw Normal View History

<?php
/**
* Implements a basic security model
* @package sapphire
* @subpackage security
*/
class Security extends Controller {
/**
* Default user name. Only used in dev-mode by {@link setDefaultAdmin()}
*
* @var string
* @see setDefaultAdmin()
*/
protected static $default_username;
/**
* Default password. Only used in dev-mode by {@link setDefaultAdmin()}
*
* @var string
* @see setDefaultAdmin()
*/
protected static $default_password;
/**
* If set to TRUE to prevent sharing of the session across several sites
* in the domain.
*
* @var bool
*/
protected static $strictPathChecking = false;
/**
* Should passwords be stored encrypted?
* @deprecated 2.4 Please use 'none' as the default $encryptionAlgorithm instead
*
* @var bool
*/
protected static $encryptPasswords = true;
/**
* The password encryption algorithm to use by default.
* This is an arbitrary code registered through {@link PasswordEncryptor}.
*
* @var string
*/
protected static $encryptionAlgorithm = 'sha1_v2.4';
/**
* Should a salt be used for the password encryption?
* @deprecated 2.4 Please use a custom {@link PasswordEncryptor} instead
*
* @var bool
*/
protected static $useSalt = true;
/**
* Showing "Remember me"-checkbox
* on loginform, and saving encrypted credentials to a cookie.
*
* @var bool
*/
public static $autologin_enabled = true;
/**
* Location of word list to use for generating passwords
*
* @var string
*/
protected static $wordlist = './wordlist.txt';
/**
* Template thats used to render the pages.
*
* @var string
*/
public static $template_main = 'Page';
/**
* Default message set used in permission failures.
*
* @var array|string
*/
protected static $default_message_set = '';
/**
* Get location of word list file
*/
static function get_word_list() {
return Security::$wordlist;
}
/**
* Enable or disable recording of login attempts
* through the {@link LoginRecord} object.
*
* @var boolean $login_recording
*/
protected static $login_recording = false;
/**
* Set location of word list file
*
* @param string $wordListFile Location of word list file
*/
static function set_word_list($wordListFile) {
Security::$wordlist = $wordListFile;
}
/**
* Set the default message set used in permissions failures.
*
* @param string|array $messageSet
*/
static function set_default_message_set($messageSet) {
self::$default_message_set = $messageSet;
}
/**
* Register that we've had a permission failure trying to view the given page
*
* This will redirect to a login page.
* If you don't provide a messageSet, a default will be used.
*
* @param Controller $controller The controller that you were on to cause the permission
* failure.
* @param string|array $messageSet The message to show to the user. This
* can be a string, or a map of different
* messages for different contexts.
* If you pass an array, you can use the
* following keys:
* - default: The default message
* - logInAgain: The message to show
* if the user has just
* logged out and the
* - alreadyLoggedIn: The message to
* show if the user
* is already logged
* in and lacks the
* permission to
* access the item.
*
* The alreadyLoggedIn value can contain a '%s' placeholder that will be replaced with a link
* to log in.
*/
static function permissionFailure($controller = null, $messageSet = null) {
if(!$controller) $controller = Controller::curr();
if(Director::is_ajax()) {
$response = ($controller) ? $controller->getResponse() : new SS_HTTPResponse();
$response->setStatusCode(403);
$response->setBody('NOTLOGGEDIN:');
return $response;
} else {
// Prepare the messageSet provided
if(!$messageSet) {
if(self::$default_message_set) {
$messageSet = self::$default_message_set;
} else {
$messageSet = array(
'default' => _t(
'Security.NOTEPAGESECURED',
"That page is secured. Enter your credentials below and we will send "
. "you right along."
),
'alreadyLoggedIn' => _t(
'Security.ALREADYLOGGEDIN',
"You don't have access to this page. If you have another account that "
. "can access that page, you can <a href=\"%s\">log in again</a>.",
PR_MEDIUM,
"%s will be replaced with a link to log in."
),
'logInAgain' => _t(
'Security.LOGGEDOUT',
"You have been logged out. If you would like to log in again, enter "
. "your credentials below."
)
);
}
}
if(!is_array($messageSet)) {
$messageSet = array('default' => $messageSet);
}
// Work out the right message to show
if(Member::currentUser()) {
$response = ($controller) ? $controller->getResponse() : new SS_HTTPResponse();
$response->setStatusCode(403);
// Replace %s with the log in link
$body = sprintf($messageSet['alreadyLoggedIn'],
Controller::join_links(Director::baseURL(), 'Security/login',
'?BackURL=' . urlencode($_SERVER['REQUEST_URI'])));
$response->setBody($body);
return $response;
} else if(substr(Director::history(),0,15) == 'Security/logout') {
$message = $messageSet['logInAgain'] ? $messageSet['logInAgain'] : $messageSet['default'];
} else {
$message = $messageSet['default'];
}
Session::set("Security.Message.message", $message);
Session::set("Security.Message.type", 'warning');
Session::set("BackURL", $_SERVER['REQUEST_URI']);
// TODO AccessLogEntry needs an extension to handle permission denied errors
// Audit logging hook
if($controller) $controller->extend('permissionDenied', $member);
Director::redirect("Security/login?BackURL=" . urlencode($_SERVER['REQUEST_URI']));
}
return;
}
/**
* Get the login form to process according to the submitted data
*/
protected function LoginForm() {
if(isset($this->requestParams['AuthenticationMethod'])) {
$authenticator = trim($_REQUEST['AuthenticationMethod']);
$authenticators = Authenticator::get_authenticators();
if(in_array($authenticator, $authenticators)) {
return call_user_func(array($authenticator, 'get_login_form'), $this);
}
}
user_error('Passed invalid authentication method', E_USER_ERROR);
}
/**
* Get the login forms for all available authentication methods
*
* @return array Returns an array of available login forms (array of Form
* objects).
*
* @todo Check how to activate/deactivate authentication methods
*/
protected function GetLoginForms()
{
$forms = array();
$authenticators = Authenticator::get_authenticators();
foreach($authenticators as $authenticator) {
array_push($forms,
call_user_func(array($authenticator, 'get_login_form'),
$this));
}
return $forms;
}
/**
* Get a link to a security action
*
* @param string $action Name of the action
* @return string Returns the link to the given action
*/
public static function Link($action = null) {
return "Security/$action";
}
/**
* Log the currently logged in user out
*
* @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
* they should go.
*/
public function logout($redirect = true) {
if($member = Member::currentUser())
$member->logOut();
if($redirect)
Director::redirectBack();
}
/**
* Show the "login" page
*
* @return string Returns the "login" page as HTML code.
*/
public function login() {
// Event handler for pre-login, with an option to let it break you out of the login form
$eventResults = $this->extend('onBeforeSecurityLogin');
// If there was a redirection, return
if(Director::redirected_to()) return;
// If there was an SS_HTTPResponse object returned, then return that
else if($eventResults) {
foreach($eventResults as $result) {
if($result instanceof SS_HTTPResponse) return $result;
}
}
$customCSS = project() . '/css/tabs.css';
if(Director::fileExists($customCSS)) {
Requirements::css($customCSS);
}
$tmpPage = new Page();
$tmpPage->Title = _t('Security.LOGIN', 'Log in');
$tmpPage->URLSegment = "Security";
$tmpPage->ID = -1; // Set the page ID to -1 so we dont get the top level pages as its children
$controller = new Page_Controller($tmpPage);
$controller->init();
//Controller::$currentController = $controller;
$content = '';
$forms = $this->GetLoginForms();
if(!count($forms)) {
user_error('No login-forms found, please use Authenticator::register_authenticator() to add one', E_USER_ERROR);
}
// 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) {
Requirements::javascript(THIRDPARTY_DIR . "/loader.js");
Requirements::javascript(THIRDPARTY_DIR . "/prototype.js");
Requirements::javascript(THIRDPARTY_DIR . "/behaviour.js");
Requirements::javascript(THIRDPARTY_DIR . "/prototype_improvements.js");
Requirements::javascript(THIRDPARTY_DIR . "/scriptaculous/effects.js");
Requirements::css(SAPPHIRE_DIR . "/css/Form.css");
// Needed because the <base href=".."> in the template makes problems
// with the tabstrip library otherwise
$link_base = Director::absoluteURL($this->Link("login"));
Requirements::javascript(THIRDPARTY_DIR . "/jquery/jquery.js");
Requirements::javascript(THIRDPARTY_DIR . "/jquery/jquery_improvements.js");
Requirements::javascript(THIRDPARTY_DIR . '/jquery/plugins/livequery/jquery.livequery.js');
Requirements::javascript(THIRDPARTY_DIR . "/tabstrip/tabstrip.js");
Requirements::css(THIRDPARTY_DIR . "/tabstrip/tabstrip.css");
$content = '<div id="Form_EditForm">';
$content .= '<ul class="tabstrip">';
$content_forms = '';
foreach($forms as $form) {
$content .= "<li><a href=\"$link_base#{$form->FormName()}_tab\">{$form->getAuthenticator()->get_name()}</a></li>\n";
$content_forms .= '<div class="tab" id="' . $form->FormName() . '_tab">' . $form->forTemplate() . "</div>\n";
}
$content .= "</ul>\n" . $content_forms . "\n</div>\n";
} else {
$content .= $forms[0]->forTemplate();
}
if(strlen($message = Session::get('Security.Message.message')) > 0) {
$message_type = Session::get('Security.Message.type');
if($message_type == 'bad') {
$message = "<p class=\"message $message_type\">$message</p>";
} else {
$message = "<p>$message</p>";
}
$customisedController = $controller->customise(array(
"Content" => $message,
"Form" => $content,
));
} else {
$customisedController = $controller->customise(array(
"Form" => $content,
));
}
Session::clear('Security.Message');
// custom processing
return $customisedController->renderWith(array('Security_login', 'Security', $this->stat('template_main')));
}
function basicauthlogin() {
$member = BasicAuth::requireLogin("SilverStripe login", 'ADMIN');
$member->LogIn();
}
/**
* Show the "lost password" page
*
* @return string Returns the "lost password" page as HTML code.
*/
public function lostpassword() {
Requirements::javascript(THIRDPARTY_DIR . '/prototype.js');
Requirements::javascript(THIRDPARTY_DIR . '/behaviour.js');
Requirements::javascript(THIRDPARTY_DIR . '/loader.js');
Requirements::javascript(THIRDPARTY_DIR . '/prototype_improvements.js');
Requirements::javascript(THIRDPARTY_DIR . '/scriptaculous/effects.js');
$tmpPage = new Page();
$tmpPage->Title = _t('Security.LOSTPASSWORDHEADER', 'Lost Password');
$tmpPage->URLSegment = 'Security';
$controller = new Page_Controller($tmpPage);
$controller->init();
$customisedController = $controller->customise(array(
'Content' =>
'<p>' .
_t(
'Security.NOTERESETPASSWORD',
'Enter your e-mail address and we will send you a link with which you can reset your password'
) .
'</p>',
'Form' => $this->LostPasswordForm(),
));
//Controller::$currentController = $controller;
return $customisedController->renderWith(array('Security_lostpassword', 'Security', $this->stat('template_main')));
}
/**
* Factory method for the lost password form
*
* @return Form Returns the lost password form
*/
public function LostPasswordForm() {
return Object::create('MemberLoginForm',
$this,
'LostPasswordForm',
new FieldSet(
new EmailField('Email', _t('Member.EMAIL', 'Email'))
),
new FieldSet(
new FormAction(
'forgotPassword',
_t('Security.BUTTONSEND', 'Send me the password reset link')
)
),
false
);
}
/**
* Show the "password sent" page, after a user has requested
* to reset their password.
*
* @param SS_HTTPRequest $request The SS_HTTPRequest for this action.
* @return string Returns the "password sent" page as HTML code.
*/
public function passwordsent($request) {
Requirements::javascript(THIRDPARTY_DIR . '/behaviour.js');
Requirements::javascript(THIRDPARTY_DIR . '/loader.js');
Requirements::javascript(THIRDPARTY_DIR . '/prototype.js');
Requirements::javascript(THIRDPARTY_DIR . '/prototype_improvements.js');
Requirements::javascript(THIRDPARTY_DIR . '/scriptaculous/effects.js');
$tmpPage = new Page();
$tmpPage->Title = _t('Security.LOSTPASSWORDHEADER');
$tmpPage->URLSegment = 'Security';
$tmpPage->ID = -1; // Set the page ID to -1 so we dont get the top level pages as its children
$controller = new Page_Controller($tmpPage);
$controller->init();
$email = Convert::raw2xml($request->param('ID') . '.' . $request->getExtension());
$customisedController = $controller->customise(array(
'Title' => sprintf(_t('Security.PASSWORDSENTHEADER', "Password reset link sent to '%s'"), $email),
'Content' =>
"<p>" .
sprintf(_t('Security.PASSWORDSENTTEXT', "Thank you! A reset link has been sent to '%s', provided an account exists for this email address."), $email) .
"</p>",
));
//Controller::$currentController = $controller;
return $customisedController->renderWith(array('Security_passwordsent', 'Security', $this->stat('template_main')));
}
/**
* Create a link to the password reset form
*
* @param string $autoLoginHash The auto login hash
*/
public static function getPasswordResetLink($autoLoginHash) {
$autoLoginHash = urldecode($autoLoginHash);
return self::Link('changepassword') . "?h=$autoLoginHash";
}
/**
* Show the "change password" page
*
* @return string Returns the "change password" page as HTML code.
*/
public function changepassword() {
$tmpPage = new Page();
$tmpPage->Title = _t('Security.CHANGEPASSWORDHEADER', 'Change your password');
$tmpPage->URLSegment = 'Security';
$tmpPage->ID = -1; // Set the page ID to -1 so we dont get the top level pages as its children
$controller = new Page_Controller($tmpPage);
$controller->init();
if(isset($_REQUEST['h']) && Member::member_from_autologinhash($_REQUEST['h'])) {
// The auto login hash is valid, store it for the change password form
Session::set('AutoLoginHash', $_REQUEST['h']);
$customisedController = $controller->customise(array(
'Content' =>
'<p>' .
_t('Security.ENTERNEWPASSWORD', 'Please enter a new password.') .
'</p>',
'Form' => $this->ChangePasswordForm(),
));
} elseif(Member::currentUser()) {
// let a logged in user change his password
$customisedController = $controller->customise(array(
'Content' => '<p>' . _t('Security.CHANGEPASSWORDBELOW', 'You can change your password below.') . '</p>',
'Form' => $this->ChangePasswordForm()));
} else {
// show an error message if the auto login hash is invalid and the
// user is not logged in
if(isset($_REQUEST['h'])) {
$customisedController = $controller->customise(
array('Content' =>
sprintf(
_t('Security.NOTERESETLINKINVALID',
'<p>The password reset link is invalid or expired.</p><p>You can request a new one <a href="%s">here</a> or change your password after you <a href="%s">logged in</a>.</p>'
),
$this->Link('lostpassword'),
$this->link('login')
)
)
);
} else {
self::permissionFailure(
$this,
_t('Security.ERRORPASSWORDPERMISSION', 'You must be logged in in order to change your password!')
);
return;
}
}
//Controller::$currentController = $controller;
return $customisedController->renderWith(array('Security_changepassword', 'Security', $this->stat('template_main')));
}
/**
* Security/ping can be visited with ajax to keep a session alive.
* This is used in the CMS.
*/
function ping() {
return 1;
}
/**
* Factory method for the lost password form
*
* @return Form Returns the lost password form
*/
public function ChangePasswordForm() {
return new ChangePasswordForm($this, 'ChangePasswordForm');
}
/**
* Authenticate using the given email and password, returning the
* appropriate member object if
*
* @return bool|Member Returns FALSE if authentication fails, otherwise
* the member object
* @see setDefaultAdmin()
*/
public static function authenticate($RAW_email, $RAW_password) {
$SQL_email = Convert::raw2sql($RAW_email);
$SQL_password = Convert::raw2sql($RAW_password);
// Default login (see {@setDetaultAdmin()})
if(($RAW_email == self::$default_username) && ($RAW_password == self::$default_password)
&& !empty(self::$default_username) && !empty(self::$default_password)) {
$member = self::findAnAdministrator();
} else {
$member = DataObject::get_one("Member", "\"Email\" = '$SQL_email' AND \"Password\" IS NOT NULL");
if($member && ($member->checkPassword($RAW_password) == false)) {
$member = null;
}
}
return $member;
}
/**
* Return a member with administrator privileges
*
* @return Member Returns a member object that has administrator
* privileges.
*/
static function findAnAdministrator($username = 'admin', $password = 'password') {
$permission = DataObject::get_one("Permission", "\"Code\" = 'ADMIN'", true, "\"Permission\".\"ID\"");
$adminGroup = null;
if($permission) $adminGroup = DataObject::get_one("Group", "\"Group\".\"ID\" = '{$permission->GroupID}'", true, "\"Group\".\"ID\"");
if($adminGroup) {
if($adminGroup->Members()->First()) {
$member = $adminGroup->Members()->First();
}
}
if(!$adminGroup) {
$adminGroup = Object::create('Group');
$adminGroup->Title = 'Administrators';
$adminGroup->Code = "administrators";
$adminGroup->write();
Permission::grant($adminGroup->ID, "ADMIN");
}
if(!isset($member)) {
$member = Object::create('Member');
$member->FirstName = $member->Surname = 'Admin';
$member->Email = $username;
$member->Password = $password;
$member->write();
$member->Groups()->add($adminGroup);
}
return $member;
}
/**
* Set a default admin in dev-mode
*
* This will set a static default-admin (e.g. "td") which is not existing
* as a database-record. By this workaround we can test pages in dev-mode
* with a unified login. Submitted login-credentials are first checked
* against this static information in {@authenticate()}.
*
* @param string $username The user name
* @param string $password The password in cleartext
*/
public static function setDefaultAdmin($username, $password) {
// don't overwrite if already set
if(self::$default_username || self::$default_password) {
return false;
}
self::$default_username = $username;
self::$default_password = $password;
}
/**
* Checks if the passed credentials are matching the default-admin.
* Compares cleartext-password set through Security::setDefaultAdmin().
*
* @param string $username
* @param string $password
* @return bool
*/
public static function check_default_admin($username, $password) {
return (
self::$default_username === $username
&& self::$default_password === $password
&& self::has_default_admin()
);
}
/**
* Check that the default admin account has been set.
*/
public static function has_default_admin() {
return !empty(self::$default_username) && !empty(self::$default_password);
}
/**
* Set strict path checking
*
* This prevents sharing of the session across several sites in the
* domain.
*
* @param boolean $strictPathChecking To enable or disable strict patch
* checking.
*/
public static function setStrictPathChecking($strictPathChecking) {
self::$strictPathChecking = $strictPathChecking;
}
/**
* Get strict path checking
*
* @return boolean Status of strict path checking
*/
public static function getStrictPathChecking() {
return self::$strictPathChecking;
}
/**
* Set if passwords should be encrypted or not
*
* @deprecated 2.4 Use PasswordEncryptor_None instead.
*
* @param bool $encrypt Set to TRUE if you want that all (new) passwords
* will be stored encrypted, FALSE if you want to
* store the passwords in clear text.
*/
public static function encrypt_passwords($encrypt) {
self::$encryptPasswords = (bool)$encrypt;
}
/**
* Get a list of all available encryption algorithms.
* Note: These are arbitrary codes, and not callable methods.
*
* @deprecated 2.4 Use PasswordEncryptor::get_encryptors()
*
* @return array Returns an array of strings containing all supported encryption algorithms.
*/
public static function get_encryption_algorithms() {
return array_keys(PasswordEncryptor::get_encryptors());
}
/**
* Set the password encryption algorithm
*
* @param string $algorithm One of the available password encryption
* algorithms determined by {@link Security::get_encryption_algorithms()}
* @return bool Returns TRUE if the passed algorithm was valid, otherwise FALSE.
*/
public static function set_password_encryption_algorithm($algorithm) {
if(!array_key_exists($algorithm, PasswordEncryptor::get_encryptors())) return false;
self::$encryptionAlgorithm = $algorithm;
return true;
}
/**
* @return String
*/
public static function get_password_encryption_algorithm() {
return self::$encryptionAlgorithm;
}
/**
* Encrypt a password according to the current password encryption settings.
* If the settings are so that passwords shouldn't be encrypted, the
* result is simple the clear text password with an empty salt except when
* a custom algorithm ($algorithm parameter) was passed.
*
* @param string $password The password to encrypt
* @param string $salt Optional: The salt to use. If it is not passed, but
* needed, the method will automatically create a
* random salt that will then be returned as return value.
* @param string $algorithm Optional: Use another algorithm to encrypt the
* password (so that the encryption algorithm can be changed over the time).
* @param Member $member Optional
* @return mixed Returns an associative array containing the encrypted
* password and the used salt in the form:
* <code>
* array(
* 'password' => string,
* 'salt' => string,
* 'algorithm' => string,
* 'encryptor' => PasswordEncryptor instance
* )
* </code>
* If the passed algorithm is invalid, FALSE will be returned.
*
* @see encrypt_passwords()
* @see set_password_encryption_algorithm()
*/
static function encrypt_password($password, $salt = null, $algorithm = null, $member = null) {
if(
// if the password is empty, don't encrypt
strlen(trim($password)) == 0
// if no algorithm is provided and no default is set, don't encrypt
|| (!$algorithm && self::$encryptPasswords == false)
) {
$algorithm = 'none';
} else {
// Fall back to the default encryption algorithm
if(!$algorithm) $algorithm = self::$encryptionAlgorithm;
}
$e = PasswordEncryptor::create_for_algorithm($algorithm);
// New salts will only need to be generated if the password is hashed for the first time
$salt = ($salt) ? $salt : $e->salt($password);
return array(
'password' => $e->encrypt($password, $salt),
'salt' => $salt,
'algorithm' => $algorithm,
'encryptor' => $e
);
}
/**
* Checks the database is in a state to perform security checks.
* @return bool
*/
public static function database_is_ready() {
$requiredTables = ClassInfo::dataClassesFor('Member');
$requiredTables[] = 'Group';
$requiredTables[] = 'Permission';
foreach($requiredTables as $table) if(!ClassInfo::hasTable($table)) return false;
return (($permissionFields = DB::fieldList('Permission')) && isset($permissionFields['Type'])) &&
(($memberFields = DB::fieldList('Member')) && isset($memberFields['RememberLoginToken']));
}
/**
* Enable or disable recording of login attempts
* through the {@link LoginRecord} object.
*
* @param boolean $bool
*/
public static function set_login_recording($bool) {
self::$login_recording = (bool)$bool;
}
/**
* @return boolean
*/
public static function login_recording() {
return self::$login_recording;
}
protected static $default_login_dest = "";
/**
* Set the default login dest
* This is the URL that users will be redirected to after they log in,
* if they haven't logged in en route to access a secured page.
*
* By default, this is set to the homepage
*/
public static function set_default_login_dest($dest) {
self::$default_login_dest = $dest;
}
/**
* Get the default login dest
*/
public static function default_login_dest() {
return self::$default_login_dest;
}
}
?>