360 lines
10 KiB
PHP
360 lines
10 KiB
PHP
<?php
|
|
/**
|
|
* @brief Multi Factor Authentication Handler for Google Authenticator
|
|
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
|
|
* @copyright (c) Invision Power Services, Inc.
|
|
* @license https://www.invisioncommunity.com/legal/standards/
|
|
* @package Invision Community
|
|
* @since 2 Sep 2016
|
|
*/
|
|
|
|
namespace IPS\MFA\GoogleAuthenticator;
|
|
|
|
/* To prevent PHP errors (extending class does not exist) revealing path */
|
|
|
|
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
|
|
use BaconQrCode\Renderer\ImageRenderer;
|
|
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
|
use BaconQrCode\Writer;
|
|
use DateInterval;
|
|
use IPS\DateTime;
|
|
use IPS\Db;
|
|
use IPS\Helpers\Form;
|
|
use IPS\Helpers\Form\CheckboxSet;
|
|
use IPS\Http\Url;
|
|
use IPS\IPS;
|
|
use IPS\Login;
|
|
use IPS\Member;
|
|
use IPS\Member\Group;
|
|
use IPS\MFA\MFAHandler;
|
|
use IPS\Output;
|
|
use IPS\Request;
|
|
use IPS\Session;
|
|
use IPS\Settings;
|
|
use IPS\Theme;
|
|
use UnderflowException;
|
|
use function chr;
|
|
use function count;
|
|
use function defined;
|
|
use function function_exists;
|
|
use function in_array;
|
|
use function ord;
|
|
use function substr;
|
|
|
|
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
|
|
{
|
|
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Multi Factor Authentication Handler for Google Authenticator
|
|
*/
|
|
class Handler extends MFAHandler
|
|
{
|
|
/**
|
|
* @brief Key
|
|
*/
|
|
protected string $key = 'google';
|
|
|
|
/* !Setup */
|
|
|
|
/**
|
|
* Handler is enabled
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function isEnabled(): bool
|
|
{
|
|
return Settings::i()->googleauth_enabled;
|
|
}
|
|
|
|
/**
|
|
* Member *can* use this handler (even if they have not yet configured it)
|
|
*
|
|
* @param Member $member
|
|
* @return bool
|
|
*/
|
|
public function memberCanUseHandler( Member $member ): bool
|
|
{
|
|
return Settings::i()->googleauth_groups == '*' or $member->inGroup( explode( ',', Settings::i()->googleauth_groups ) );
|
|
}
|
|
|
|
/**
|
|
* Member has configured this handler
|
|
*
|
|
* @param Member $member The member
|
|
* @return bool
|
|
*/
|
|
public function memberHasConfiguredHandler( Member $member ): bool
|
|
{
|
|
return isset( $member->mfa_details['google'] );
|
|
}
|
|
|
|
/**
|
|
* Show a setup screen
|
|
*
|
|
* @param Member $member The member
|
|
* @param bool $showingMultipleHandlers Set to TRUE if multiple options are being displayed
|
|
* @param Url $url URL for page
|
|
* @return string
|
|
*/
|
|
public function configurationScreen( Member $member, bool $showingMultipleHandlers, Url $url ): string
|
|
{
|
|
/* Generate a secret */
|
|
if ( isset( Request::i()->secret ) )
|
|
{
|
|
$secret = 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 */
|
|
IPS::$PSR0Namespaces['BaconQrCode'] = \IPS\ROOT_PATH . '/system/3rd_party/BaconQrCode/src';
|
|
IPS::$PSR0Namespaces['DASPRiD'] = \IPS\ROOT_PATH . '/system/3rd_party/DASPRiD';
|
|
$renderer = new ImageRenderer(
|
|
new RendererStyle(150),
|
|
new SvgImageBackEnd()
|
|
);
|
|
$writer = new Writer($renderer);
|
|
$data = "otpauth://totp/{$member->email}?secret={$secret}&issuer=" . rawurlencode( Settings::i()->board_name );
|
|
|
|
$str = $writer->writeString($data);
|
|
|
|
$qrCode = 'data:image/svg+xml;utf8,' . rawurlencode($str);
|
|
|
|
/* Display */
|
|
return Theme::i()->getTemplate( 'login', 'core', 'global' )->googleAuthenticatorSetup( $qrCode, $secret, $showingMultipleHandlers );
|
|
}
|
|
|
|
/**
|
|
* Submit configuration screen. Return TRUE if was accepted
|
|
*
|
|
* @param Member $member The member
|
|
* @return bool
|
|
*/
|
|
public function configurationScreenSubmit( Member $member ): bool
|
|
{
|
|
if ( Request::i()->google_authenticator_setup_code )
|
|
{
|
|
if ( static::checkSubmittedCode( Request::i()->google_authenticator_setup_code, Request::i()->secret, $member ) )
|
|
{
|
|
$mfaDetails = $member->mfa_details;
|
|
$mfaDetails['google'] = Request::i()->secret;
|
|
$member->mfa_details = $mfaDetails;
|
|
$member->save();
|
|
|
|
/* Log MFA Enable */
|
|
$member->logHistory( 'core', 'mfa', array( 'handler' => $this->key, 'enable' => TRUE ) );
|
|
|
|
return TRUE;
|
|
}
|
|
}
|
|
return FALSE;
|
|
}
|
|
|
|
/* !Authentication */
|
|
|
|
/**
|
|
* Get the form for a member to authenticate
|
|
*
|
|
* @param Member $member The member
|
|
* @param Url $url URL for page
|
|
* @return string
|
|
*/
|
|
public function authenticationScreen( Member $member, Url $url ): string
|
|
{
|
|
try
|
|
{
|
|
$waitUntil = ( 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 Theme::i()->getTemplate( 'login', 'core', 'global' )->googleAuthenticatorAuth( $waitUntil );
|
|
}
|
|
|
|
/**
|
|
* Submit authentication screen. Return TRUE if was accepted
|
|
*
|
|
* @param Member $member The member
|
|
* @return bool
|
|
*/
|
|
public function authenticationScreenSubmit( Member $member ): bool
|
|
{
|
|
if ( Request::i()->google_authenticator_auth_code )
|
|
{
|
|
if ( $codeTime = static::checkSubmittedCode( Request::i()->google_authenticator_auth_code, $member->mfa_details['google'], $member ) )
|
|
{
|
|
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 void
|
|
*/
|
|
public function toggle( bool $enabled ): void
|
|
{
|
|
Settings::i()->changeValues( array( 'googleauth_enabled' => $enabled ) );
|
|
}
|
|
|
|
/**
|
|
* ACP Settings
|
|
*
|
|
* @return string
|
|
*/
|
|
public function acpSettings(): string
|
|
{
|
|
$form = new Form;
|
|
$form->add( new CheckboxSet( 'googleauth_groups', Settings::i()->googleauth_groups == '*' ? '*' : explode( ',', Settings::i()->googleauth_groups ), FALSE, array(
|
|
'multiple' => TRUE,
|
|
'options' => array_combine( array_keys( Group::groups() ), array_map( function( $_group ) { return (string) $_group; }, Group::groups() ) ),
|
|
'unlimited' => '*',
|
|
'unlimitedLang' => 'everyone',
|
|
'impliedUnlimited' => TRUE
|
|
) ) );
|
|
|
|
if ( $values = $form->values() )
|
|
{
|
|
$values['googleauth_groups'] = ( $values['googleauth_groups'] == '*' ) ? '*' : implode( ',', $values['googleauth_groups'] );
|
|
$form->saveAsSettings( $values );
|
|
Session::i()->log( 'acplogs__mfa_handler_enabled', array( "mfa_google_title" => TRUE ) );
|
|
Output::i()->redirect( Url::internal( 'app=core&module=settings&controller=mfa' ), 'saved' );
|
|
}
|
|
|
|
return (string) $form;
|
|
}
|
|
|
|
/* !Misc */
|
|
|
|
/**
|
|
* If member has configured this handler, disable it
|
|
*
|
|
* @param Member $member The member
|
|
* @return void
|
|
*/
|
|
public function disableHandlerForMember( Member $member ): void
|
|
{
|
|
$mfaDetails = $member->mfa_details;
|
|
unset( $mfaDetails['google'] );
|
|
$member->mfa_details = $mfaDetails;
|
|
$member->save();
|
|
|
|
/* Log MFA Disable */
|
|
$member->logHistory( 'core', 'mfa', array( 'handler' => $this->key, 'enable' => FALSE ) );
|
|
}
|
|
|
|
/* !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 Member $member The member this is for
|
|
* @return int|FALSE
|
|
*/
|
|
protected static function checkSubmittedCode(string $submittedCode, string $secret, Member $member ): int|FALSE
|
|
{
|
|
$submittedCode = str_replace( ' ', '', $submittedCode );
|
|
|
|
$validTimes = array( new DateTime(), ( new DateTime() )->add( new DateInterval('PT30S') ), ( new DateTime() )->sub( new DateInterval('PT30S') ) );
|
|
$blockedTimes = iterator_to_array( 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 ( Login::compareHashes( (string) $code, $submittedCode ) )
|
|
{
|
|
return $time;
|
|
}
|
|
}
|
|
|
|
return FALSE;
|
|
}
|
|
|
|
/**
|
|
* Get the code
|
|
*
|
|
* @param string $secret The secret key for the user
|
|
* @param DateTime $time Timestamp
|
|
* @return string
|
|
*/
|
|
protected static function getCodeForSecretAtTime( string $secret, DateTime $time ): string
|
|
{
|
|
/* 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 );
|
|
}
|
|
} |