Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @since 29 Apr 2017 */ use IPS\Api\OAuthClient; use IPS\Db; use IPS\Login; use IPS\Member; use IPS\Member\Device; use IPS\Output; use IPS\Request; use const IPS\OAUTH_REQUIRES_HTTPS; define('REPORT_EXCEPTIONS', TRUE); require '../../init.php'; class oAuthServerTokenRequest { /** * @brief Client */ public $client; /** * Init * * @param string $clientId Client ID * @param string $clientSecret Client Secret * @return void */ public static function init( $clientId, $clientSecret ) { $obj = new static; /* Get the client */ try { $obj->client = OAuthClient::load( $clientId ); if ( !$obj->client->enabled ) { throw new OutOfRangeException; } } catch (OutOfRangeException $e ) { throw new \IPS\Login\Handler\OAuth2\Exception( 'invalid_client' ); } /* Validate the secret */ if ( $obj->client->client_secret ) { $bruteForce = $obj->client->brute_force ? json_decode( $obj->client->brute_force, TRUE ) : array(); if ( isset( $bruteForce[ Request::i()->ipAddress() ] ) and $bruteForce[ Request::i()->ipAddress() ] >= 3 ) { throw new \IPS\Login\Handler\OAuth2\Exception( 'invalid_client', "blocked for too many authentication failures" ); } if ( password_verify( $clientSecret, $obj->client->client_secret ) ) { if ( isset( $bruteForce[ Request::i()->ipAddress() ] ) ) { unset( $bruteForce[ Request::i()->ipAddress() ] ); $obj->client->brute_force = json_encode( $bruteForce ); $obj->client->save(); } } else { if ( !isset( $bruteForce[ Request::i()->ipAddress() ] ) ) { $bruteForce[ Request::i()->ipAddress() ] = 0; } $bruteForce[ Request::i()->ipAddress() ]++; $obj->client->brute_force = json_encode( $bruteForce ); $obj->client->save(); throw new \IPS\Login\Handler\OAuth2\Exception( 'invalid_client' ); } } return $obj; } /** * Validate the grant type * * @param string $grantType The Authorization Code * @return string */ public function grantType( $grantType ) { if ( !in_array( $grantType, array( 'authorization_code', 'implicit', 'client_credentials', 'password', 'refresh_token' ) ) ) { throw new \IPS\Login\Handler\OAuth2\Exception( 'unsupported_grant_type' ); } if ( $grantType === 'refresh_token' ) { if ( !$this->client->use_refresh_tokens ) { throw new \IPS\Login\Handler\OAuth2\Exception( 'unsupported_grant_type' ); } } elseif ( !in_array( $grantType, explode( ',', $this->client->grant_types ) ) ) { throw new \IPS\Login\Handler\OAuth2\Exception( 'unauthorized_client' ); } return $grantType; } /** * Validate Authorization Code * * @param string $authorizationCode The Authorization Code * @param string|NULL $redirectUri The redirect URI, if provided * @param string|NULL $codeVerifier The code verifier, if provided * @return array|NULL */ public function validateAuthorizationCode( $authorizationCode, $redirectUri = NULL, $codeVerifier = NULL ) { try { $authorizationCode = Db::i()->select( '*', 'core_oauth_server_authorization_codes', array( 'client_id=? AND code=?', $this->client->client_id, $authorizationCode ) )->first(); /* If it's expired, delete it and do not validate */ if ( $authorizationCode['expires'] < time() ) { Db::i()->delete( 'core_oauth_server_authorization_codes', array( 'client_id=? AND code=?', $authorizationCode['client_id'], $authorizationCode['code'] ) ); return; } /* If it's already been used, this should be treated as an attack: revoke any access tokens already generated and do not validate */ if ( $authorizationCode['used'] ) { Db::i()->update( 'core_oauth_server_access_tokens', array( 'status' => 'revoked' ), array( 'client_id=? AND authorization_code=?', $this->client->client_id, $authorizationCode['code'] ) ); return; } /* If the redirect URI does not match, do not validate */ if ( $redirectUri !== $authorizationCode['redirect_uri'] ) { return; } /* If it has a code verifier, validate that */ if ( $authorizationCode['code_challenge'] or $this->client->pkce !== 'none' ) { if ( !$codeVerifier or !$authorizationCode['code_challenge'] ) { return; } if ( $authorizationCode['code_challenge_method'] === 'S256' or $this->client->pkce === 'S256' ) { $codeVerifier = rtrim( strtr( base64_encode( pack( 'H*', hash( 'sha256', $codeVerifier ) ) ), '+/', '-_' ), '=' ); } if ( !Login::compareHashes( $authorizationCode['code_challenge'], $codeVerifier ) ) { return; } } /* Check we're not banned and not validating */ $member = Member::load( $authorizationCode['member_id'] ); if ( $member->isBanned() or $member->members_bitoptions['validating'] ) { return; } /* Mark it used */ Db::i()->update( 'core_oauth_server_authorization_codes', array( 'used' => 1 ), array( 'client_id=? AND code=?', $authorizationCode['client_id'], $authorizationCode['code'] ) ); /* Return access token */ try { $device = $authorizationCode['device_key'] ? Device::load( $authorizationCode['device_key'] ) : NULL; } catch (UnderflowException $e ) { $device = NULL; } return $this->client->generateAccessToken( $member, $authorizationCode['scope'] ? json_decode( $authorizationCode['scope'] ) : NULL, 'authorization_code', FALSE, $authorizationCode['code'], $authorizationCode['user_agent'], isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : NULL, $device ); } catch (UnderflowException $e ) { return; } } /** * Validate Password * * @param string $username Username * @param object $password The plaintext password provided by the user, wrapped in an object that can be cast to a string so it doesn't show in any logs * @param array|null $scope Scopes * @return array|NULL */ public function validatePassword( $username, $password, $scope ) { $success = FALSE; $accessToken = NULL; $fails = []; $login = new Login(); foreach ( $login->usernamePasswordMethods() as $method ) { try { if( $member = $method->authenticateUsernamePassword( $login, $username, $password ) ) { $success = TRUE; } Login::checkIfAccountIsLocked( $member, TRUE ); if ( !$member->isBanned() and !$member->members_bitoptions['validating'] ) { $accessToken = $this->client->generateAccessToken( $member, $scope, 'password', FALSE, NULL, isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : NULL, isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : NULL, NULL ); } break; } catch ( \IPS\Login\Exception $e ) { if ( $e->getCode() === \IPS\Login\Exception::BAD_PASSWORD and $e->member ) { $fails[ $e->member->member_id ] = $e->member; } } catch ( Exception $e ) { } } /* Record any fails if none of the sign-ins were successful */ if( $success === TRUE ) { foreach ( $fails as $failedMember ) { $failedMember->failedLogin(); } } return $accessToken; } /** * Validate Client Credentials * * @param array|null $scope Scopes * @return array|NULL */ public function validateClientCredentials( $scope ) { if ( $this->client->client_secret ) { return $this->client->generateAccessToken( NULL, $scope, 'client_credentials', TRUE, NULL, NULL, isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : NULL ); } } /** * Validate Refresh Token * * @param string $refreshToken The refresh token * @param array|null $scope Scopes * @return array|NULL */ public function validateRefreshToken( $refreshToken, $newScope ) { $accessToken = $this->client->validateRefreshToken( $refreshToken ); if ( $accessToken ) { $member = NULL; if ( $accessToken['member_id'] ) { $member = Member::load( $accessToken['member_id'] ); if ( !$member->member_id or $member->isBanned() or $member->members_bitoptions['validating'] ) { return; } } $originalScope = $accessToken['scope'] ? json_decode( $accessToken['scope'], TRUE ) : array(); $scope = $newScope ? array_intersect( $originalScope, $newScope ) : $originalScope; try { $device = $accessToken['device_key'] ? Device::load( $accessToken['device_key'] ) : NULL; } catch (UnderflowException $e ) { $device = NULL; } return $this->client->generateAccessToken( $member, $scope, 'refresh_token', FALSE, NULL, $accessToken['auth_user_agent'], isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : NULL, $device, $accessToken ); } } } /* Handle it */ try { /* TLS only */ if ( OAUTH_REQUIRES_HTTPS and !Request::i()->isSecure() ) { throw new \IPS\Login\Handler\OAuth2\Exception( 'invalid_request', "request must be made with https" ); } /* POST only */ if ( Request::i()->requestMethod() !== 'POST' ) { throw new \IPS\Login\Handler\OAuth2\Exception( 'invalid_request', "request must be a POST request" ); } /* Check we are not IP banned */ $ipBanned = Request::i()->ipAddressIsBanned(); if ( $ipBanned ) { throw new \IPS\Login\Handler\OAuth2\Exception( 'invalid_client', "IP Address banned" ); } /* Get the client id and secret */ $clientId = NULL; $clientSecret = NULL; if ( isset( $_POST['client_id'] ) ) { $clientId = $_POST['client_id']; $clientSecret = isset( $_POST['client_secret'] ) ? $_POST['client_secret'] : NULL; } elseif ( isset( $_SERVER['PHP_AUTH_USER'] ) ) { $clientId = $_SERVER['PHP_AUTH_USER']; $clientSecret = isset( $_SERVER['PHP_AUTH_PW'] ) ? $_SERVER['PHP_AUTH_PW'] : NULL; } else { foreach ( $_SERVER as $k => $v ) { if ( mb_substr( $k, -18 ) == 'HTTP_AUTHORIZATION' ) { $exploded = explode( ':', base64_decode( mb_substr( $v, 6 ) ) ); if ( isset( $exploded[0] ) and isset( $exploded[1] ) ) { $clientId = $exploded[0]; $clientSecret = isset( $exploded[1] ) ? $exploded[1] : NULL; } } } } /* Initiate request */ $request = oAuthServerTokenRequest::init( $clientId, $clientSecret ); /* Validate grant */ $accessToken = NULL; switch ( $request->grantType( Request::i()->grant_type ) ) { case 'authorization_code': $accessToken = $request->validateAuthorizationCode( Request::i()->code, Request::i()->redirect_uri, isset( Request::i()->code_verifier ) ? Request::i()->code_verifier : NULL ); break; case 'password': $accessToken = $request->validatePassword( Request::i()->username, Request::i()->protect('password'), isset( Request::i()->scope ) ? explode( ' ', Request::i()->scope ) : NULL ); break; case 'client_credentials': $accessToken = $request->validateClientCredentials( isset( Request::i()->scope ) ? explode( ' ', Request::i()->scope ) : NULL ); break; case 'refresh_token': $accessToken = $request->validateRefreshToken( Request::i()->refresh_token, isset( Request::i()->scope ) ? explode( ' ', Request::i()->scope ) : NULL ); break; } /* Return */ if ( $accessToken ) { $response = array( 'access_token' => $accessToken['access_token'], 'token_type' => 'bearer' ); if ( $accessToken['access_token_expires'] ) { $response['expires_in'] = $accessToken['access_token_expires'] - time(); } if ( $accessToken['refresh_token'] ) { $response['refresh_token'] = $accessToken['refresh_token']; } if ( $accessToken['scope'] ) { $response['scope'] = implode( ' ', json_decode( $accessToken['scope'], TRUE ) ); } Output::i()->sendOutput( json_encode( $response ), 200, 'application/json', array( 'Cache-Control' => 'no-cache, no-store, must-revalidate, max-age=0, s-maxage=0', 'Pragma' => 'no-cache' ), FALSE, FALSE, FALSE ); } else { throw new \IPS\Login\Handler\OAuth2\Exception( 'invalid_grant', 400 ); } } catch ( \IPS\Login\Handler\OAuth2\Exception $e ) { $response = array( 'error' => $e->getMessage() ); if ( $e->description ) { $response['error_description'] = $e->description; } Output::i()->sendOutput( json_encode( $response ), $e->getMessage() === 'invalid_client' ? 401 : 400, 'application/json', array( 'Cache-Control' => 'no-cache, no-store, must-revalidate, max-age=0, s-maxage=0' ), FALSE, FALSE, FALSE ); } catch ( Exception $e ) { Output::i()->sendOutput( json_encode( array( 'error' => 'server_error', 'error_description' => $e->getMessage() ) ), 500, 'application/json', array( 'Cache-Control' => 'no-cache, no-store, must-revalidate, max-age=0, s-maxage=0' ), FALSE, FALSE, FALSE ); }