silverstripe-framework/src/Security/PasswordExpirationMiddlewar...

247 lines
7.6 KiB
PHP

<?php declare(strict_types=1);
namespace SilverStripe\Security;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Control\Middleware\HTTPMiddleware;
use SilverStripe\Control\Session;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\ORM\Connect\DatabaseException;
/**
* Check if authenticated user has password expired.
* Depending on the configuration there are following outcomes:
* - if the current requested URL whitelisted, then allow to process further
* - else if the change password form URL is set, then redirect to it
* - else set current user to null (deauthenticate for the current request) and process further
*/
class PasswordExpirationMiddleware implements HTTPMiddleware
{
use Configurable;
/**
* Session key for persisting URL of the password change form
*/
const SESSION_KEY_REDIRECT = __CLASS__ . '.change password redirect';
/**
* Session key for persisting a flag allowing to process the current request
* without performing password expiration check
*/
const SESSION_KEY_ALLOW_CURRENT_REQUEST = __CLASS__ . '.allow current request';
/**
* List of URL patterns allowed for users to visit where
* URL starts with the pattern
*
* @var string[]
*
* @config
*/
private static $whitelisted_url_startswith = [];
/**
* Where users with expired passwords get redirected by default
* when login form didn't register a custom one with
* {@see SilverStripe\Security\AuthenticationMiddleware::setRedirect}
*
* @var string
*
* @config
*/
private static $default_redirect = null;
/**
* The list of mimetypes allowing a redirect to a change password form.
* By default this is (x)HTML
*
* @var string[]
*
* @config
*/
private static $mimetypes_allowing_redirect = [
'*/*',
'text/*',
'text/html',
'application/xhtml+xml',
'text/xml',
'application/xml'
];
public function process(HTTPRequest $request, callable $delegate)
{
try {
if ($response = $this->checkForExpiredPassword($request)) {
return $response;
}
} catch (DatabaseException $e) {
// Database isn't ready, carry on.
}
return $delegate($request);
}
/**
* Check if the just authenticated member has the password expired.
* Returns a response if the current request should not be
* processed as usual.
*
* @param HTTPRequest $request
*
* @return HTTPResponse|null
*/
protected function checkForExpiredPassword(HTTPRequest $request): ?HTTPResponse
{
$session = $request->getSession();
if ($session && $session->get(static::SESSION_KEY_ALLOW_CURRENT_REQUEST)) {
// allow current request and skip the expiration check, but for only the current
// request, so we're deleting the flag from the session so it's not affecting other
// requests.
// This flag would usually be set from within $handler->authenticateRequest()
$session->clear(static::SESSION_KEY_ALLOW_CURRENT_REQUEST);
return null;
}
$user = Security::getCurrentUser();
if ($user && $user->isPasswordExpired()) {
if ($response = $this->handleExpiredPassword($request)) {
return $response;
}
}
return null;
}
/**
* Check if we have a redirect to a password change form registered
* and redirect there if possible.
* Otherwise, deauthenticate the user by resetting it for this request,
* since we should treat ones with expired passwords as unauthorised.
*
* @param HTTPRequest $request
*
* @return HTTPResponse|null
*/
protected function handleExpiredPassword(HTTPRequest $request): ?HTTPResponse
{
$session = $request->getSession();
$sessionRedirectUrl = $session->get(static::SESSION_KEY_REDIRECT);
$defaultRedirectUrl = static::config()->get('default_redirect');
if ($sessionRedirectUrl || $defaultRedirectUrl) {
$redirectUrl = $this->absoluteUrl($sessionRedirectUrl ?? $defaultRedirectUrl);
} else {
$redirectUrl = null;
}
if (!$session || !$redirectUrl) {
Security::setCurrentUser(null);
return null;
}
$currentUrl = $this->absoluteUrl($request->getURL(true));
if ($currentUrl === $redirectUrl) {
return null;
}
$allowedStartswith = static::config()->get('whitelisted_url_startswith');
if (is_array($allowedStartswith)) {
foreach ($allowedStartswith as $pattern) {
$startswith = $this->absoluteUrl($pattern);
if (strncmp($currentUrl ?? '', $startswith ?? '', strlen($startswith ?? '')) === 0) {
return null;
}
}
}
return $this->redirectOrForbid($request, $redirectUrl);
}
/**
* Builds an absolute URL for the given path, adds base url
* if the path configured as absolute
*
* @param string $url
*
* @return string
*/
protected static function absoluteUrl($url): string
{
if (substr($url ?? '', 0, 1) === '/' && substr($url ?? '', 1, 1) !== '/') {
// add BASE_URL explicitly if not absolute
$url = Controller::join_links(Director::absoluteBaseURL(), $url);
} else {
$url = Director::absoluteURL($url) ?: Controller::join_links(Director::absoluteBaseURL(), $url);
}
if (substr($url ?? '', -1) === '/') {
$url = substr($url ?? '', 0, -1);
}
return $url;
}
/**
* Returns a redirect to the URL if text/html is acceptable, otherwise
* deauthenticates the current request by Security::setCurrentUser(null)
*
* @param HTTPRequest $request
* @param string $redirectUrl
*
* @return HTTPResponse|null
*/
private function redirectOrForbid(HTTPRequest $request, $redirectUrl): ?HTTPResponse
{
$acceptableTypes = $request->getAcceptMimetypes();
$allowedTypes = static::config()->get('mimetypes_allowing_redirect') ?? [];
if (count(array_intersect($allowedTypes ?? [], $acceptableTypes)) > 0) {
$redirectAllowed = true;
} else {
// if browser didn't send the Accept header
// with mimetypes, let's redirect anyway
$redirectAllowed = count($acceptableTypes ?? []) === 0;
}
if ($redirectAllowed) {
$response = new HTTPResponse();
$response->redirect($redirectUrl);
return $response;
}
Security::setCurrentUser(null);
return null;
}
/**
* Preserve the password change URL in the session
* That URL is to be redirected to to force users change expired passwords
*
* @param Session $session Session where we persist the redirect URL
* @param string $url change password form address
*/
public static function setRedirect(Session $session, $url)
{
$session->set(static::SESSION_KEY_REDIRECT, $url);
}
/**
* Allow the current request to be finished without password expiration check
*
* @param Session $session Session where we persist the redirect URL
*/
public static function allowCurrentRequest(Session $session)
{
$session->set(static::SESSION_KEY_ALLOW_CURRENT_REQUEST, true);
}
}