Invision Power Services, Inc. * @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(); } }