Introduce $wgForceHTTPS
Add $wgForceHTTPS. When set to true: * It makes the HTTP to HTTPS redirect unconditional and suppresses the forceHTTPS cookie. * It makes session cookies be secure. * In the Action API, it triggers the existing deprecation warning and avoids more expensive user/session checks. * In login and signup, it suppresses the old hidden form fields for protocol switching. * It hides the prefershttps user preference. Other changes: * Factor out the HTTPS redirect in MediaWiki::main() into maybeDoHttpsRedirect() and shouldDoHttpRedirect(). Improve documentation. * User::requiresHTTPS() reflects $wgForceHTTPS whereas the Session concept of "force HTTPS" does not. The documentation of User::requiresHTTPS() says that it includes configuration, and retaining this definition was beneficial for some callers. Whereas Session::shouldForceHTTPS() was used fairly narrowly as the value of the forceHTTPS cookie, and injecting configuration into it is not so easy or beneficial, so I left it as it was, except for clarifying the documentation. * Deprecate the following hooks: BeforeHttpsRedirect, UserRequiresHTTPS, CanIPUseHTTPS. No known extension uses them, and they're not compatible with the long-term goal of ending support for mixed-protocol wikis. BeforeHttpsRedirect was documented as unstable from its inception. CanIPUseHTTPS was a WMF config hack now superseded by GFOC's SNI sniffing. * For tests which failed with $wgForceHTTPS=true, I mostly split the tests, testing each configuration value separately. * Add ArrayUtils::cartesianProduct() as a helper for generating combinations of boolean options in the session tests. Bug: T256095 Change-Id: Iefb5ba55af35350dfc7c050f9fb8f4e8a79751cb
This commit is contained in:
parent
01bf0a5786
commit
c75eef91bf
22 changed files with 399 additions and 110 deletions
|
|
@ -73,6 +73,9 @@ For notes on 1.34.x and older releases, see HISTORY.
|
|||
* Added $wgCdnMaxageStale, which controls the Cache-Control s-maxage header
|
||||
for page views when PoolCounter lock contention indicates that a stale cache
|
||||
entry should be sent.
|
||||
* Added $wgForceHTTPS, which makes the HTTP to HTTPS redirect be unconditional
|
||||
and suppresses various hacks needed to support mixed HTTP/HTTPS wikis. We
|
||||
recommend this be set to true on pure HTTPS wikis.
|
||||
* …
|
||||
|
||||
==== Changed configuration ====
|
||||
|
|
@ -1270,6 +1273,9 @@ because of Phabricator reports.
|
|||
deprecated. Use OutputPageBodyAttributes hook instead.
|
||||
* Installer::getDBTypes has been hard deprecated in favor of
|
||||
InstallerDBSupport::getDatabases
|
||||
* The hooks BeforeHttpsRedirect, CanIPUseHTTPS and UserRequiresHTTPS were
|
||||
deprecated, as part of a long-term plan to remove support for mixed
|
||||
HTTP/HTTPS wikis.
|
||||
* …
|
||||
|
||||
=== Other changes in 1.35 ===
|
||||
|
|
|
|||
|
|
@ -145,6 +145,26 @@ $wgAssumeProxiesUseDefaultProtocolPorts = true;
|
|||
*/
|
||||
$wgHttpsPort = 443;
|
||||
|
||||
/**
|
||||
* If this is true, when an insecure HTTP request is received, always redirect
|
||||
* to HTTPS. This overrides and disables the preferhttps user preference, and it
|
||||
* overrides $wgSecureLogin and the CanIPUseHTTPS hook.
|
||||
*
|
||||
* $wgServer may be either https or protocol-relative. If $wgServer starts with
|
||||
* "http://", an exception will be thrown.
|
||||
*
|
||||
* If a reverse proxy or CDN is used to forward requests from HTTPS to HTTP,
|
||||
* the request header "X-Forwarded-Proto: https" should be sent to suppress
|
||||
* the redirect.
|
||||
*
|
||||
* In addition to setting this to true, for optimal security, the web server
|
||||
* should also be configured to send Strict-Transport-Security response headers.
|
||||
*
|
||||
* @var bool
|
||||
* @since 1.35
|
||||
*/
|
||||
$wgForceHTTPS = false;
|
||||
|
||||
/**
|
||||
* The path we should point to.
|
||||
* It might be a virtual path in case with use apache mod_rewrite for example.
|
||||
|
|
@ -6431,7 +6451,11 @@ $wgCookiePath = '/';
|
|||
* Whether the "secure" flag should be set on the cookie. This can be:
|
||||
* - true: Set secure flag
|
||||
* - false: Don't set secure flag
|
||||
* - "detect": Set the secure flag if $wgServer is set to an HTTPS URL
|
||||
* - "detect": Set the secure flag if $wgServer is set to an HTTPS URL,
|
||||
* or if $wgForceHTTPS is true.
|
||||
*
|
||||
* If $wgForceHTTPS is true, session cookies will be secure regardless of this
|
||||
* setting. However, other cookies will still be affected.
|
||||
*/
|
||||
$wgCookieSecure = 'detect';
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ namespace MediaWiki\Hook;
|
|||
use IContextSource;
|
||||
|
||||
/**
|
||||
* @stable for implementation
|
||||
* @deprecated since 1.35
|
||||
* @ingroup Hooks
|
||||
*/
|
||||
interface BeforeHttpsRedirectHook {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
namespace MediaWiki\Hook;
|
||||
|
||||
/**
|
||||
* @stable for implementation
|
||||
* @deprecated since 1.35
|
||||
* @ingroup Hooks
|
||||
*/
|
||||
interface CanIPUseHTTPSHook {
|
||||
|
|
@ -11,6 +11,8 @@ interface CanIPUseHTTPSHook {
|
|||
* Use this hook to determine whether the client at a given source IP is likely
|
||||
* to be able to access the wiki via HTTPS.
|
||||
*
|
||||
* @deprecated since 1.35 This feature will be removed. All clients should use HTTPS.
|
||||
*
|
||||
* @since 1.35
|
||||
*
|
||||
* @param string $ip IP address in human-readable form
|
||||
|
|
|
|||
|
|
@ -46,7 +46,9 @@ class DeprecatedHooks {
|
|||
'ArticleRollbackComplete' => [ 'deprecatedVersion' => '1.35' ],
|
||||
'BaseTemplateAfterPortlet' => [ 'deprecatedVersion' => '1.35', 'silent' => true ],
|
||||
'BaseTemplateToolbox' => [ 'deprecatedVersion' => '1.35' ],
|
||||
'BeforeHttpsRedirect' => [ 'deprecatedVersion' => '1.35' ],
|
||||
'BeforeParserrenderImageGallery' => [ 'deprecatedVersion' => '1.35' ],
|
||||
'CanIPUseHTTPS' => [ 'deprecatedVersion' => '1.35' ],
|
||||
'DiffRevisionTools' => [ 'deprecatedVersion => 1.35' ],
|
||||
'DiffViewHeader' => [ 'deprecatedVersion' => '1.35' ],
|
||||
'HistoryRevisionTools' => [ 'deprecatedVersion => 1.35' ],
|
||||
|
|
@ -75,6 +77,7 @@ class DeprecatedHooks {
|
|||
'TitleMoveComplete' => [ 'deprecatedVersion' => '1.35', 'silent' => true ],
|
||||
'TitleMoveCompleting' => [ 'deprecatedVersion' => '1.35' ],
|
||||
'UndeleteShowRevision' => [ 'deprecatedVersion' => '1.35' ],
|
||||
'UserRequiresHTTPS' => [ 'deprecatedVersion' => '1.35' ],
|
||||
'UserRetrieveNewTalks' => [ 'deprecatedVersion' => '1.35' ],
|
||||
'UserSetCookies' => [ 'deprecatedVersion' => '1.27' ],
|
||||
'WikiPageDeletionUpdates' => [ 'deprecatedVersion' => '1.32', 'silent' => true ],
|
||||
|
|
|
|||
|
|
@ -913,54 +913,8 @@ class MediaWiki {
|
|||
$trxProfiler->setExpectations( $trxLimits['POST'], __METHOD__ );
|
||||
}
|
||||
|
||||
// If the user has forceHTTPS set to true, or if the user
|
||||
// is in a group requiring HTTPS, or if they have the HTTPS
|
||||
// preference set, redirect them to HTTPS.
|
||||
// Note: Do this after $wgTitle is setup, otherwise the hooks run from
|
||||
// isLoggedIn() will do all sorts of weird stuff.
|
||||
if (
|
||||
$request->getProtocol() == 'http' &&
|
||||
// switch to HTTPS only when supported by the server
|
||||
preg_match( '#^https://#', wfExpandUrl( $request->getRequestURL(), PROTO_HTTPS ) ) &&
|
||||
(
|
||||
$request->getSession()->shouldForceHTTPS() ||
|
||||
// Check the cookie manually, for paranoia
|
||||
$request->getCookie( 'forceHTTPS', '' ) ||
|
||||
// check for prefixed version that was used for a time in older MW versions
|
||||
$request->getCookie( 'forceHTTPS' ) ||
|
||||
// Avoid checking the user and groups unless it's enabled.
|
||||
(
|
||||
$this->context->getUser()->isLoggedIn()
|
||||
&& $this->context->getUser()->requiresHTTPS()
|
||||
)
|
||||
)
|
||||
) {
|
||||
$oldUrl = $request->getFullRequestURL();
|
||||
$redirUrl = preg_replace( '#^http://#', 'https://', $oldUrl );
|
||||
|
||||
// ATTENTION: This hook is likely to be removed soon due to overall design of the system.
|
||||
if ( $this->getHookRunner()->onBeforeHttpsRedirect( $this->context, $redirUrl ) ) {
|
||||
if ( $request->wasPosted() ) {
|
||||
// This is weird and we'd hope it almost never happens. This
|
||||
// means that a POST came in via HTTP and policy requires us
|
||||
// redirecting to HTTPS. It's likely such a request is going
|
||||
// to fail due to post data being lost, but let's try anyway
|
||||
// and just log the instance.
|
||||
|
||||
// @todo FIXME: See if we could issue a 307 or 308 here, need
|
||||
// to see how clients (automated & browser) behave when we do
|
||||
wfDebugLog( 'RedirectedPosts', "Redirected from HTTP to HTTPS: $oldUrl" );
|
||||
}
|
||||
// Setup dummy Title, otherwise OutputPage::redirect will fail
|
||||
$title = Title::newFromText( 'REDIR', NS_MAIN );
|
||||
$this->context->setTitle( $title );
|
||||
// Since we only do this redir to change proto, always send a vary header
|
||||
$output->addVaryHeader( 'X-Forwarded-Proto' );
|
||||
$output->redirect( $redirUrl );
|
||||
$output->output();
|
||||
|
||||
return;
|
||||
}
|
||||
if ( $this->maybeDoHttpsRedirect() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $title->canExist() && HTMLFileCache::useFileCache( $this->context ) ) {
|
||||
|
|
@ -1006,6 +960,93 @@ class MediaWiki {
|
|||
$this->outputResponsePayload( $outputWork() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an HTTP->HTTPS redirect should be done. It may still be aborted
|
||||
* by a hook, so this is not the final word.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function shouldDoHttpRedirect() {
|
||||
$request = $this->context->getRequest();
|
||||
|
||||
// Don't redirect if we're already on HTTPS
|
||||
if ( $request->getProtocol() !== 'http' ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$force = $this->config->get( 'ForceHTTPS' );
|
||||
|
||||
// Don't redirect if $wgServer is explicitly HTTP. We test for this here
|
||||
// by checking whether wfExpandUrl() is able to force HTTPS.
|
||||
if ( !preg_match( '#^https://#', wfExpandUrl( $request->getRequestURL(), PROTO_HTTPS ) ) ) {
|
||||
if ( $force ) {
|
||||
throw new RuntimeException( '$wgForceHTTPS is true but the server is not HTTPS' );
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Configured $wgForceHTTPS overrides the remaining conditions
|
||||
if ( $force ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if HTTPS is required by the session or user preferences
|
||||
return $request->getSession()->shouldForceHTTPS() ||
|
||||
// Check the cookie manually, for paranoia
|
||||
$request->getCookie( 'forceHTTPS', '' ) ||
|
||||
// Avoid checking the user and groups unless it's enabled.
|
||||
(
|
||||
$this->context->getUser()->isLoggedIn()
|
||||
&& $this->context->getUser()->requiresHTTPS()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the stars are suitably aligned, do an HTTP->HTTPS redirect
|
||||
*
|
||||
* Note: Do this after $wgTitle is setup, otherwise the hooks run from
|
||||
* isLoggedIn() will do all sorts of weird stuff.
|
||||
*
|
||||
* @return bool True if the redirect was done. Handling of the request
|
||||
* should be aborted. False if no redirect was done.
|
||||
*/
|
||||
private function maybeDoHttpsRedirect() {
|
||||
if ( !$this->shouldDoHttpRedirect() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$request = $this->context->getRequest();
|
||||
$oldUrl = $request->getFullRequestURL();
|
||||
$redirUrl = preg_replace( '#^http://#', 'https://', $oldUrl );
|
||||
|
||||
// ATTENTION: This hook is likely to be removed soon due to overall design of the system.
|
||||
if ( !$this->getHookRunner()->onBeforeHttpsRedirect( $this->context, $redirUrl ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( $request->wasPosted() ) {
|
||||
// This is weird and we'd hope it almost never happens. This
|
||||
// means that a POST came in via HTTP and policy requires us
|
||||
// redirecting to HTTPS. It's likely such a request is going
|
||||
// to fail due to post data being lost, but let's try anyway
|
||||
// and just log the instance.
|
||||
|
||||
// @todo FIXME: See if we could issue a 307 or 308 here, need
|
||||
// to see how clients (automated & browser) behave when we do
|
||||
wfDebugLog( 'RedirectedPosts', "Redirected from HTTP to HTTPS: $oldUrl" );
|
||||
}
|
||||
// Setup dummy Title, otherwise OutputPage::redirect will fail
|
||||
$title = Title::newFromText( 'REDIR', NS_MAIN );
|
||||
$this->context->setTitle( $title );
|
||||
// Since we only do this redir to change proto, always send a vary header
|
||||
$output = $this->context->getOutput();
|
||||
$output->addVaryHeader( 'X-Forwarded-Proto' );
|
||||
$output->redirect( $redirUrl );
|
||||
$output->output();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the actual output and attempt to flush it to the client if necessary
|
||||
*
|
||||
|
|
|
|||
|
|
@ -503,7 +503,7 @@ if ( $wgPageLanguageUseDB ) {
|
|||
}
|
||||
|
||||
if ( $wgCookieSecure === 'detect' ) {
|
||||
$wgCookieSecure = ( WebRequest::detectProtocol() === 'https' );
|
||||
$wgCookieSecure = $wgForceHTTPS || ( WebRequest::detectProtocol() === 'https' );
|
||||
}
|
||||
|
||||
// Backwards compatibility with old password limits
|
||||
|
|
|
|||
|
|
@ -1542,11 +1542,14 @@ class ApiMain extends ApiBase {
|
|||
$this->mPrinter = $this->createPrinterByName( $params['format'] );
|
||||
}
|
||||
|
||||
if ( $request->getProtocol() === 'http' && (
|
||||
$request->getSession()->shouldForceHTTPS() ||
|
||||
( $this->getUser()->isLoggedIn() &&
|
||||
$this->getUser()->requiresHTTPS() )
|
||||
) ) {
|
||||
if ( $request->getProtocol() === 'http' &&
|
||||
(
|
||||
$this->getConfig()->get( 'ForceHTTPS' ) ||
|
||||
$request->getSession()->shouldForceHTTPS() ||
|
||||
( $this->getUser()->isLoggedIn() &&
|
||||
$this->getUser()->requiresHTTPS() )
|
||||
)
|
||||
) {
|
||||
$this->addDeprecation( 'apiwarn-deprecation-httpsexpected', 'https-expected' );
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,4 +182,72 @@ class ArrayUtils {
|
|||
|
||||
return $ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an array consisting of every combination of the elements of the
|
||||
* input arrays. Each element of the output array is an array with a number
|
||||
* of elements equal to the number of input parameters.
|
||||
*
|
||||
* In mathematical terms, this is an n-ary Cartesian product.
|
||||
*
|
||||
* For example, ArrayUtils::cartesianProduct( [ 1, 2 ], [ 'a', 'b' ] )
|
||||
* produces [ [ 1, 'a' ], [ 1, 'b' ], [ 2, 'a' ], [ 2, 'b' ] ]
|
||||
*
|
||||
* If any of the input arrays is empty, the result is the empty array [].
|
||||
* This is in keeping with the mathematical definition.
|
||||
*
|
||||
* If no parameters are given, the result is also the empty array.
|
||||
*
|
||||
* The array keys are ignored. This implementation uses the internal
|
||||
* pointers of the input arrays to keep track of the current position
|
||||
* without referring to the keys.
|
||||
*
|
||||
* @since 1.35
|
||||
*
|
||||
* @param array ...$inputArrays
|
||||
* @return array
|
||||
*/
|
||||
public static function cartesianProduct( ...$inputArrays ) {
|
||||
$numInputs = count( $inputArrays );
|
||||
if ( $numInputs === 0 ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Reset the internal pointers
|
||||
foreach ( $inputArrays as &$inputArray ) {
|
||||
if ( !count( $inputArray ) ) {
|
||||
return [];
|
||||
}
|
||||
reset( $inputArray );
|
||||
}
|
||||
unset( $inputArray );
|
||||
|
||||
$outputArrays = [];
|
||||
$done = false;
|
||||
while ( !$done ) {
|
||||
// Construct the output array element
|
||||
$element = [];
|
||||
foreach ( $inputArrays as $paramIndex => $inputArray ) {
|
||||
$element[] = current( $inputArray );
|
||||
}
|
||||
$outputArrays[] = $element;
|
||||
|
||||
// Increment the pointers starting from the least significant.
|
||||
// If the least significant rolls over back to the start of the
|
||||
// array, continue with the next most significant, and so on until
|
||||
// that stops happening. If all pointers roll over, we are done.
|
||||
$done = true;
|
||||
for ( $paramIndex = $numInputs - 1; $paramIndex >= 0; $paramIndex-- ) {
|
||||
next( $inputArrays[$paramIndex] );
|
||||
if ( key( $inputArrays[$paramIndex] ) === null ) {
|
||||
reset( $inputArrays[$paramIndex] );
|
||||
// continue
|
||||
} else {
|
||||
$done = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $outputArrays;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ class DefaultPreferencesFactory implements PreferencesFactory {
|
|||
'EnotifRevealEditorAddress',
|
||||
'EnotifUserTalk',
|
||||
'EnotifWatchlist',
|
||||
'ForceHTTPS',
|
||||
'HiddenPrefs',
|
||||
'ImageLimits',
|
||||
'LanguageCode',
|
||||
|
|
@ -457,7 +458,10 @@ class DefaultPreferencesFactory implements PreferencesFactory {
|
|||
];
|
||||
}
|
||||
// Only show prefershttps if secure login is turned on
|
||||
if ( $this->options->get( 'SecureLogin' ) && $canIPUseHTTPS ) {
|
||||
if ( !$this->options->get( 'ForceHTTPS' )
|
||||
&& $this->options->get( 'SecureLogin' )
|
||||
&& $canIPUseHTTPS
|
||||
) {
|
||||
$defaultPreferences['prefershttps'] = [
|
||||
'type' => 'toggle',
|
||||
'label-message' => 'tog-prefershttps',
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ class CookieSessionProvider extends SessionProvider {
|
|||
'prefix' => $config->get( 'CookiePrefix' ),
|
||||
'path' => $config->get( 'CookiePath' ),
|
||||
'domain' => $config->get( 'CookieDomain' ),
|
||||
'secure' => $config->get( 'CookieSecure' ),
|
||||
'secure' => $config->get( 'CookieSecure' ) || $this->config->get( 'ForceHTTPS' ),
|
||||
'httpOnly' => $config->get( 'CookieHttpOnly' ),
|
||||
];
|
||||
}
|
||||
|
|
@ -210,10 +210,8 @@ class CookieSessionProvider extends SessionProvider {
|
|||
|
||||
$forceHTTPS = $session->shouldForceHTTPS() || $user->requiresHTTPS();
|
||||
if ( $forceHTTPS ) {
|
||||
// Don't set the secure flag if the request came in
|
||||
// over "http", for backwards compat.
|
||||
// @todo Break that backwards compat properly.
|
||||
$options['secure'] = $this->config->get( 'CookieSecure' );
|
||||
$options['secure'] = $this->config->get( 'CookieSecure' )
|
||||
|| $this->config->get( 'ForceHTTPS' );
|
||||
}
|
||||
|
||||
$response->setCookie( $this->params['sessionName'], $session->getId(), null,
|
||||
|
|
@ -263,12 +261,17 @@ class CookieSessionProvider extends SessionProvider {
|
|||
}
|
||||
|
||||
/**
|
||||
* Set the "forceHTTPS" cookie
|
||||
* Set the "forceHTTPS" cookie, unless $wgForceHTTPS prevents it.
|
||||
*
|
||||
* @param bool $set Whether the cookie should be set or not
|
||||
* @param SessionBackend|null $backend
|
||||
* @param WebRequest $request
|
||||
*/
|
||||
protected function setForceHTTPSCookie( $set, ?SessionBackend $backend, WebRequest $request ) {
|
||||
if ( $this->config->get( 'ForceHTTPS' ) ) {
|
||||
// No need to send a cookie if the wiki is always HTTPS (T256095)
|
||||
return;
|
||||
}
|
||||
$response = $request->response();
|
||||
if ( $set ) {
|
||||
if ( $backend->shouldRememberUser() ) {
|
||||
|
|
|
|||
|
|
@ -112,8 +112,11 @@ abstract class ImmutableSessionProviderWithCookie extends SessionProvider {
|
|||
|
||||
$options = $this->sessionCookieOptions;
|
||||
if ( $session->shouldForceHTTPS() || $session->getUser()->requiresHTTPS() ) {
|
||||
$response->setCookie( 'forceHTTPS', 'true', null,
|
||||
[ 'prefix' => '', 'secure' => false ] + $options );
|
||||
// Send a cookie unless $wgForceHTTPS is set (T256095)
|
||||
if ( !$this->config->get( 'ForceHTTPS' ) ) {
|
||||
$response->setCookie( 'forceHTTPS', 'true', null,
|
||||
[ 'prefix' => '', 'secure' => false ] + $options );
|
||||
}
|
||||
$options['secure'] = true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -209,7 +209,10 @@ class Session implements \Countable, \Iterator, \ArrayAccess {
|
|||
}
|
||||
|
||||
/**
|
||||
* Whether HTTPS should be forced
|
||||
* Get the expected value of the forceHTTPS cookie. This reflects whether
|
||||
* session cookies were sent with the Secure attribute. If $wgForceHTTPS
|
||||
* is true, the forceHTTPS cookie is not sent and this value is ignored.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function shouldForceHTTPS() {
|
||||
|
|
@ -217,7 +220,10 @@ class Session implements \Countable, \Iterator, \ArrayAccess {
|
|||
}
|
||||
|
||||
/**
|
||||
* Set whether HTTPS should be forced
|
||||
* Set the value of the forceHTTPS cookie. This reflects whether session
|
||||
* cookies were sent with the Secure attribute. If $wgForceHTTPS is true,
|
||||
* the forceHTTPS cookie is not sent, and this value is ignored.
|
||||
*
|
||||
* @param bool $force
|
||||
*/
|
||||
public function setForceHTTPS( $force ) {
|
||||
|
|
|
|||
|
|
@ -80,7 +80,8 @@ class SessionInfo {
|
|||
* - persisted: (bool) Whether this session was persisted
|
||||
* - remembered: (bool) Whether the verified user was remembered.
|
||||
* Defaults to true.
|
||||
* - forceHTTPS: (bool) Whether to force HTTPS for this session
|
||||
* - forceHTTPS: (bool) Whether to force HTTPS for this session. This is
|
||||
* ignored if $wgForceHTTPS is true.
|
||||
* - metadata: (array) Provider metadata, to be returned by
|
||||
* Session::getProviderMetadata(). See SessionProvider::mergeMetadata()
|
||||
* and SessionProvider::refreshSessionInfo().
|
||||
|
|
@ -271,7 +272,9 @@ class SessionInfo {
|
|||
}
|
||||
|
||||
/**
|
||||
* Whether this session should only be used over HTTPS
|
||||
* Whether this session should only be used over HTTPS. This should be
|
||||
* ignored if $wgForceHTTPS is true.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
final public function forceHTTPS() {
|
||||
|
|
|
|||
|
|
@ -108,7 +108,8 @@ abstract class LoginSignupSpecialPage extends AuthManagerSpecialPage {
|
|||
$this->mAction = $request->getVal( 'action' );
|
||||
$this->mFromHTTP = $request->getBool( 'fromhttp', false )
|
||||
|| $request->getBool( 'wpFromhttp', false );
|
||||
$this->mStickHTTPS = ( !$this->mFromHTTP && $request->getProtocol() === 'https' )
|
||||
$this->mStickHTTPS = $this->getConfig()->get( 'ForceHTTPS' )
|
||||
|| ( !$this->mFromHTTP && $request->getProtocol() === 'https' )
|
||||
|| $request->getBool( 'wpForceHttps', false );
|
||||
$this->mLanguage = $request->getText( 'uselang' );
|
||||
$this->mReturnTo = $request->getVal( 'returnto', '' );
|
||||
|
|
@ -650,7 +651,6 @@ abstract class LoginSignupSpecialPage extends AuthManagerSpecialPage {
|
|||
* @return HTMLForm
|
||||
*/
|
||||
protected function getAuthForm( array $requests, $action, $msg = '', $msgType = 'error' ) {
|
||||
global $wgSecureLogin;
|
||||
// FIXME merge this with parent
|
||||
|
||||
if ( isset( $this->authForm ) ) {
|
||||
|
|
@ -679,7 +679,8 @@ abstract class LoginSignupSpecialPage extends AuthManagerSpecialPage {
|
|||
}
|
||||
$form->addHiddenField( 'force', $this->securityLevel );
|
||||
$form->addHiddenField( $this->getTokenName(), $this->getToken()->toString() );
|
||||
if ( $wgSecureLogin ) {
|
||||
$config = $this->getConfig();
|
||||
if ( $config->get( 'SecureLogin' ) && !$config->get( 'ForceHTTPS' ) ) {
|
||||
// If using HTTPS coming from HTTP, then the 'fromhttp' parameter must be preserved
|
||||
if ( !$this->isSignup() ) {
|
||||
$form->addHiddenField( 'wpForceHttps', (int)$this->mStickHTTPS );
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@ class LoginHelper extends ContextSource {
|
|||
* - successredirect: send an HTTP redirect using $wgRedirectOnLogin if needed
|
||||
* @param string $returnTo
|
||||
* @param array|string $returnToQuery
|
||||
* @param bool $stickHTTPS Keep redirect link on HTTPS
|
||||
* @param bool $stickHTTPS Keep redirect link on HTTPS. Ignored (treated as
|
||||
* true) if $wgForceHTTPS is true.
|
||||
*/
|
||||
public function showReturnToPage(
|
||||
$type, $returnTo = '', $returnToQuery = '', $stickHTTPS = false
|
||||
|
|
@ -81,12 +82,14 @@ class LoginHelper extends ContextSource {
|
|||
|
||||
$returnToTitle = Title::newFromText( $returnTo ) ?: Title::newMainPage();
|
||||
|
||||
if ( $config->get( 'SecureLogin' ) && !$stickHTTPS ) {
|
||||
$options = [ 'http' ];
|
||||
$proto = PROTO_HTTP;
|
||||
} elseif ( $config->get( 'SecureLogin' ) ) {
|
||||
if ( $config->get( 'ForceHTTPS' )
|
||||
|| ( $config->get( 'SecureLogin' ) && $stickHTTPS )
|
||||
) {
|
||||
$options = [ 'https' ];
|
||||
$proto = PROTO_HTTPS;
|
||||
} elseif ( $config->get( 'SecureLogin' ) && !$stickHTTPS ) {
|
||||
$options = [ 'http' ];
|
||||
$proto = PROTO_HTTP;
|
||||
} else {
|
||||
$options = [];
|
||||
$proto = PROTO_RELATIVE;
|
||||
|
|
|
|||
|
|
@ -5,13 +5,16 @@ namespace MediaWiki\User\Hook;
|
|||
use User;
|
||||
|
||||
/**
|
||||
* @stable for implementation
|
||||
* @deprecated since 1.35
|
||||
* @ingroup Hooks
|
||||
*/
|
||||
interface UserRequiresHTTPSHook {
|
||||
/**
|
||||
* This hook is called to determine whether a user needs to be switched to HTTPS.
|
||||
*
|
||||
* Deprecated since 1.35 as part of a drive towards deprecation of
|
||||
* mixed-protocol wikis.
|
||||
*
|
||||
* @since 1.35
|
||||
*
|
||||
* @param User $user User in question.
|
||||
|
|
|
|||
|
|
@ -2891,11 +2891,13 @@ class User implements IDBAccessObject, UserIdentity {
|
|||
* @return bool
|
||||
*/
|
||||
public function requiresHTTPS() {
|
||||
global $wgSecureLogin;
|
||||
global $wgForceHTTPS, $wgSecureLogin;
|
||||
if ( $wgForceHTTPS ) {
|
||||
return true;
|
||||
}
|
||||
if ( !$wgSecureLogin ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$https = $this->getBoolOption( 'prefershttps' );
|
||||
$this->getHookRunner()->onUserRequiresHTTPS( $this, $https );
|
||||
if ( $https ) {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
|
|||
'SessionName' => false,
|
||||
'CookieExpiration' => 100,
|
||||
'ExtendedLoginCookieExpiration' => 200,
|
||||
'ForceHTTPS' => false,
|
||||
] );
|
||||
}
|
||||
|
||||
|
|
@ -37,6 +38,16 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
|
|||
return MediaWikiServices::getInstance()->getHookContainer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for testing both values of $wgForceHTTPS
|
||||
*/
|
||||
public static function provideForceHTTPS() {
|
||||
return [
|
||||
[ false ],
|
||||
[ true ]
|
||||
];
|
||||
}
|
||||
|
||||
public function testConstructor() {
|
||||
try {
|
||||
new CookieSessionProvider();
|
||||
|
|
@ -400,7 +411,8 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
|
|||
$this->assertEquals( 'Example', $provider->suggestLoginUsername( $request ) );
|
||||
}
|
||||
|
||||
public function testPersistSession() {
|
||||
/** @dataProvider provideForceHTTPS */
|
||||
public function testPersistSession( $forceHTTPS ) {
|
||||
$provider = new CookieSessionProvider( [
|
||||
'priority' => 1,
|
||||
'sessionName' => 'MySessionName',
|
||||
|
|
@ -408,6 +420,7 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
|
|||
'cookieOptions' => [ 'prefix' => 'x' ],
|
||||
] );
|
||||
$config = $this->getConfig();
|
||||
$config->set( 'ForceHTTPS', $forceHTTPS );
|
||||
$hookContainer = $this->getHookContainer();
|
||||
$provider->setLogger( new \TestLogger() );
|
||||
$provider->setConfig( $config );
|
||||
|
|
@ -416,6 +429,10 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
|
|||
|
||||
$sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
$store = new TestBagOStuff();
|
||||
|
||||
// For User::requiresHTTPS
|
||||
$this->setMwGlobals( [ 'wgForceHTTPS' => $forceHTTPS ] );
|
||||
|
||||
$user = static::getTestSysop()->getUser();
|
||||
$anon = new User;
|
||||
|
||||
|
|
@ -450,7 +467,11 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
|
|||
$this->assertSame( '', $request->response()->getCookie( 'xUserID' ) );
|
||||
$this->assertSame( null, $request->response()->getCookie( 'xUserName' ) );
|
||||
$this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
|
||||
$this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) );
|
||||
if ( $forceHTTPS ) {
|
||||
$this->assertSame( null, $request->response()->getCookie( 'forceHTTPS' ) );
|
||||
} else {
|
||||
$this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) );
|
||||
}
|
||||
$this->assertSame( [], $backend->getData() );
|
||||
|
||||
// Logged-in user, no remember
|
||||
|
|
@ -463,7 +484,11 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
|
|||
$this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) );
|
||||
$this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) );
|
||||
$this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
|
||||
$this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) );
|
||||
if ( $forceHTTPS ) {
|
||||
$this->assertSame( null, $request->response()->getCookie( 'forceHTTPS' ) );
|
||||
} else {
|
||||
$this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) );
|
||||
}
|
||||
$this->assertSame( [], $backend->getData() );
|
||||
|
||||
// Logged-in user, remember
|
||||
|
|
@ -477,7 +502,11 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
|
|||
$this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) );
|
||||
$this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) );
|
||||
$this->assertSame( $user->getToken(), $request->response()->getCookie( 'xToken' ) );
|
||||
$this->assertSame( 'true', $request->response()->getCookie( 'forceHTTPS' ) );
|
||||
if ( $forceHTTPS ) {
|
||||
$this->assertSame( null, $request->response()->getCookie( 'forceHTTPS' ) );
|
||||
} else {
|
||||
$this->assertSame( 'true', $request->response()->getCookie( 'forceHTTPS' ) );
|
||||
}
|
||||
$this->assertSame( [], $backend->getData() );
|
||||
}
|
||||
|
||||
|
|
@ -485,10 +514,12 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
|
|||
* @dataProvider provideCookieData
|
||||
* @param bool $secure
|
||||
* @param bool $remember
|
||||
* @param bool $forceHTTPS
|
||||
*/
|
||||
public function testCookieData( $secure, $remember ) {
|
||||
public function testCookieData( $secure, $remember, $forceHTTPS ) {
|
||||
$this->setMwGlobals( [
|
||||
'wgSecureLogin' => false,
|
||||
'wgForceHTTPS' => $forceHTTPS,
|
||||
] );
|
||||
|
||||
$provider = new CookieSessionProvider( [
|
||||
|
|
@ -499,6 +530,7 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
|
|||
] );
|
||||
$config = $this->getConfig();
|
||||
$config->set( 'CookieSecure', $secure );
|
||||
$config->set( 'ForceHTTPS', $forceHTTPS );
|
||||
$hookContainer = $this->getHookContainer();
|
||||
$provider->setLogger( new \TestLogger() );
|
||||
$provider->setConfig( $config );
|
||||
|
|
@ -507,7 +539,7 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
|
|||
|
||||
$sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
$user = static::getTestSysop()->getUser();
|
||||
$this->assertFalse( $user->requiresHTTPS(), 'sanity check' );
|
||||
$this->assertSame( $user->requiresHTTPS(), $forceHTTPS, 'sanity check' );
|
||||
|
||||
$backend = new SessionBackend(
|
||||
new SessionId( $sessionId ),
|
||||
|
|
@ -534,7 +566,7 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
|
|||
'expire' => (int)100,
|
||||
'path' => $config->get( 'CookiePath' ),
|
||||
'domain' => $config->get( 'CookieDomain' ),
|
||||
'secure' => $secure,
|
||||
'secure' => $secure || $forceHTTPS,
|
||||
'httpOnly' => $config->get( 'CookieHttpOnly' ),
|
||||
'raw' => false,
|
||||
];
|
||||
|
|
@ -558,13 +590,15 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
|
|||
'xToken' => [
|
||||
'value' => $remember ? $user->getToken() : '',
|
||||
'expire' => $remember ? $extendedExpiry : -31536000,
|
||||
] + $defaults,
|
||||
'forceHTTPS' => [
|
||||
] + $defaults
|
||||
];
|
||||
if ( !$forceHTTPS ) {
|
||||
$expect['forceHTTPS'] = [
|
||||
'value' => $secure ? 'true' : '',
|
||||
'secure' => false,
|
||||
'expire' => $secure ? ( $remember ? $defaults['expire'] : 0 ) : -31536000,
|
||||
] + $defaults,
|
||||
];
|
||||
] + $defaults;
|
||||
}
|
||||
foreach ( $expect as $key => $value ) {
|
||||
$actual = $request->response()->getCookieData( $key );
|
||||
if ( $actual && $actual['expire'] > 0 ) {
|
||||
|
|
@ -576,12 +610,11 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
|
|||
}
|
||||
|
||||
public static function provideCookieData() {
|
||||
return [
|
||||
[ false, false ],
|
||||
[ false, true ],
|
||||
[ true, false ],
|
||||
[ true, true ],
|
||||
];
|
||||
return \ArrayUtils::cartesianProduct(
|
||||
[ false, true ], // $secure
|
||||
[ false, true ], // $remember
|
||||
[ false, true ] // $forceHTTPS
|
||||
);
|
||||
}
|
||||
|
||||
protected function getSentRequest() {
|
||||
|
|
@ -612,6 +645,9 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
|
|||
$provider->setManager( SessionManager::singleton() );
|
||||
$provider->setHookContainer( $hookContainer );
|
||||
|
||||
// For User::requiresHTTPS
|
||||
$this->setMwGlobals( [ 'wgForceHTTPS' => false ] );
|
||||
|
||||
$sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
$store = new TestBagOStuff();
|
||||
$user = static::getTestSysop()->getUser();
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ use Wikimedia\TestingAccessWrapper;
|
|||
*/
|
||||
class ImmutableSessionProviderWithCookieTest extends MediaWikiTestCase {
|
||||
|
||||
private function getProvider( $name, $prefix = null ) {
|
||||
private function getProvider( $name, $prefix = null, $forceHTTPS = false ) {
|
||||
$config = new \HashConfig();
|
||||
$config->set( 'CookiePrefix', 'wgCookiePrefix' );
|
||||
$config->set( 'ForceHTTPS', $forceHTTPS );
|
||||
|
||||
$params = [
|
||||
'sessionCookieName' => $name,
|
||||
|
|
@ -178,14 +179,16 @@ class ImmutableSessionProviderWithCookieTest extends MediaWikiTestCase {
|
|||
* @dataProvider providePersistSession
|
||||
* @param bool $secure
|
||||
* @param bool $remember
|
||||
* @param bool $forceHTTPS
|
||||
*/
|
||||
public function testPersistSession( $secure, $remember ) {
|
||||
public function testPersistSession( $secure, $remember, $forceHTTPS ) {
|
||||
$this->setMwGlobals( [
|
||||
'wgCookieExpiration' => 100,
|
||||
'wgSecureLogin' => false,
|
||||
'wgForceHTTPS' => $forceHTTPS,
|
||||
] );
|
||||
|
||||
$provider = $this->getProvider( 'session' );
|
||||
$provider = $this->getProvider( 'session', null, $forceHTTPS );
|
||||
$provider->setLogger( new \Psr\Log\NullLogger() );
|
||||
$priv = TestingAccessWrapper::newFromObject( $provider );
|
||||
$priv->sessionCookieOptions = [
|
||||
|
|
@ -198,7 +201,7 @@ class ImmutableSessionProviderWithCookieTest extends MediaWikiTestCase {
|
|||
|
||||
$sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
$user = User::newFromName( 'UTSysop' );
|
||||
$this->assertFalse( $user->requiresHTTPS(), 'sanity check' );
|
||||
$this->assertSame( $forceHTTPS, $user->requiresHTTPS(), 'sanity check' );
|
||||
|
||||
$backend = new SessionBackend(
|
||||
new SessionId( $sessionId ),
|
||||
|
|
@ -241,13 +244,13 @@ class ImmutableSessionProviderWithCookieTest extends MediaWikiTestCase {
|
|||
'expire' => null,
|
||||
'path' => 'CookiePath',
|
||||
'domain' => 'CookieDomain',
|
||||
'secure' => $secure,
|
||||
'secure' => $secure || $forceHTTPS,
|
||||
'httpOnly' => true,
|
||||
'raw' => false,
|
||||
], $cookie );
|
||||
|
||||
$cookie = $request->response()->getCookieData( 'forceHTTPS' );
|
||||
if ( $secure ) {
|
||||
if ( $secure && !$forceHTTPS ) {
|
||||
$this->assertIsArray( $cookie );
|
||||
if ( isset( $cookie['expire'] ) && $cookie['expire'] > 0 ) {
|
||||
// Round expiry so we don't randomly fail if the seconds ticked during the test.
|
||||
|
|
@ -273,12 +276,11 @@ class ImmutableSessionProviderWithCookieTest extends MediaWikiTestCase {
|
|||
}
|
||||
|
||||
public static function providePersistSession() {
|
||||
return [
|
||||
[ false, false ],
|
||||
[ false, true ],
|
||||
[ true, false ],
|
||||
[ true, true ],
|
||||
];
|
||||
return \ArrayUtils::cartesianProduct(
|
||||
[ false, true ], // $secure
|
||||
[ false, true ], // $remember
|
||||
[ false, true ] // $forceHTTPS
|
||||
);
|
||||
}
|
||||
|
||||
public function testUnpersistSession() {
|
||||
|
|
|
|||
|
|
@ -2569,16 +2569,19 @@ class UserTest extends MediaWikiTestCase {
|
|||
public function testRequiresHTTPS( $preference, $hook1, $hook2, bool $expected ) {
|
||||
$this->setMwGlobals( [
|
||||
'wgSecureLogin' => true,
|
||||
'wgForceHTTPS' => false,
|
||||
] );
|
||||
|
||||
$user = User::newFromName( 'UserWhoMayRequireHTTPS' );
|
||||
$user->setOption( 'prefershttps', $preference );
|
||||
$user->saveSettings();
|
||||
|
||||
$this->filterDeprecated( '/UserRequiresHTTPS hook/' );
|
||||
$this->setTemporaryHook( 'UserRequiresHTTPS', function ( $user, &$https ) use ( $hook1 ) {
|
||||
$https = $hook1;
|
||||
return false;
|
||||
} );
|
||||
$this->filterDeprecated( '/CanIPUseHTTPS hook/' );
|
||||
$this->setTemporaryHook( 'CanIPUseHTTPS', function ( $ip, &$canDo ) use ( $hook2 ) {
|
||||
if ( $hook2 === 'notcalled' ) {
|
||||
$this->fail( 'CanIPUseHTTPS hook should not have been called' );
|
||||
|
|
@ -2608,6 +2611,7 @@ class UserTest extends MediaWikiTestCase {
|
|||
public function testRequiresHTTPS_disabled() {
|
||||
$this->setMwGlobals( [
|
||||
'wgSecureLogin' => false,
|
||||
'wgForceHTTPS' => false,
|
||||
] );
|
||||
|
||||
$user = User::newFromName( 'UserWhoMayRequireHTTP' );
|
||||
|
|
@ -2621,6 +2625,26 @@ class UserTest extends MediaWikiTestCase {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers User::requiresHTTPS
|
||||
*/
|
||||
public function testRequiresHTTPS_forced() {
|
||||
$this->setMwGlobals( [
|
||||
'wgSecureLogin' => true,
|
||||
'wgForceHTTPS' => true,
|
||||
] );
|
||||
|
||||
$user = User::newFromName( 'UserWhoMayRequireHTTP' );
|
||||
$user->setOption( 'prefershttps', false );
|
||||
$user->saveSettings();
|
||||
|
||||
$user = User::newFromName( $user->getName() );
|
||||
$this->assertTrue(
|
||||
$user->requiresHTTPS(),
|
||||
'User preference ignored if wgForceHTTPS is true'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers User::isCreatableName
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -303,4 +303,56 @@ class ArrayUtilsTest extends PHPUnit\Framework\TestCase {
|
|||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideCartesianProduct
|
||||
* @covers ArrayUtils::cartesianProduct
|
||||
*/
|
||||
public function testCartesianProduct( $inputs, $expected ) {
|
||||
$result = ArrayUtils::cartesianProduct( ...$inputs );
|
||||
$this->assertSame( $expected, $result );
|
||||
}
|
||||
|
||||
public function provideCartesianProduct() {
|
||||
$ab = [ 'a', 'b' ];
|
||||
$cd = [ 'c', 'd' ];
|
||||
$ac = [ 'a', 'c' ];
|
||||
$ad = [ 'a', 'd' ];
|
||||
$bc = [ 'b', 'c' ];
|
||||
$bd = [ 'b', 'd' ];
|
||||
|
||||
return [
|
||||
'no inputs' => [
|
||||
[],
|
||||
[]
|
||||
],
|
||||
'one empty input' => [
|
||||
[ [] ],
|
||||
[]
|
||||
],
|
||||
'one non-empty input' => [
|
||||
[ $ab ],
|
||||
[ [ 'a' ], [ 'b' ] ]
|
||||
],
|
||||
'non-empty times empty' => [
|
||||
[ $ab, [] ],
|
||||
[]
|
||||
],
|
||||
'empty times non-empty' => [
|
||||
[ [], $ab ],
|
||||
[]
|
||||
],
|
||||
'ab x cd' => [
|
||||
[ $ab, $cd ],
|
||||
[ $ac, $ad, $bc, $bd ]
|
||||
],
|
||||
'keys are ignored' => [
|
||||
[
|
||||
[ 99 => 'a', 98 => 'b' ],
|
||||
[ 97 => 'c', 96 => 'd' ],
|
||||
],
|
||||
[ $ac, $ad, $bc, $bd ]
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue