465 lines
11 KiB
PHP
465 lines
11 KiB
PHP
<?php
|
|
/**
|
|
* @brief Sign In With Apple Login Handler
|
|
* @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 Jun 2020
|
|
*/
|
|
|
|
namespace IPS\Login\Handler\OAuth2;
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/**
|
|
* Sign In With Apple Login Handler
|
|
*/
|
|
class _Apple extends \IPS\Login\Handler\OAuth2\OpenID
|
|
{
|
|
/**
|
|
* Get title
|
|
*
|
|
* @return string
|
|
*/
|
|
public static function getTitle()
|
|
{
|
|
return 'login_handler_Apple';
|
|
}
|
|
|
|
/**
|
|
* Should client credentials be sent as an "Authorisation" header, or as POST data?
|
|
*
|
|
* @return string
|
|
*/
|
|
protected function _authenticationType()
|
|
{
|
|
return static::AUTHENTICATE_POST;
|
|
}
|
|
|
|
/**
|
|
* ACP Settings Form
|
|
*
|
|
* @return array List of settings to save - settings will be stored to core_login_methods.login_settings DB field
|
|
* @code
|
|
return array( 'savekey' => new \IPS\Helpers\Form\[Type]( ... ), ... );
|
|
* @endcode
|
|
*/
|
|
public function acpForm()
|
|
{
|
|
\IPS\Member::loggedIn()->language()->words['oauth_client_id'] = \IPS\Member::loggedIn()->language()->addToStack('login_apple_services_id');
|
|
|
|
$return = array();
|
|
$return[] = array( 'login_handler_apple_settings', 'login_handler_Apple_info' );
|
|
|
|
$accountManagementSettings = array();
|
|
$active = 'return';
|
|
|
|
foreach ( parent::acpForm() as $k => $v )
|
|
{
|
|
if ( $v === 'account_management_settings' )
|
|
{
|
|
$active = 'accountManagementSettings';
|
|
}
|
|
if ( !\is_string( $v ) and !\is_array( $v ) and $k !== 'client_secret' )
|
|
{
|
|
${$active}[ $k ] = $v;
|
|
}
|
|
}
|
|
|
|
$return['apple_team_id'] = new \IPS\Helpers\Form\Text( 'apple_team_id', isset( $this->settings['apple_team_id'] ) ? $this->settings['apple_team_id'] : NULL, NULL, array(), NULL, NULL, NULL, 'apple_team_id' );
|
|
$return['apple_key_id'] = new \IPS\Helpers\Form\Text( 'apple_key_id', isset( $this->settings['apple_key_id'] ) ? $this->settings['apple_key_id'] : NULL, NULL, array(), NULL, NULL, NULL, 'apple_key_id' );
|
|
$return['apple_key'] = new \IPS\Helpers\Form\Upload( 'apple_key', ( isset( $this->settings['apple_key'] ) and $this->settings['apple_key'] ) ? \IPS\File::get( 'core_Login', $this->settings['apple_key'] ) : NULL, TRUE, array( 'storageExtension' => 'core_Login', 'allowedFileTypes' => ['p8'] ), NULL, NULL, NULL, 'apple_key' );;
|
|
|
|
$return[] = 'account_management_settings';
|
|
foreach ( $accountManagementSettings as $k => $v )
|
|
{
|
|
$return[ $k ] = $v;
|
|
}
|
|
|
|
return $return;
|
|
}
|
|
|
|
/**
|
|
* Save Handler Settings
|
|
*
|
|
* @param array $values Values from form
|
|
* @return array
|
|
*/
|
|
public function acpFormSave( &$values )
|
|
{
|
|
$return = parent::acpFormSave( $values );
|
|
$return['apple_key'] = (string) $return['apple_key'];
|
|
|
|
return $return;
|
|
}
|
|
|
|
/**
|
|
* Get the button color
|
|
*
|
|
* @return string
|
|
*/
|
|
public function buttonColor()
|
|
{
|
|
return '#000000';
|
|
}
|
|
|
|
/**
|
|
* Get the button icon
|
|
*
|
|
* @return string
|
|
*/
|
|
public function buttonIcon()
|
|
{
|
|
return 'apple';
|
|
}
|
|
|
|
/**
|
|
* Get button text
|
|
*
|
|
* @return string
|
|
*/
|
|
public function buttonText()
|
|
{
|
|
return 'login_apple';
|
|
}
|
|
|
|
/**
|
|
* Get button class
|
|
*
|
|
* @return string
|
|
*/
|
|
public function buttonClass()
|
|
{
|
|
return 'ipsSocial_apple';
|
|
}
|
|
|
|
/**
|
|
* Get logo to display in information about logins with this method
|
|
* Returns NULL for methods where it is not necessary to indicate the method, e..g Standard
|
|
*
|
|
* @return \IPS\Http\Url
|
|
*/
|
|
public function logoForDeviceInformation()
|
|
{
|
|
return \IPS\Theme::i()->resource( 'logos/login/Apple.png', 'core', 'interface' );
|
|
}
|
|
|
|
/**
|
|
* Grant Type
|
|
*
|
|
* @return string
|
|
*/
|
|
protected function grantType()
|
|
{
|
|
return 'authorization_code';
|
|
}
|
|
|
|
/**
|
|
* Get scopes to request
|
|
*
|
|
* @param array|NULL $additional Any additional scopes to request
|
|
* @return array
|
|
*/
|
|
protected function scopesToRequest( $additional=NULL )
|
|
{
|
|
$return = array( 'name email' );
|
|
|
|
return $return;
|
|
}
|
|
|
|
/**
|
|
* Authorization Endpoint
|
|
*
|
|
* @param \IPS\Login $login The login object
|
|
* @return \IPS\Http\Url
|
|
*/
|
|
protected function authorizationEndpoint( \IPS\Login $login )
|
|
{
|
|
$return = \IPS\Http\Url::external( 'https://appleid.apple.com/auth/authorize' )->setQueryString( 'response_mode', 'form_post' );
|
|
|
|
return $return;
|
|
}
|
|
|
|
/**
|
|
* Token Endpoint
|
|
*
|
|
* @return \IPS\Http\Url
|
|
*/
|
|
protected function tokenEndpoint()
|
|
{
|
|
return \IPS\Http\Url::external( 'https://appleid.apple.com/auth/token' );
|
|
}
|
|
|
|
/**
|
|
* Get authenticated user's identifier (may not be a number)
|
|
*
|
|
* @param string $accessToken Access Token
|
|
* @return string
|
|
*/
|
|
protected function authenticatedUserId( $accessToken )
|
|
{
|
|
$claims = $this->getClaimsfromIdToken( $this->_getIdToken( $accessToken ) );
|
|
|
|
return ( isset( $claims['sub'] ) ) ? $claims['sub'] : NULL;
|
|
}
|
|
|
|
/**
|
|
* Get authenticated user's username
|
|
* May return NULL if server doesn't support this
|
|
*
|
|
* @param string $accessToken Access Token
|
|
* @return string|NULL
|
|
*/
|
|
protected function authenticatedUserName( $accessToken )
|
|
{
|
|
$name = NULL;
|
|
|
|
if( isset( $_SESSION['oauth_user'] ) )
|
|
{
|
|
$session = json_decode( $_SESSION['oauth_user'], true );
|
|
|
|
if( isset( $session['name'] ) )
|
|
{
|
|
$name = implode( " ", $session['name'] );
|
|
}
|
|
}
|
|
|
|
return $name;
|
|
}
|
|
|
|
/**
|
|
* Get authenticated user's email address
|
|
* May return NULL if server doesn't support this
|
|
*
|
|
* @param string $accessToken Access Token
|
|
* @return string|NULL
|
|
*/
|
|
protected function authenticatedEmail( $accessToken )
|
|
{
|
|
$claims = $this->getClaimsfromIdToken( $this->_getIdToken( $accessToken ) );
|
|
|
|
return ( isset( $claims['email'] ) ) ? $claims['email'] : NULL;
|
|
}
|
|
|
|
/**
|
|
* Client Secret
|
|
*
|
|
* @return string | NULL
|
|
* @throws \IPS\Login\Exception
|
|
*/
|
|
public function clientSecret()
|
|
{
|
|
$key = NULL;
|
|
|
|
if ( isset( $this->settings['apple_key'] ) and $this->settings['apple_key'] )
|
|
{
|
|
try
|
|
{
|
|
$key = \IPS\File::get( 'core_Login', $this->settings['apple_key'] )->contents();
|
|
}
|
|
catch ( \Exception $e )
|
|
{
|
|
return NULL;
|
|
}
|
|
}
|
|
|
|
if( !$key )
|
|
{
|
|
return NULL;
|
|
}
|
|
|
|
$kid = $this->settings['apple_key_id'];
|
|
$iss = $this->settings['apple_team_id'];
|
|
$sub = $this->settings['client_id'];
|
|
|
|
$header = array(
|
|
'alg' => 'ES256',
|
|
'kid' => $kid
|
|
);
|
|
|
|
$data = array(
|
|
'iss' => $iss,
|
|
'iat' => time(),
|
|
'exp' => time() + 3600,
|
|
'aud' => 'https://appleid.apple.com',
|
|
'sub' => $sub
|
|
);
|
|
|
|
$pKey = openssl_pkey_get_private( $key );
|
|
|
|
if ( !$pKey )
|
|
{
|
|
throw new \IPS\Login\Exception( 'login_apple_invalid_key', \IPS\Login\Exception::INTERNAL_ERROR );
|
|
}
|
|
|
|
$payload = $this->baseURL64encode( json_encode( $header ) ) . '.' . $this->baseURL64encode( json_encode( $data ) );
|
|
|
|
$signature = '';
|
|
$success = openssl_sign( $payload, $signature, $pKey, OPENSSL_ALGO_SHA256 );
|
|
if ( !$success )
|
|
{
|
|
throw new \IPS\Login\Exception( 'generic_error', \IPS\Login\Exception::INTERNAL_ERROR );
|
|
}
|
|
|
|
$rawSignature = $this->fromDER( $signature, 64 );
|
|
|
|
return $payload. '.' . $this->baseURL64encode( $rawSignature );
|
|
}
|
|
|
|
/**
|
|
* Syncing Options
|
|
*
|
|
* @param \IPS\Member $member The member we're asking for (can be used to not show certain options iof the user didn't grant those scopes)
|
|
* @param bool $defaultOnly If TRUE, only returns which options should be enabled by default for a new account
|
|
* @return array
|
|
*/
|
|
public function syncOptions( \IPS\Member $member, $defaultOnly = FALSE )
|
|
{
|
|
$return = array();
|
|
$scopes = $this->authorizedScopes( $member );
|
|
|
|
if ( ( !isset( $this->settings['update_email_changes'] ) or $this->settings['update_email_changes'] === 'optional' ) and ( $scopes and \in_array( 'email', $scopes ) ) )
|
|
{
|
|
$return[] = 'email';
|
|
}
|
|
|
|
return $return;
|
|
}
|
|
|
|
/**
|
|
* Process an ID Token
|
|
*
|
|
* @param array $iDToken ID Token
|
|
* @return array
|
|
*/
|
|
protected function getClaimsfromIdToken ( $iDToken )
|
|
{
|
|
$claims = explode( '.', $iDToken )[1];
|
|
$claims = json_decode( base64_decode( $claims ), true );
|
|
|
|
return $claims;
|
|
}
|
|
|
|
/**
|
|
* Convert Key From DER
|
|
*
|
|
* @param string $der
|
|
* @param int $partLength
|
|
*
|
|
* @return string
|
|
* @throws \IPS\Login\Exception
|
|
*/
|
|
public static function fromDER( string $der, int $partLength )
|
|
{
|
|
$hex = unpack( 'H*', $der )[1];
|
|
|
|
if ( '30' !== mb_substr( $hex, 0, 2, '8bit' ) )
|
|
{
|
|
throw new \IPS\Login\Exception( 'generic_error', \IPS\Login\Exception::INTERNAL_ERROR );
|
|
}
|
|
|
|
if ( '81' === mb_substr( $hex, 2, 2, '8bit' ) )
|
|
{
|
|
$hex = mb_substr( $hex, 6, null, '8bit' );
|
|
}
|
|
else
|
|
{
|
|
$hex = mb_substr( $hex, 4, null, '8bit' );
|
|
}
|
|
|
|
if ( '02' !== mb_substr( $hex, 0, 2, '8bit' ) )
|
|
{
|
|
throw new \IPS\Login\Exception( 'generic_error', \IPS\Login\Exception::INTERNAL_ERROR );
|
|
}
|
|
|
|
$Rl = hexdec( mb_substr( $hex, 2, 2, '8bit' ) );
|
|
$R = self::retrievePositiveInteger( mb_substr( $hex, 4, $Rl * 2, '8bit' ) );
|
|
$R = str_pad( $R, $partLength, '0', STR_PAD_LEFT );
|
|
$hex = mb_substr( $hex, 4 + $Rl * 2, null, '8bit' );
|
|
|
|
if ( '02' !== mb_substr( $hex, 0, 2, '8bit' ) )
|
|
{
|
|
throw new \IPS\Login\Exception( 'generic_error', \IPS\Login\Exception::INTERNAL_ERROR );
|
|
}
|
|
|
|
$Sl = hexdec( mb_substr( $hex, 2, 2, '8bit' ) );
|
|
$S = self::retrievePositiveInteger( mb_substr( $hex, 4, $Sl * 2, '8bit' ) );
|
|
$S = str_pad( $S, $partLength, '0', STR_PAD_LEFT );
|
|
|
|
return pack( 'H*', $R . $S );
|
|
}
|
|
|
|
/**
|
|
* Prepare Integer
|
|
*
|
|
* @param string $data
|
|
* @return string
|
|
*/
|
|
protected static function preparePositiveInteger( string $data )
|
|
{
|
|
if ( mb_substr( $data, 0, 2, '8bit') > '7f' )
|
|
{
|
|
return '00' . $data;
|
|
}
|
|
while ( '00' === mb_substr( $data, 0, 2, '8bit' ) && mb_substr( $data, 2, 2, '8bit' ) <= '7f' )
|
|
{
|
|
$data = mb_substr( $data, 2, null, '8bit' );
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Retrieve Integer
|
|
*
|
|
* @param string $data
|
|
* @return string
|
|
*/
|
|
protected static function retrievePositiveInteger( string $data )
|
|
{
|
|
while ( '00' === mb_substr( $data, 0, 2, '8bit' ) && mb_substr( $data, 2, 2, '8bit' ) > '7f' )
|
|
{
|
|
$data = mb_substr( $data, 2, null, '8bit' );
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Encode text in baseurl64 format
|
|
*
|
|
* @param string $data
|
|
* @return string
|
|
*/
|
|
protected function baseURL64encode( $data )
|
|
{
|
|
$encoded = strtr( base64_encode( $data ), '+/', '-_' );
|
|
|
|
return rtrim( $encoded, '=' );
|
|
}
|
|
|
|
/**
|
|
* Delete files
|
|
*
|
|
* @return void
|
|
*/
|
|
public function delete()
|
|
{
|
|
if ( isset( $this->settings['apple_key'] ) )
|
|
{
|
|
try
|
|
{
|
|
\IPS\File::get( 'core_Login', $this->settings['apple_key'] )->delete();
|
|
}
|
|
catch( \Exception $e ){}
|
|
}
|
|
|
|
parent::delete();
|
|
}
|
|
} |