Version 4.1.19
This commit is contained in:
1 parent
28bd025b35
commit
2bd5025018
2674 files changed
+233518
-77766
No files matched your search
@@ -0,0 +1,315 @@
|
||||
<?php
|
||||
/**
|
||||
* @brief Multi Factor Authentication Handler for Google Authenticator
|
||||
* @author <a href='http://www.invisionpower.com'>Invision Power Services, Inc.</a>
|
||||
* @copyright (c) 2001 - 2016 Invision Power Services, Inc.
|
||||
* @license http://www.invisionpower.com/legal/standards/
|
||||
* @package IPS Community Suite
|
||||
* @since 2 Sep 2016
|
||||
* @version SVN_VERSION_NUMBER
|
||||
*/
|
||||
|
||||
namespace IPS\MFA\GoogleAuthenticator;
|
||||
|
||||
/* To prevent PHP errors (extending class does not exist) revealing path */
|
||||
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
|
||||
{
|
||||
header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi Factor Authentication Handler for Google Authenticator
|
||||
*/
|
||||
class _Handler extends \IPS\MFA\MFAHandler
|
||||
{
|
||||
/**
|
||||
* @brief Key
|
||||
*/
|
||||
protected $key = 'google';
|
||||
|
||||
/* !Setup */
|
||||
|
||||
/**
|
||||
* Handler is enabled
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isEnabled()
|
||||
{
|
||||
return \IPS\Settings::i()->googleauth_enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Member *can* use this handler (even if they have not yet configured it)
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function memberCanUseHandler( \IPS\Member $member )
|
||||
{
|
||||
return \IPS\Settings::i()->googleauth_groups == '*' or $member->inGroup( explode( ',', \IPS\Settings::i()->googleauth_groups ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Member has configured this handler
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @return bool
|
||||
*/
|
||||
public function memberHasConfiguredHandler( \IPS\Member $member )
|
||||
{
|
||||
return isset( $member->mfa_details['google'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a setup screen
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @param bool $showingMultipleHandlers Set to TRUE if multiple options are being displayed
|
||||
* @return string
|
||||
*/
|
||||
public function configurationScreen( \IPS\Member $member, $showingMultipleHandlers=FALSE )
|
||||
{
|
||||
/* Generate a secret */
|
||||
if ( isset( \IPS\Request::i()->secret ) )
|
||||
{
|
||||
$secret = \IPS\Request::i()->secret;
|
||||
}
|
||||
else
|
||||
{
|
||||
if ( function_exists( 'random_bytes' ) )
|
||||
{
|
||||
$randomString = random_bytes( 16 );
|
||||
}
|
||||
elseif ( function_exists( 'mcrypt_create_iv' ) )
|
||||
{
|
||||
$randomString = mcrypt_create_iv( 16, MCRYPT_DEV_URANDOM );
|
||||
}
|
||||
elseif ( function_exists( 'openssl_random_pseudo_bytes' ) )
|
||||
{
|
||||
$randomString = openssl_random_pseudo_bytes( 16 );
|
||||
}
|
||||
else
|
||||
{
|
||||
$randomString = \substr( md5( uniqid( microtime(), true ) ) . md5( uniqid( microtime(), true ) ), 0, 16 );
|
||||
}
|
||||
$validChars = array( 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '2', '3', '4', '5', '6', '7', '=' );
|
||||
$secret = '';
|
||||
for ( $i = 0; $i < 16; ++$i )
|
||||
{
|
||||
$secret .= $validChars[ ord( $randomString[ $i ] ) & 31 ];
|
||||
}
|
||||
}
|
||||
|
||||
/* Generate QR code */
|
||||
$qrCode = \IPS\Http\Url::external("https://chart.googleapis.com/chart")->setQueryString( array(
|
||||
'cht' => 'qr',
|
||||
'chs' => '200x200',
|
||||
'chl' => "otpauth://totp/{$member->email}?secret={$secret}&issuer=" . rawurlencode( \IPS\Settings::i()->board_name ),
|
||||
) );
|
||||
|
||||
/* Display */
|
||||
return \IPS\Theme::i()->getTemplate( 'login', 'core', 'global' )->googleAuthenticatorSetup( $qrCode, $secret, $showingMultipleHandlers );
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit configuration screen. Return TRUE if was accepted
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @return bool
|
||||
*/
|
||||
public function configurationScreenSubmit( \IPS\Member $member )
|
||||
{
|
||||
if ( \IPS\Request::i()->google_authenticator_setup_code )
|
||||
{
|
||||
if ( static::checkSubmittedCode( \IPS\Request::i()->google_authenticator_setup_code, \IPS\Request::i()->secret, $member ) )
|
||||
{
|
||||
$mfaDetails = $member->mfa_details;
|
||||
$mfaDetails['google'] = \IPS\Request::i()->secret;
|
||||
$member->mfa_details = $mfaDetails;
|
||||
$member->save();
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/* !Authentication */
|
||||
|
||||
/**
|
||||
* Get the form for a member to authenticate
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @return string
|
||||
*/
|
||||
public function authenticationScreen( \IPS\Member $member )
|
||||
{
|
||||
try
|
||||
{
|
||||
$waitUntil = ( \IPS\Db::i()->select( 'time', 'core_googleauth_used_codes', array( 'member=?', $member->member_id ), 'time DESC', 1 )->first() * 30 ) + 30;
|
||||
}
|
||||
catch ( \UnderflowException $e )
|
||||
{
|
||||
$waitUntil = NULL;
|
||||
}
|
||||
|
||||
return \IPS\Theme::i()->getTemplate( 'login', 'core', 'global' )->googleAuthenticatorAuth( $waitUntil );
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit authentication screen. Return TRUE if was accepted
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @return string
|
||||
*/
|
||||
public function authenticationScreenSubmit( \IPS\Member $member )
|
||||
{
|
||||
if ( \IPS\Request::i()->google_authenticator_auth_code )
|
||||
{
|
||||
if ( $codeTime = static::checkSubmittedCode( \IPS\Request::i()->google_authenticator_auth_code, $member->mfa_details['google'], $member ) )
|
||||
{
|
||||
\IPS\Db::i()->insert( 'core_googleauth_used_codes', array(
|
||||
'member' => $member->member_id,
|
||||
'time' => $codeTime
|
||||
) );
|
||||
return TRUE;
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/* !ACP */
|
||||
|
||||
/**
|
||||
* Toggle
|
||||
*
|
||||
* @param bool $enabled On/Off
|
||||
* @return bool
|
||||
*/
|
||||
public function toggle( $enabled )
|
||||
{
|
||||
\IPS\Db::i()->update( 'core_sys_conf_settings', array( 'conf_value' => $enabled ), array( 'conf_key=?', 'googleauth_enabled' ) );
|
||||
unset( \IPS\Data\Store::i()->settings );
|
||||
}
|
||||
|
||||
/**
|
||||
* ACP Settings
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function acpSettings()
|
||||
{
|
||||
$form = new \IPS\Helpers\Form;
|
||||
$form->add( new \IPS\Helpers\Form\Select( 'googleauth_groups', \IPS\Settings::i()->googleauth_groups == '*' ? '*' : explode( ',', \IPS\Settings::i()->googleauth_groups ), FALSE, array(
|
||||
'multiple' => TRUE,
|
||||
'options' => array_combine( array_keys( \IPS\Member\Group::groups() ), array_map( function( $_group ) { return (string) $_group; }, \IPS\Member\Group::groups() ) ),
|
||||
'unlimited' => '*',
|
||||
'unlimitedLang' => 'everyone'
|
||||
) ) );
|
||||
|
||||
if ( $values = $form->values() )
|
||||
{
|
||||
$values['googleauth_groups'] = ( $values['googleauth_groups'] == '*' ) ? '*' : implode( ',', $values['googleauth_groups'] );
|
||||
$form->saveAsSettings( $values );
|
||||
\IPS\Session::i()->log( 'acplogs__mfa_handler_enabled', array( "mfa_google_title" => TRUE ) );
|
||||
\IPS\Output::i()->redirect( \IPS\Http\Url::internal( 'app=core&module=settings&controller=mfa' ), 'saved' );
|
||||
}
|
||||
|
||||
return (string) $form;
|
||||
}
|
||||
|
||||
/* !Misc */
|
||||
|
||||
/**
|
||||
* If member has configured this handler, disable it
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @return bool
|
||||
*/
|
||||
public function disableHandlerForMember( \IPS\Member $member )
|
||||
{
|
||||
$mfaDetails = $member->mfa_details;
|
||||
unset( $mfaDetails['google'] );
|
||||
$member->mfa_details = $mfaDetails;
|
||||
$member->save();
|
||||
}
|
||||
|
||||
/* !Helper Methods */
|
||||
|
||||
/**
|
||||
* Verify a submitted code with a ±30 seconds leeway
|
||||
*
|
||||
* @param string $submittedCode The code that was submitted
|
||||
* @param string $secret The secret key
|
||||
* @param \IPS\Member $member The member this is for
|
||||
* @return int|FALSE
|
||||
*/
|
||||
protected static function checkSubmittedCode( $submittedCode, $secret, $member )
|
||||
{
|
||||
$submittedCode = str_replace( ' ', '', $submittedCode );
|
||||
|
||||
$validTimes = array( new \IPS\DateTime(), ( new \IPS\DateTime() )->add( new \DateInterval('PT30S') ), ( new \IPS\DateTime() )->sub( new \DateInterval('PT30S') ) );
|
||||
$blockedTimes = iterator_to_array( \IPS\Db::i()->select( 'time', 'core_googleauth_used_codes', array( 'member=?', $member->member_id ) ) );
|
||||
|
||||
$allowedCodes = array();
|
||||
foreach ( $validTimes as $time )
|
||||
{
|
||||
$codeTime = floor( $time->getTimestamp() / 30 );
|
||||
if ( !in_array( $codeTime, $blockedTimes ) )
|
||||
{
|
||||
$allowedCodes[ static::getCodeForSecretAtTime( $secret, $time ) ] = $codeTime;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ( $allowedCodes as $code => $time )
|
||||
{
|
||||
if ( \IPS\Login::compareHashes( (string) $code, $submittedCode ) )
|
||||
{
|
||||
return $time;
|
||||
}
|
||||
}
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the code
|
||||
*
|
||||
* @param string $secret The secret key for the user
|
||||
* @param \IPS\DateTime $time Timestamp
|
||||
* @return string
|
||||
*/
|
||||
protected static function getCodeForSecretAtTime( $secret, \IPS\DateTime $time )
|
||||
{
|
||||
/* Decode secret key */
|
||||
$secret = str_split( str_replace( '=', '', $secret ) );
|
||||
$chars = array( 'A' => 0, 'B' => 1, 'C' => 2, 'D' => 3, 'E' => 4, 'F' => 5, 'G' => 6, 'H' => 7, 'I' => 8, 'J' => 9, 'K' => 10, 'L' => 11, 'M' => 12, 'N' => 13, 'O' => 14, 'P' => 15, 'Q' => 16, 'R' => 17, 'S' => 18, 'T' => 19, 'U' => 20, 'V' => 21, 'W' => 22, 'X' => 23, 'Y' => 24, 'Z' => 25, 2 => 26, 3 => 27, 4 => 28, 5 => 29, 6 => 30, 7 => 31, '=' => 32 );
|
||||
$decodedSecretKey = '';
|
||||
for ( $i = 0; $i < 16; $i += 8 )
|
||||
{
|
||||
$block = '';
|
||||
for ( $j = 0; $j < 8; ++$j )
|
||||
{
|
||||
$block .= str_pad( base_convert( $chars[ $secret[ $i + $j ] ], 10, 2 ), 5, '0', STR_PAD_LEFT );
|
||||
}
|
||||
$eightBits = str_split( $block, 8 );
|
||||
for ( $z = 0; $z < count( $eightBits ); ++$z )
|
||||
{
|
||||
$decodedSecretKey .= ( ( $y = chr( base_convert( $eightBits[ $z ], 2, 10) ) ) || ord( $y ) == 48) ? $y : '';
|
||||
}
|
||||
}
|
||||
|
||||
/* Hash the timestamp with the secret key */
|
||||
$hash = hash_hmac('SHA1', chr(0).chr(0).chr(0).chr(0).pack('N*', floor( $time->getTimestamp() / 30 ) ), $decodedSecretKey, true);
|
||||
|
||||
/* Unpack it */
|
||||
$value = unpack( 'N', \substr( $hash, ord( \substr( $hash, -1 ) ) & 0x0F, 4 ) );
|
||||
$value = $value[1];
|
||||
|
||||
/* Get 32 bits */
|
||||
$value = $value & 0x7FFFFFFF;
|
||||
return str_pad( $value % pow( 10, 6 ), 6, '0', STR_PAD_LEFT );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
<?php
|
||||
/**
|
||||
* @brief Abstract Multi Factor Authentication Handler and Factory
|
||||
* @author <a href='http://www.invisionpower.com'>Invision Power Services, Inc.</a>
|
||||
* @copyright (c) 2001 - 2016 Invision Power Services, Inc.
|
||||
* @license http://www.invisionpower.com/legal/standards/
|
||||
* @package IPS Community Suite
|
||||
* @since 26 Aug 2016
|
||||
* @version SVN_VERSION_NUMBER
|
||||
*/
|
||||
|
||||
namespace IPS\MFA;
|
||||
|
||||
/* To prevent PHP errors (extending class does not exist) revealing path */
|
||||
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
|
||||
{
|
||||
header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract Multi Factor Authentication Handler and Factory
|
||||
*/
|
||||
abstract class _MFAHandler
|
||||
{
|
||||
/* !Access Methods */
|
||||
|
||||
/**
|
||||
* Get areas
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function areas()
|
||||
{
|
||||
$return = array();
|
||||
foreach ( \IPS\Application::allExtensions( 'core', 'MFAArea', FALSE, 'core', NULL, FALSE ) as $k => $v )
|
||||
{
|
||||
$return[ $k ] = "MFA_{$k}";
|
||||
}
|
||||
return $return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get handlers
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function handlers()
|
||||
{
|
||||
return array(
|
||||
'google' => new \IPS\MFA\GoogleAuthenticator\Handler(),
|
||||
'questions' => new \IPS\MFA\SecurityQuestions\Handler()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display output when trying to access an area
|
||||
*
|
||||
* @param string $app The application which owns the MFAArea extension
|
||||
* @param string $area The MFAArea key
|
||||
* @param \IPS\Http\Url $url URL for page
|
||||
* @param \IPS\Member $member The member, or NULL for currently logged in member
|
||||
* @return string
|
||||
*/
|
||||
public static function accessToArea( $app, $area, \IPS\Http\Url $url, \IPS\Member $member = NULL )
|
||||
{
|
||||
$member = $member ?: \IPS\Member::loggedIn();
|
||||
|
||||
/* Constant to disable MFA for emergency recovery */
|
||||
if ( \IPS\DISABLE_MFA )
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* If MFA is not enabled for this area, do nothing */
|
||||
if ( !\IPS\Settings::i()->security_questions_areas or !in_array( "{$app}_{$area}", explode( ',', \IPS\Settings::i()->security_questions_areas ) ) )
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Are we already authenticated? */
|
||||
if ( !\IPS\DEV_FORCE_MFA and ( isset( $_SESSION['MFAAuthenticated'] ) and ( !\IPS\Settings::i()->security_questions_timer or ( ( $_SESSION['MFAAuthenticated'] + ( \IPS\Settings::i()->security_questions_timer * 60 ) ) > time() ) ) ) )
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* "Opt Out" */
|
||||
if ( \IPS\Settings::i()->mfa_required_groups != '*' and !$member->inGroup( explode( ',', \IPS\Settings::i()->mfa_required_groups ) ) )
|
||||
{
|
||||
if ( $member->members_bitoptions['security_questions_opt_out'] )
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
if ( isset( \IPS\Request::i()->_mfa ) and \IPS\Request::i()->_mfa == 'optout' )
|
||||
{
|
||||
$member->members_bitoptions['security_questions_opt_out'] = TRUE;
|
||||
$member->save();
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/* Gather all the one we *can* use */
|
||||
$acceptableHandlers = array();
|
||||
foreach ( static::handlers() as $key => $handler )
|
||||
{
|
||||
/* If it's enabled and we can use it... */
|
||||
if ( $handler->isEnabled() and $handler->memberCanUseHandler( $member ) )
|
||||
{
|
||||
$acceptableHandlers[ $key ] = $handler;
|
||||
}
|
||||
}
|
||||
if ( !$acceptableHandlers )
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Locked out? */
|
||||
if ( $lockedOutScreen = static::_lockedOutScreen( $member ) )
|
||||
{
|
||||
\IPS\Output::i()->cssFiles = array_merge( \IPS\Output::i()->cssFiles, \IPS\Theme::i()->css( '2fa.css', 'core', 'global' ) );
|
||||
return $lockedOutScreen;
|
||||
}
|
||||
|
||||
/* "Try another way to sign in" */
|
||||
if ( isset( \IPS\Request::i()->_mfa ) and \IPS\Request::i()->_mfa == 'alt' )
|
||||
{
|
||||
\IPS\Output::i()->cssFiles = array_merge( \IPS\Output::i()->cssFiles, \IPS\Theme::i()->css( '2fa.css', 'core', 'global' ) );
|
||||
|
||||
/* What handlers have we configured? */
|
||||
$configuredHandlers = array();
|
||||
foreach ( $acceptableHandlers as $key => $handler )
|
||||
{
|
||||
if ( $handler->memberHasConfiguredHandler( $member ) )
|
||||
{
|
||||
$configuredHandlers[ $key ] = $handler;
|
||||
}
|
||||
}
|
||||
|
||||
if ( isset( \IPS\Request::i()->_mfaMethod ) and array_key_exists( \IPS\Request::i()->_mfaMethod, $configuredHandlers ) )
|
||||
{
|
||||
return static::_showHandlerAuthScreen( $configuredHandlers[ \IPS\Request::i()->_mfaMethod ], $url->setQueryString( array( '_mfa' => 'alt', '_mfaMethod' => \IPS\Request::i()->_mfaMethod ) ), $member );
|
||||
}
|
||||
|
||||
/* Display */
|
||||
return \IPS\Theme::i()->getTemplate( 'login', 'core', 'global' )->mfaRecovery( $configuredHandlers, $url );
|
||||
}
|
||||
|
||||
/* Normal authentication form */
|
||||
foreach ( $acceptableHandlers as $handler )
|
||||
{
|
||||
if ( $handler->memberHasConfiguredHandler( $member ) )
|
||||
{
|
||||
\IPS\Output::i()->cssFiles = array_merge( \IPS\Output::i()->cssFiles, \IPS\Theme::i()->css( '2fa.css', 'core', 'global' ) );
|
||||
return static::_showHandlerAuthScreen( $handler, $url, $member );
|
||||
}
|
||||
}
|
||||
|
||||
/* Setup form */
|
||||
$showSetupForm = ( \IPS\Settings::i()->mfa_required_groups == '*' or $member->inGroup( explode( ',', \IPS\Settings::i()->mfa_required_groups ) ) ) ? 'mfa_required_prompt' : 'mfa_optional_prompt';
|
||||
if ( \IPS\Settings::i()->$showSetupForm === 'access' or ( $app === 'core' and $area === 'AuthenticateAdmin' and \IPS\Settings::i()->$showSetupForm === 'immediate' ) )
|
||||
{
|
||||
\IPS\Output::i()->cssFiles = array_merge( \IPS\Output::i()->cssFiles, \IPS\Theme::i()->css( '2fa.css', 'core', 'global' ) );
|
||||
|
||||
/* Did we just submit it? */
|
||||
if ( isset( \IPS\Request::i()->mfa_setup ) )
|
||||
{
|
||||
foreach ( $acceptableHandlers as $key => $handler )
|
||||
{
|
||||
if ( ( count( $acceptableHandlers ) == 1 ) or $key == \IPS\Request::i()->mfa_method )
|
||||
{
|
||||
if ( $handler->configurationScreenSubmit( $member ) )
|
||||
{
|
||||
$_SESSION['MFAAuthenticated'] = time();
|
||||
return NULL;
|
||||
}
|
||||
elseif ( $lockedOutScreen = static::_lockedOutScreen( $member ) )
|
||||
{
|
||||
return $lockedOutScreen;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* No, show it */
|
||||
return \IPS\Theme::i()->getTemplate( 'login', 'core', 'global' )->mfaSetup( $acceptableHandlers, $member, $url );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a handler's authentication screen
|
||||
*
|
||||
* @param \IPS\MFA\MFAHandler $handler The handler to use
|
||||
* @param \IPS\Http\Url $url URL for page
|
||||
* @param \IPS\Member $member The member
|
||||
* @return string|null
|
||||
*/
|
||||
protected static function _showHandlerAuthScreen( \IPS\MFA\MFAHandler $handler, \IPS\Http\Url $url, \IPS\Member $member )
|
||||
{
|
||||
/* Did we just submit it? */
|
||||
if ( isset( \IPS\Request::i()->mfa_auth ) )
|
||||
{
|
||||
if ( $handler->authenticationScreenSubmit( $member ) )
|
||||
{
|
||||
$member->failed_mfa_attempts = 0;
|
||||
$member->save();
|
||||
$_SESSION['MFAAuthenticated'] = time();
|
||||
return NULL;
|
||||
}
|
||||
else
|
||||
{
|
||||
$member->failed_mfa_attempts++;
|
||||
$member->save();
|
||||
}
|
||||
}
|
||||
|
||||
/* No, show it */
|
||||
return \IPS\Theme::i()->getTemplate( 'login', 'core', 'global' )->mfaAuthenticate( $handler->authenticationScreen( $member ), $url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the locked out screen, if necessary
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @return string|null
|
||||
*/
|
||||
protected static function _lockedOutScreen( \IPS\Member $member )
|
||||
{
|
||||
if ( $member->failed_mfa_attempts >= \IPS\Settings::i()->security_questions_tries )
|
||||
{
|
||||
if ( \IPS\Settings::i()->mfa_lockout_behaviour == 'lock' )
|
||||
{
|
||||
$mfaDetails = $member->mfa_details;
|
||||
if ( !isset( $mfaDetails['_lockouttime'] ) )
|
||||
{
|
||||
$mfaDetails['_lockouttime'] = time();
|
||||
$member->mfa_details = $mfaDetails;
|
||||
$member->save();
|
||||
}
|
||||
|
||||
$lockEndTime = \IPS\DateTime::ts( $mfaDetails['_lockouttime'] )->add( new \DateInterval( 'PT' . \IPS\Settings::i()->mfa_lockout_time . 'M' ) );
|
||||
if ( $lockEndTime->getTimestamp() < time() )
|
||||
{
|
||||
unset( $mfaDetails['_lockouttime'] );
|
||||
$member->mfa_details = $mfaDetails;
|
||||
$member->failed_mfa_attempts = 0;
|
||||
$member->save();
|
||||
}
|
||||
else
|
||||
{
|
||||
return \IPS\Theme::i()->getTemplate( 'login', 'core', 'global' )->mfaLockout( ( \IPS\Settings::i()->mfa_lockout_time > 1440 ) ? $lockEndTime : $lockEndTime->localeTime( FALSE ) );
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return \IPS\Theme::i()->getTemplate( 'login', 'core', 'global' )->mfaLockout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* !Setup */
|
||||
|
||||
/**
|
||||
* Handler is enabled
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
abstract public function isEnabled();
|
||||
|
||||
/**
|
||||
* Member *can* use this handler (even if they have not yet configured it)
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @return bool
|
||||
*/
|
||||
abstract public function memberCanUseHandler( \IPS\Member $member );
|
||||
|
||||
/**
|
||||
* Member has configured this handler
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @return bool
|
||||
*/
|
||||
abstract public function memberHasConfiguredHandler( \IPS\Member $member );
|
||||
|
||||
/**
|
||||
* Show a setup screen
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @param bool $showingMultipleHandlers Set to TRUE if multiple options are being displayed
|
||||
* @return string
|
||||
*/
|
||||
abstract public function configurationScreen( \IPS\Member $member, $showingMultipleHandlers = FALSE );
|
||||
|
||||
/**
|
||||
* Submit configuration screen. Return TRUE if was accepted
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @return bool
|
||||
*/
|
||||
abstract public function configurationScreenSubmit( \IPS\Member $member );
|
||||
|
||||
/* !Authentication */
|
||||
|
||||
/**
|
||||
* Get the form for a member to authenticate
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @return string
|
||||
*/
|
||||
abstract public function authenticationScreen( \IPS\Member $member );
|
||||
|
||||
/**
|
||||
* Submit authentication screen. Return TRUE if was accepted
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @return string
|
||||
*/
|
||||
abstract public function authenticationScreenSubmit( \IPS\Member $member );
|
||||
|
||||
/* !ACP */
|
||||
|
||||
/**
|
||||
* Toggle
|
||||
*
|
||||
* @param bool $enabled On/Off
|
||||
* @return bool
|
||||
*/
|
||||
abstract public function toggle( $enabled );
|
||||
|
||||
/**
|
||||
* ACP Settings
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
abstract public function acpSettings();
|
||||
|
||||
/**
|
||||
* Configuration options when editing member account in ACP
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @param \IPS\Helpers\Form $form Set to TRUE if multiple options are being displayed
|
||||
* @return array
|
||||
*/
|
||||
public function acpConfiguration( \IPS\Member $member )
|
||||
{
|
||||
return array( new \IPS\Helpers\Form\YesNo( "mfa_{$this->key}_title", $this->memberHasConfiguredHandler( $member ), FALSE, array( 'disabled' => !$this->memberHasConfiguredHandler( $member ) ), NULL, NULL, NULL, "mfa_{$this->key}_title" ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Save configuration when editing member account in ACP
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @param array $values Values from form
|
||||
* @return array
|
||||
*/
|
||||
public function acpConfigurationSave( \IPS\Member $member, $values )
|
||||
{
|
||||
if ( isset( $values["mfa_{$this->key}_title"] ) and !$values["mfa_{$this->key}_title"] )
|
||||
{
|
||||
$this->disableHandlerForMember( $member );
|
||||
}
|
||||
}
|
||||
|
||||
/* !Misc */
|
||||
|
||||
/**
|
||||
* If member has configured this handler, disable it
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @return bool
|
||||
*/
|
||||
abstract public function disableHandlerForMember( \IPS\Member $member );
|
||||
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
/**
|
||||
* @brief Multi Factor Authentication Handler for Security Questions
|
||||
* @author <a href='http://www.invisionpower.com'>Invision Power Services, Inc.</a>
|
||||
* @copyright (c) 2001 - 2016 Invision Power Services, Inc.
|
||||
* @license http://www.invisionpower.com/legal/standards/
|
||||
* @package IPS Community Suite
|
||||
* @since 2 Sep 2016
|
||||
* @version SVN_VERSION_NUMBER
|
||||
*/
|
||||
|
||||
namespace IPS\MFA\SecurityQuestions;
|
||||
|
||||
/* To prevent PHP errors (extending class does not exist) revealing path */
|
||||
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
|
||||
{
|
||||
header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi Factor Authentication Handler for Security Questions
|
||||
*/
|
||||
class _Handler extends \IPS\MFA\MFAHandler
|
||||
{
|
||||
/**
|
||||
* @brief Key
|
||||
*/
|
||||
protected $key = 'questions';
|
||||
|
||||
/* !Setup */
|
||||
|
||||
/**
|
||||
* Handler is enabled
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isEnabled()
|
||||
{
|
||||
return \IPS\Settings::i()->security_questions_enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Member *can* use this handler (even if they have not yet configured it)
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function memberCanUseHandler( \IPS\Member $member )
|
||||
{
|
||||
return \IPS\Settings::i()->security_questions_groups == '*' or $member->inGroup( explode( ',', \IPS\Settings::i()->security_questions_groups ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Member has configured this handler
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @return bool
|
||||
*/
|
||||
public function memberHasConfiguredHandler( \IPS\Member $member )
|
||||
{
|
||||
return $member->members_bitoptions['has_security_answers'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a setup screen
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @param bool $showingMultipleHandlers Set to TRUE if multiple options are being displayed
|
||||
* @return string
|
||||
*/
|
||||
public function configurationScreen( \IPS\Member $member, $showingMultipleHandlers=FALSE )
|
||||
{
|
||||
$securityQuestions = array();
|
||||
foreach ( Question::roots() as $question )
|
||||
{
|
||||
$securityQuestions[ $question->id ] = $question->_title;
|
||||
}
|
||||
|
||||
return \IPS\Theme::i()->getTemplate( 'login', 'core', 'global' )->securityQuestionsSetup( $securityQuestions, $showingMultipleHandlers );
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit configuration screen. Return TRUE if was accepted
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @return bool
|
||||
*/
|
||||
public function configurationScreenSubmit( \IPS\Member $member )
|
||||
{
|
||||
$answers = array();
|
||||
|
||||
foreach ( \IPS\Request::i()->security_question as $k => $v )
|
||||
{
|
||||
$answers[ $v ] = array(
|
||||
'answer_question_id' => $v,
|
||||
'answer_member_id' => $member->member_id,
|
||||
'answer_answer' => \IPS\Text\Encrypt::fromPlaintext( \IPS\Request::i()->security_answer[ $k ] )->tag()
|
||||
);
|
||||
}
|
||||
|
||||
if ( count( $answers ) >= \IPS\Settings::i()->security_questions_number )
|
||||
{
|
||||
\IPS\Db::i()->delete( 'core_security_answers', array( 'answer_member_id=?', $member->member_id ) );
|
||||
\IPS\Db::i()->insert( 'core_security_answers', $answers );
|
||||
|
||||
$member->members_bitoptions['has_security_answers'] = TRUE;
|
||||
$member->save();
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/* !Authentication */
|
||||
|
||||
/**
|
||||
* Get the form for a member to authenticate
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @return string
|
||||
*/
|
||||
public function authenticationScreen( \IPS\Member $member )
|
||||
{
|
||||
try
|
||||
{
|
||||
$chosenAnswer = \IPS\Db::i()->select( '*', 'core_security_answers', array( 'answer_member_id=? AND answer_is_chosen=1', $member->member_id ) )->first();
|
||||
$chosenQuestion = Question::load( $chosenAnswer['answer_question_id'] );
|
||||
}
|
||||
catch ( \Exception $e )
|
||||
{
|
||||
$chosenQuestion = NULL;
|
||||
foreach ( \IPS\Db::i()->select( '*', 'core_security_answers', array( 'answer_member_id=?', $member->member_id ), 'RAND()' ) as $chosenAnswer )
|
||||
{
|
||||
try
|
||||
{
|
||||
$chosenQuestion = Question::load( $chosenAnswer['answer_question_id'] );
|
||||
\IPS\Db::i()->update( 'core_security_answers', array( 'answer_is_chosen' => 1 ), array( 'answer_member_id=? AND answer_question_id=?', $member->member_id, $chosenQuestion->id ) );
|
||||
break;
|
||||
}
|
||||
catch ( \OutOfRangeException $e ) { }
|
||||
}
|
||||
if ( !$chosenQuestion )
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
return \IPS\Theme::i()->getTemplate( 'login', 'core', 'global' )->securityQuestionsAuth( $chosenQuestion );
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit authentication screen. Return TRUE if was accepted
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @return string
|
||||
*/
|
||||
public function authenticationScreenSubmit( \IPS\Member $member )
|
||||
{
|
||||
try
|
||||
{
|
||||
$authenticated = \IPS\Request::i()->security_answer == \IPS\Text\Encrypt::fromTag( \IPS\Db::i()->select( 'answer_answer', 'core_security_answers', array( 'answer_member_id=? AND answer_is_chosen=1', $member->member_id ) )->first() )->decrypt();
|
||||
|
||||
if( $authenticated )
|
||||
{
|
||||
\IPS\Db::i()->update( 'core_security_answers', array( 'answer_is_chosen' => 0 ), array( 'answer_member_id=? AND answer_is_chosen=?', $member->member_id, 1 ) );
|
||||
}
|
||||
|
||||
return $authenticated;
|
||||
}
|
||||
catch ( \UnderflowException $e )
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
/* !ACP */
|
||||
|
||||
/**
|
||||
* Toggle
|
||||
*
|
||||
* @param bool $enabled On/Off
|
||||
* @return bool
|
||||
*/
|
||||
public function toggle( $enabled )
|
||||
{
|
||||
\IPS\Db::i()->update( 'core_sys_conf_settings', array( 'conf_value' => $enabled ), array( 'conf_key=?', 'security_questions_enabled' ) );
|
||||
unset( \IPS\Data\Store::i()->settings );
|
||||
}
|
||||
|
||||
/**
|
||||
* ACP Settings
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function acpSettings()
|
||||
{
|
||||
/* Init */
|
||||
$activeTabContents = '';
|
||||
$tabs = array(
|
||||
'settings' => 'security_question_settings',
|
||||
'questions' => 'security_questions_questions'
|
||||
);
|
||||
$activeTab = ( isset( \IPS\Request::i()->tab ) and array_key_exists( \IPS\Request::i()->tab, $tabs ) ) ? \IPS\Request::i()->tab : 'handlers';
|
||||
|
||||
/* Settings Form */
|
||||
if ( $activeTab === 'settings' )
|
||||
{
|
||||
$form = new \IPS\Helpers\Form;
|
||||
$form->add( new \IPS\Helpers\Form\Select( 'security_questions_groups', \IPS\Settings::i()->security_questions_groups == '*' ? '*' : explode( ',', \IPS\Settings::i()->security_questions_groups ), FALSE, array(
|
||||
'multiple' => TRUE,
|
||||
'options' => array_combine( array_keys( \IPS\Member\Group::groups() ), array_map( function( $_group ) { return (string) $_group; }, \IPS\Member\Group::groups() ) ),
|
||||
'unlimited' => '*',
|
||||
'unlimitedLang' => 'everyone'
|
||||
), NULL, NULL, NULL, 'security_questions_groups' ) );
|
||||
$form->add( new \IPS\Helpers\Form\Radio( 'security_questions_prompt', \IPS\Settings::i()->security_questions_prompt, FALSE, array( 'options' => array( 'register' => 'security_questions_prompt_register', 'optional' => 'security_questions_prompt_optional', 'access' => 'security_questions_prompt_access' ) ), NULL, NULL, NULL, 'security_questions_prompt' ) );
|
||||
$form->add( new \IPS\Helpers\Form\Number( 'security_questions_number', \IPS\Settings::i()->security_questions_number, FALSE, array( 'min' => 1, 'max' => \IPS\Db::i()->select( 'COUNT(*)', 'core_security_questions' )->first() ?: NULL ), NULL, NULL, NULL, 'security_questions_number' ) );
|
||||
|
||||
if ( $values = $form->values() )
|
||||
{
|
||||
$values['security_questions_groups'] = ( $values['security_questions_groups'] == '*' ) ? '*' : implode( ',', $values['security_questions_groups'] );
|
||||
$form->saveAsSettings( $values );
|
||||
\IPS\Session::i()->log( 'acplogs__mfa_handler_enabled', array( "mfa_questions_title" => TRUE ) );
|
||||
\IPS\Output::i()->redirect( \IPS\Http\Url::internal( 'app=core&module=settings&controller=mfa' ), 'saved' );
|
||||
}
|
||||
|
||||
$activeTabContents = (string) $form;
|
||||
}
|
||||
|
||||
/* Questions table */
|
||||
else
|
||||
{
|
||||
$controller = new \IPS\core\modules\admin\settings\securityquestions;
|
||||
$controller->execute();
|
||||
$activeTabContents = \IPS\Output::i()->output;
|
||||
}
|
||||
|
||||
|
||||
/* Output */
|
||||
if( \IPS\Request::i()->isAjax() )
|
||||
{
|
||||
return $activeTabContents;
|
||||
}
|
||||
else
|
||||
{
|
||||
return \IPS\Theme::i()->getTemplate( 'global' )->tabs( $tabs, $activeTab, $activeTabContents, \IPS\Http\Url::internal( "app=core&module=settings&controller=mfa&tab=handlers&do=settings&key=questions" ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options when editing member account in ACP
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @return array
|
||||
*/
|
||||
public function acpConfiguration( \IPS\Member $member )
|
||||
{
|
||||
$return = array();
|
||||
$return[] = new \IPS\Helpers\Form\YesNo( "mfa_{$this->key}_title", $this->memberHasConfiguredHandler( $member ), FALSE, array( 'togglesOn' => array( 'security_question_matrix' ) ) );
|
||||
|
||||
$securityQuestions = array();
|
||||
foreach ( \IPS\MFA\SecurityQuestions\Question::roots() as $question )
|
||||
{
|
||||
$securityQuestions[ $question->id ] = $question->_title;
|
||||
}
|
||||
|
||||
$matrix = new \IPS\Helpers\Form\Matrix('security_question_matrix');
|
||||
$matrix->columns = array(
|
||||
'security_question_q' => function( $key, $value, $data ) use ( $securityQuestions )
|
||||
{
|
||||
return new \IPS\Helpers\Form\Select( $key, $value, FALSE, array( 'options' => $securityQuestions ) );
|
||||
},
|
||||
'security_question_a' => function( $key, $value, $data )
|
||||
{
|
||||
return new \IPS\Helpers\Form\Text( $key, $value );
|
||||
},
|
||||
);
|
||||
|
||||
foreach ( $member->securityAnswers() as $questionId => $answer )
|
||||
{
|
||||
$matrix->rows[] = array(
|
||||
'security_question_q' => $questionId,
|
||||
'security_question_a' => \IPS\Text\Encrypt::fromTag( $answer )->decrypt()
|
||||
);
|
||||
}
|
||||
|
||||
$return['security_answers'] = $matrix;
|
||||
|
||||
return $return;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Save configuration when editing member account in ACP
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @param array $values Values from form
|
||||
* @return array
|
||||
*/
|
||||
public function acpConfigurationSave( \IPS\Member $member, $values )
|
||||
{
|
||||
\IPS\Db::i()->delete( 'core_security_answers', array( 'answer_member_id=?', $member->member_id ) );
|
||||
|
||||
$toInsert = array();
|
||||
|
||||
foreach ( $values['security_question_matrix'] as $row )
|
||||
{
|
||||
if ( $row['security_question_a'] )
|
||||
{
|
||||
$toInsert[ $row['security_question_q'] ] = array(
|
||||
'answer_question_id' => $row['security_question_q'],
|
||||
'answer_member_id' => $member->member_id,
|
||||
'answer_answer' => (string) \IPS\Text\Encrypt::fromPlaintext( $row['security_question_a'] )->tag()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ( count( $toInsert ) )
|
||||
{
|
||||
\IPS\Db::i()->insert( 'core_security_answers', $toInsert );
|
||||
}
|
||||
|
||||
if ( count( $toInsert ) >= \IPS\Settings::i()->security_questions_number )
|
||||
{
|
||||
$member->members_bitoptions['has_security_answers'] = TRUE;
|
||||
}
|
||||
else
|
||||
{
|
||||
$member->members_bitoptions['has_security_answers'] = FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
/* !Misc */
|
||||
|
||||
/**
|
||||
* If member has configured this handler, disable it
|
||||
*
|
||||
* @param \IPS\Member $member The member
|
||||
* @return bool
|
||||
*/
|
||||
public function disableHandlerForMember( \IPS\Member $member )
|
||||
{
|
||||
\IPS\Db::i()->delete( 'core_security_answers', array( 'answer_member_id=?', $member->member_id ) );
|
||||
$member->members_bitoptions['has_security_answers'] = FALSE;
|
||||
$member->save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
/**
|
||||
* @brief Security Question Node
|
||||
* @author <a href='http://www.invisionpower.com'>Invision Power Services, Inc.</a>
|
||||
* @copyright (c) 2001 - 2016 Invision Power Services, Inc.
|
||||
* @license http://www.invisionpower.com/legal/standards/
|
||||
* @package IPS Community Suite
|
||||
* @since 26 Aug 2016
|
||||
* @version SVN_VERSION_NUMBER
|
||||
*/
|
||||
|
||||
namespace IPS\MFA\SecurityQuestions;
|
||||
|
||||
/* To prevent PHP errors (extending class does not exist) revealing path */
|
||||
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
|
||||
{
|
||||
header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Security Question Node
|
||||
*/
|
||||
class _Question extends \IPS\Node\Model
|
||||
{
|
||||
/**
|
||||
* @brief [ActiveRecord] Multiton Store
|
||||
*/
|
||||
protected static $multitons;
|
||||
|
||||
/**
|
||||
* @brief [ActiveRecord] Database Table
|
||||
*/
|
||||
public static $databaseTable = 'core_security_questions';
|
||||
|
||||
/**
|
||||
* @brief [ActiveRecord] Database Prefix
|
||||
*/
|
||||
public static $databasePrefix = 'question_';
|
||||
|
||||
/**
|
||||
* @brief [Node] Order Database Column
|
||||
*/
|
||||
public static $databaseColumnOrder = 'order';
|
||||
|
||||
/**
|
||||
* @brief [Node] Node Title
|
||||
*/
|
||||
public static $nodeTitle = 'security_questions';
|
||||
|
||||
/**
|
||||
* @brief [Node] Title prefix. If specified, will look for a language key with "{$key}_title" as the key
|
||||
*/
|
||||
public static $titleLangPrefix = 'security_question_';
|
||||
|
||||
/**
|
||||
* [Node] Add/Edit Form
|
||||
*
|
||||
* @param \IPS\Helpers\Form $form The form
|
||||
* @return void
|
||||
*/
|
||||
public function form( &$form )
|
||||
{
|
||||
$form->add( new \IPS\Helpers\Form\Translatable( 'security_question_title', NULL, TRUE, array( 'app' => 'core', 'key' => ( $this->id ? "security_question_{$this->id}" : NULL ) ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* [Node] Format form values from add/edit form for save
|
||||
*
|
||||
* @param array $values Values from the form
|
||||
* @return array
|
||||
*/
|
||||
public function formatFormValues( $values )
|
||||
{
|
||||
if( !$this->id )
|
||||
{
|
||||
$this->save();
|
||||
}
|
||||
|
||||
\IPS\Lang::saveCustom( 'core', "security_question_{$this->id}", $values['security_question_title'] );
|
||||
unset( $values['security_question_title'] );
|
||||
|
||||
return $values;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user