From f38a129ad7896a88ffa62223836c387cd9ad549b Mon Sep 17 00:00:00 2001 From: micromagicman Date: Mon, 15 Apr 2024 06:41:40 +0300 Subject: [PATCH] #5 auth_date expiration validation, webAppConfig helper function --- README.md | 15 ++--- composer.lock | 60 ++++++++++---------- config/telegram-webapp.php | 10 ++++ src/Http/WebAppDataValidationMiddleware.php | 5 +- src/Service/TelegramWebAppService.php | 61 ++++++++++++++++++--- src/Util/{CryptoUtils.php => Crypto.php} | 7 ++- src/Util/Time.php | 26 +++++++++ src/helpers.php | 13 ++++- tests/TelegramWebAppServiceProviderTest.php | 53 ++++++++++++++++++ workbench/routes/api.php | 1 - 10 files changed, 200 insertions(+), 51 deletions(-) rename src/Util/{CryptoUtils.php => Crypto.php} (72%) create mode 100644 src/Util/Time.php diff --git a/README.md b/README.md index e9b9548..ac2a739 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,14 @@ php artisan vendor:publish --provider="Micromagicman\TelegramWebApp\TelegramWebA All package configuration available in `config/telegram-webapp.php` file after `publish` command execution: -| Config name | Description | Environment | Default value | -|------------------------|------------------------------------------------------------------------------|-------------------------------------------|-----------------------------------------------| -| `enabled` | Telegram MiniApp data validation switch | `TELEGRAM_WEBAPP_DATA_VALIDATION_ENABLED` | `true` | -| `webAppScriptLocation` | Path to script (.js) which initializes Telegram MiniApp on your frontend app | - | `https://telegram.org/js/telegram-web-app.js` | -| `botToken` | Your Telegram bot token | `TELEGRAM_BOT_TOKEN` | - | -| `error.status` | HTTP status code when Telegram MiniApp data validation fails | - | 403 (Forbidden) | -| `error.message` | HTTP status code when Telegram MiniApp data validation fails | - | 403 (Forbidden) | +| Config name | Description | Environment | Default value | +|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------|-----------------------------------------------| +| `enabled` | Telegram MiniApp data validation switch | `TELEGRAM_WEBAPP_DATA_VALIDATION_ENABLED` | `true` | +| `webAppScriptLocation` | Path to script (.js) which initializes Telegram MiniApp on your frontend app | - | `https://telegram.org/js/telegram-web-app.js` | +| `botToken` | Your Telegram bot token | `TELEGRAM_BOT_TOKEN` | - | +| `error.status` | HTTP status code when Telegram MiniApp data validation fails | - | 403 (Forbidden) | +| `error.message` | Error message returned when Telegram MiniApp data validation fails | - | 403 (Forbidden) | +| `authDateLifetimeSeconds` | The lifetime of the Telegram initData auth_date parameter in seconds. The request to the server must be made within this interval, otherwise the data transmitted from Telegram will be considered invalid. The values of the parameter <= 0 imply that there is no verification of the lifetime of data from telegram and the auth_date parameter is not validated | - | 0 | Example in code: diff --git a/composer.lock b/composer.lock index 4462bc5..a6c42ef 100644 --- a/composer.lock +++ b/composer.lock @@ -1047,16 +1047,16 @@ }, { "name": "laravel/framework", - "version": "v11.2.0", + "version": "v11.3.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "a1750156b671f37cba702380107e2d22161c31e3" + "reference": "3b87d0767e9cbddec46480d883010ba720e50dea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/a1750156b671f37cba702380107e2d22161c31e3", - "reference": "a1750156b671f37cba702380107e2d22161c31e3", + "url": "https://api.github.com/repos/laravel/framework/zipball/3b87d0767e9cbddec46480d883010ba720e50dea", + "reference": "3b87d0767e9cbddec46480d883010ba720e50dea", "shasum": "" }, "require": { @@ -1248,20 +1248,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2024-04-02T14:01:33+00:00" + "time": "2024-04-10T15:13:49+00:00" }, { "name": "laravel/prompts", - "version": "v0.1.17", + "version": "v0.1.18", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "8ee9f87f7f9eadcbe21e9e72cd4176b2f06cd5b5" + "reference": "3b5e6b03f1f1175574b5a32331d99c9819da9848" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/8ee9f87f7f9eadcbe21e9e72cd4176b2f06cd5b5", - "reference": "8ee9f87f7f9eadcbe21e9e72cd4176b2f06cd5b5", + "url": "https://api.github.com/repos/laravel/prompts/zipball/3b5e6b03f1f1175574b5a32331d99c9819da9848", + "reference": "3b5e6b03f1f1175574b5a32331d99c9819da9848", "shasum": "" }, "require": { @@ -1303,9 +1303,9 @@ ], "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.1.17" + "source": "https://github.com/laravel/prompts/tree/v0.1.18" }, - "time": "2024-03-13T16:05:43+00:00" + "time": "2024-04-04T17:41:50+00:00" }, { "name": "laravel/serializable-closure", @@ -1762,16 +1762,16 @@ }, { "name": "monolog/monolog", - "version": "3.5.0", + "version": "3.6.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "c915e2634718dbc8a4a15c61b0e62e7a44e14448" + "reference": "4b18b21a5527a3d5ffdac2fd35d3ab25a9597654" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/c915e2634718dbc8a4a15c61b0e62e7a44e14448", - "reference": "c915e2634718dbc8a4a15c61b0e62e7a44e14448", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/4b18b21a5527a3d5ffdac2fd35d3ab25a9597654", + "reference": "4b18b21a5527a3d5ffdac2fd35d3ab25a9597654", "shasum": "" }, "require": { @@ -1794,7 +1794,7 @@ "phpstan/phpstan": "^1.9", "phpstan/phpstan-deprecation-rules": "^1.0", "phpstan/phpstan-strict-rules": "^1.4", - "phpunit/phpunit": "^10.1", + "phpunit/phpunit": "^10.5.17", "predis/predis": "^1.1 || ^2", "ruflin/elastica": "^7", "symfony/mailer": "^5.4 || ^6", @@ -1847,7 +1847,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.5.0" + "source": "https://github.com/Seldaek/monolog/tree/3.6.0" }, "funding": [ { @@ -1859,7 +1859,7 @@ "type": "tidelift" } ], - "time": "2023-10-27T15:32:31+00:00" + "time": "2024-04-12T21:02:21+00:00" }, { "name": "nesbot/carbon", @@ -6312,16 +6312,16 @@ }, { "name": "orchestra/testbench-core", - "version": "v9.0.11", + "version": "v9.0.12", "source": { "type": "git", "url": "https://github.com/orchestral/testbench-core.git", - "reference": "d8422871876a729ce243503e1ce007195eb0407b" + "reference": "1ede9aaaf809b9ff6afecaf420cc336b543d11e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/testbench-core/zipball/d8422871876a729ce243503e1ce007195eb0407b", - "reference": "d8422871876a729ce243503e1ce007195eb0407b", + "url": "https://api.github.com/repos/orchestral/testbench-core/zipball/1ede9aaaf809b9ff6afecaf420cc336b543d11e1", + "reference": "1ede9aaaf809b9ff6afecaf420cc336b543d11e1", "shasum": "" }, "require": { @@ -6397,7 +6397,7 @@ "issues": "https://github.com/orchestral/testbench/issues", "source": "https://github.com/orchestral/testbench-core" }, - "time": "2024-04-08T09:56:42+00:00" + "time": "2024-04-13T08:45:27+00:00" }, { "name": "orchestra/workbench", @@ -8245,16 +8245,16 @@ }, { "name": "spatie/laravel-ray", - "version": "1.36.0", + "version": "1.36.1", "source": { "type": "git", "url": "https://github.com/spatie/laravel-ray.git", - "reference": "f15936b5d308ae391ee67370a5628f0712537c34" + "reference": "799eb881d5ede337f373b5fe9722c92b787890f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/f15936b5d308ae391ee67370a5628f0712537c34", - "reference": "f15936b5d308ae391ee67370a5628f0712537c34", + "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/799eb881d5ede337f373b5fe9722c92b787890f4", + "reference": "799eb881d5ede337f373b5fe9722c92b787890f4", "shasum": "" }, "require": { @@ -8283,7 +8283,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.29.x-dev" + "dev-main": "1.x-dev" }, "laravel": { "providers": [ @@ -8316,7 +8316,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-ray/issues", - "source": "https://github.com/spatie/laravel-ray/tree/1.36.0" + "source": "https://github.com/spatie/laravel-ray/tree/1.36.1" }, "funding": [ { @@ -8328,7 +8328,7 @@ "type": "other" } ], - "time": "2024-03-29T09:10:11+00:00" + "time": "2024-04-12T12:15:59+00:00" }, { "name": "spatie/macroable", diff --git a/config/telegram-webapp.php b/config/telegram-webapp.php index c47f789..a4d9203 100644 --- a/config/telegram-webapp.php +++ b/config/telegram-webapp.php @@ -23,6 +23,16 @@ */ 'botToken' => env( 'TELEGRAM_BOT_TOKEN', '' ), + /* + |-------------------------------------------------------------------------- + | The lifetime of the {@link https://core.telegram.org/bots/webapps#webappinitdata Telegram initData} auth_date parameter in seconds. + | The request to the server must be made within this interval, otherwise the data transmitted from Telegram + | will be considered invalid. The values of the parameter <= 0 imply that there is no verification of the lifetime + | of data from telegram and the auth_date parameter is not validated. + |-------------------------------------------------------------------------- + */ + 'authDateLifetimeSeconds' => 0, + /* |-------------------------------------------------------------------------- | HTTP error response format options diff --git a/src/Http/WebAppDataValidationMiddleware.php b/src/Http/WebAppDataValidationMiddleware.php index d33d4d3..ed86c01 100644 --- a/src/Http/WebAppDataValidationMiddleware.php +++ b/src/Http/WebAppDataValidationMiddleware.php @@ -10,10 +10,9 @@ /** * Middleware that provides a mechanism for validating Telegram MiniApp users */ -class WebAppDataValidationMiddleware +readonly class WebAppDataValidationMiddleware { - public function __construct( - private readonly TelegramWebAppService $webAppService ) {} + public function __construct( private TelegramWebAppService $webAppService ) {} public function handle( Request $request, Closure $next ) { diff --git a/src/Service/TelegramWebAppService.php b/src/Service/TelegramWebAppService.php index d06cc9b..6491e46 100644 --- a/src/Service/TelegramWebAppService.php +++ b/src/Service/TelegramWebAppService.php @@ -5,7 +5,8 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Micromagicman\TelegramWebApp\Dto\TelegramUser; -use Micromagicman\TelegramWebApp\Util\CryptoUtils; +use Micromagicman\TelegramWebApp\Util\Crypto; +use Micromagicman\TelegramWebApp\Util\Time; /** * Telegram MiniApp service functions @@ -22,15 +23,30 @@ class TelegramWebAppService */ private const USER_QUERY_PARAMETER_KEY = 'user'; + /** + * A name of auth_date query parameter from Telegram WebApp + */ + private const AUTH_DATE_QUERY_PARAMETER_KEY = 'auth_date'; + /** * A key for hashing a key that will be used to calculate the hash from the data received via Telegram MiniApp */ private const SHA256_TOKEN_HASH_KEY = 'WebAppData'; + /** + * Default {@link https://core.telegram.org/bots/webapps#webappinitdata Telegram initData} auth_date lifetime + * (seconds) + */ + private const DEFAULT_AUTH_DATE_LIFETIME = 0; + + public function __construct( + private readonly Crypto $crypto, + private readonly Time $time ) {} + public function abortWithError( array $errorMessageParams = [] ): void { - $errorMessage = config( 'telegram-webapp.error.message' ); - $statusCode = config( 'telegram-webapp.error.status' ); + $errorMessage = $this->config( 'error.message' ); + $statusCode = $this->config( 'error.status' ); abort( $statusCode, __( $errorMessage, $errorMessageParams ) ); } @@ -41,7 +57,7 @@ public function abortWithError( array $errorMessageParams = [] ): void public function verifyInitData( ?Request $request = null ): bool { $queryParams = $request->query(); - if ( !array_key_exists( self::HASH_QUERY_PARAMETER_KEY, $queryParams ) ) { + if ( !$this->telegramInitDataValid( $queryParams ) ) { return false; } $requestHash = $queryParams[ self::HASH_QUERY_PARAMETER_KEY ]; @@ -66,6 +82,14 @@ public function getWebAppUser( ?Request $request = null ): ?TelegramUser return new TelegramUser( $telegramUserData ); } + /** + * Receive configuration value by the key with plugin prefix + */ + public function config( string $key, mixed $defaultValue = null ) + { + return config( "telegram-webapp.$key", $defaultValue ); + } + /** * Verify integrity of the data received by comparing the received hash parameter with the hexadecimal * representation of the HMAC-SHA-256 signature of the data-check-string with the secret key, which is the @@ -73,8 +97,8 @@ public function getWebAppUser( ?Request $request = null ): ?TelegramUser */ private function createHashFromQueryString( array $queryParams ): string { - $telegramBotToken = config( 'telegram-webapp.botToken' ); - $dataDigestKey = CryptoUtils::hmacSHA256( $telegramBotToken, self::SHA256_TOKEN_HASH_KEY, true ); + $telegramBotToken = $this->config( 'botToken' ); + $dataDigestKey = $this->crypto->hmacSHA256( $telegramBotToken, self::SHA256_TOKEN_HASH_KEY, true ); $dataWithoutHash = array_filter( $queryParams, fn( $key ) => $key !== self::HASH_QUERY_PARAMETER_KEY, @@ -89,6 +113,29 @@ private function createHashFromQueryString( array $queryParams ): string $dataWithoutHash ) ); - return CryptoUtils::hmacSHA256( $dataCheckString, $dataDigestKey ); + return $this->crypto->hmacSHA256( $dataCheckString, $dataDigestKey ); + } + + /** + * Checking that {@link https://core.telegram.org/bots/webapps#webappinitdata Telegram initData} is valid + */ + private function telegramInitDataValid( array $telegramInitData ): bool + { + return array_key_exists( self::USER_QUERY_PARAMETER_KEY, $telegramInitData ) + && !$this->authDateExpired( $telegramInitData[ self::AUTH_DATE_QUERY_PARAMETER_KEY ] ); + } + + /** + * Checking that Telegram {@link https://core.telegram.org/bots/webapps#webappinitdata auth_date} parameter has expired + * The lifetime of {@link https://core.telegram.org/bots/webapps#webappinitdata Telegram initData} + * is set by the telegram-webapp.authDateLifetimeSeconds parameter + */ + private function authDateExpired( int $authDate ): bool + { + $authDateLifetime = $this->config( 'authDateLifetimeSeconds', self::DEFAULT_AUTH_DATE_LIFETIME ); + if ( $authDateLifetime <= self::DEFAULT_AUTH_DATE_LIFETIME ) { + return false; + } + return $this->time->expired( $authDate + $authDateLifetime ); } } \ No newline at end of file diff --git a/src/Util/CryptoUtils.php b/src/Util/Crypto.php similarity index 72% rename from src/Util/CryptoUtils.php rename to src/Util/Crypto.php index af31936..f67b48e 100644 --- a/src/Util/CryptoUtils.php +++ b/src/Util/Crypto.php @@ -2,7 +2,10 @@ namespace Micromagicman\TelegramWebApp\Util; -class CryptoUtils +/** + * Service with util methods for cryptography + */ +class Crypto { /** * SHA256 hashing algorithm name for {@link hash_hmac} function @@ -13,7 +16,7 @@ class CryptoUtils * Generate a SHA256 hash value * @param bool $binary - When set to true, outputs raw binary data. false outputs lowercase hexits */ - public static function hmacSHA256( string $plainText, string $key, bool $binary = false ): string + public function hmacSHA256( string $plainText, string $key, bool $binary = false ): string { return hash_hmac( self::SHA_256_ALGORITHM, $plainText, $key, $binary ); } diff --git a/src/Util/Time.php b/src/Util/Time.php new file mode 100644 index 0000000..97d4105 --- /dev/null +++ b/src/Util/Time.php @@ -0,0 +1,26 @@ +now(); + } +} \ No newline at end of file diff --git a/src/helpers.php b/src/helpers.php index d2971cd..050b083 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -1,7 +1,7 @@ getWebAppUser(); } +} + +if ( !function_exists( 'webAppConfig' ) ) { + + /** + * Get Telegram MiniApp config value by key or default value + */ + function webAppConfig( string $key, mixed $defaultValue = null ): TelegramUser + { + return telegramWebApp()->config( $key, $defaultValue ); + } } \ No newline at end of file diff --git a/tests/TelegramWebAppServiceProviderTest.php b/tests/TelegramWebAppServiceProviderTest.php index 8a3052e..ef62321 100644 --- a/tests/TelegramWebAppServiceProviderTest.php +++ b/tests/TelegramWebAppServiceProviderTest.php @@ -6,10 +6,12 @@ use Illuminate\Foundation\Application; use Micromagicman\TelegramWebApp\Dto\TelegramUser; use Micromagicman\TelegramWebApp\Facade\TelegramFacade; +use Micromagicman\TelegramWebApp\Util\Crypto; use Orchestra\Testbench\Attributes\DefineEnvironment; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\MockObject\Exception; class TelegramWebAppServiceProviderTest extends TestCase { @@ -25,6 +27,23 @@ protected function useTestTelegramBotToken( Application $app ): void $app[ 'config' ]->set( 'telegram-webapp.botToken', 'test-token' ); } + /** + * @throws Exception + */ + protected function useCryptoServiceMock( Application $app ): void + { + $cryptoMock = $this->createMock( Crypto::class ); + $cryptoMock->expects( $this->atLeast( 2 ) ) + ->method( 'hmacSHA256' ) + ->willReturn( '1e22c77f7ed7c91699d93eaf3925dc7e84a3ebb695642bb6a7664e34df63cc32' ); + $app->singleton( Crypto::class, fn() => $cryptoMock ); + } + + protected function useAuthDateLifetimeMinute( Application $app ): void + { + $app[ 'config' ]->set( 'telegram-webapp.authDateLifetimeSeconds', 60 ); + } + #[Test] public function testWebAppPageLoadsCorrectly() { @@ -103,6 +122,40 @@ public function testRequestDeclinedWithoutTelegramData() $this->assertNull( TelegramFacade::getWebAppUser() ); } + #[Test] + #[DefineEnvironment( 'useCryptoServiceMock' )] + #[DefineEnvironment( 'useTestTelegramBotToken' )] + #[DefineEnvironment( 'useAuthDateLifetimeMinute' )] + public function testRequestPassedWithValidAuthDate() + { + $authDate = time() - 55; + $response = $this->post( "/api/telegram-webapp?query_id=AAE0m7oLAAAAADSbugtKyT4p&user={\"id\":111111111,\"first_name\":\"Evgen\",\"last_name\":\"Evgen\",\"username\":\"micromagicman\",\"language_code\":\"xx\",\"is_premium\":true,\"allows_write_to_pm\":true}&auth_date=$authDate&hash=1e22c77f7ed7c91699d93eaf3925dc7e84a3ebb695642bb6a7664e34df63cc32" ); + $this->assertEquals( 200, $response->getStatusCode() ); + $this->assertEquals( [ 'message' => 'OK' ], $response->json() ); + $expectedUser = new TelegramUser( [ + 'id' => 111111111, + 'first_name' => 'Evgen', + 'last_name' => 'Evgen', + 'username' => 'micromagicman', + 'language_code' => 'xx', + 'is_premium' => true, + 'allows_write_to_pm' => true, + ] ); + + $this->assertEquals( $expectedUser, TelegramFacade::getWebAppUser() ); + $this->assertEquals( $expectedUser, TelegramFacade::getWebAppUser( request() ) ); + } + + #[Test] + #[DefineEnvironment( 'useTestTelegramBotToken' )] + #[DefineEnvironment( 'useAuthDateLifetimeMinute' )] + public function testRequestPassedWithInvalidAuthDate() + { + $authDate = time() - 61; + $response = $this->post( "/api/telegram-webapp?query_id=AAE0m7oLAAAAADSbugtKyT4p&user={\"id\":111111111,\"first_name\":\"Evgen\",\"last_name\":\"Evgen\",\"username\":\"micromagicman\",\"language_code\":\"xx\",\"is_premium\":true,\"allows_write_to_pm\":true}&auth_date=$authDate&hash=1e22c77f7ed7c91699d93eaf3925dc7e84a3ebb695642bb6a7664e34df63cc32" ); + $this->assertEquals( 403, $response->getStatusCode() ); + } + private function loadDOM( string $htmlContent ): DOMDocument { $dom = new DOMDocument(); diff --git a/workbench/routes/api.php b/workbench/routes/api.php index 4d3eae0..23c58bb 100644 --- a/workbench/routes/api.php +++ b/workbench/routes/api.php @@ -1,6 +1,5 @@