From de944d2080245548bf4495018d051bf5d1aa3481 Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Sun, 23 Nov 2025 14:41:40 +0700 Subject: [PATCH 1/3] [client] add JWT auth support --- Makefile | 2 +- src/Client.php | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 096a30b..4f336a6 100644 --- a/Makefile +++ b/Makefile @@ -14,5 +14,5 @@ .PHONY: test test: - vendor/bin/phpstan analyze --level 6 src/ tests/ \ + vendor/bin/phpstan analyze --memory-limit 256M --level 6 src/ tests/ \ && vendor/bin/phpunit --do-not-cache-result tests/ diff --git a/src/Client.php b/src/Client.php index 324614b..e646a5d 100644 --- a/src/Client.php +++ b/src/Client.php @@ -21,7 +21,7 @@ class Client { public const DEFAULT_URL = 'https://api.sms-gate.app/3rdparty/v1'; public const USER_AGENT_TEMPLATE = 'android-sms-gateway/2.0 (client; php %s)'; - protected string $basicAuth; + protected string $authHeader; protected string $baseUrl; protected ClientInterface $client; @@ -31,13 +31,21 @@ class Client { protected StreamFactoryInterface $streamFactory; public function __construct( - string $login, + ?string $login, string $password, string $serverUrl = self::DEFAULT_URL, ?ClientInterface $client = null, ?Encryptor $encryptor = null ) { - $this->basicAuth = base64_encode($login . ':' . $password); + if (!empty($login)) { + $this->authHeader = 'Basic ' . base64_encode($login . ':' . $password); + } elseif (!empty($password)) { + $passwordOrToken = $password; + $this->authHeader = 'Bearer ' . $passwordOrToken; + } else { + throw new RuntimeException('Missing credentials'); + } + $this->baseUrl = $serverUrl; $this->client = $client ?? Psr18ClientDiscovery::find(); $this->encryptor = $encryptor; @@ -390,8 +398,9 @@ protected function sendRequest(string $method, string $path, $payload = null) { $method, $this->baseUrl . $path ) - ->withAddedHeader('Authorization', 'Basic ' . $this->basicAuth) - ->withAddedHeader('User-Agent', sprintf(self::USER_AGENT_TEMPLATE, PHP_VERSION)); + ->withAddedHeader('User-Agent', sprintf(self::USER_AGENT_TEMPLATE, PHP_VERSION)) + ->withAddedHeader('Authorization', $this->authHeader); + if (isset($data)) { $request = $request ->withAddedHeader('Content-Type', 'application/json') From bbefddf27a057e605531520c1c95e4e838b22826 Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Sun, 23 Nov 2025 17:08:40 +0700 Subject: [PATCH 2/3] [client] add JWT management methods --- src/Client.php | 38 +++++++++++++ src/Domain/TokenRequest.php | 67 ++++++++++++++++++++++ src/Domain/TokenResponse.php | 83 +++++++++++++++++++++++++++ tests/ClientTest.php | 68 ++++++++++++++++++++++ tests/Domain/TokenRequestTest.php | 91 ++++++++++++++++++++++++++++++ tests/Domain/TokenResponseTest.php | 82 +++++++++++++++++++++++++++ 6 files changed, 429 insertions(+) create mode 100644 src/Domain/TokenRequest.php create mode 100644 src/Domain/TokenResponse.php create mode 100644 tests/Domain/TokenRequestTest.php create mode 100644 tests/Domain/TokenResponseTest.php diff --git a/src/Client.php b/src/Client.php index e646a5d..5bf8046 100644 --- a/src/Client.php +++ b/src/Client.php @@ -9,6 +9,8 @@ use AndroidSmsGateway\Domain\Webhook; use AndroidSmsGateway\Domain\MessagesExportRequest; use AndroidSmsGateway\Domain\Settings; +use AndroidSmsGateway\Domain\TokenRequest; +use AndroidSmsGateway\Domain\TokenResponse; use AndroidSmsGateway\Exceptions\HttpException; use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; @@ -379,6 +381,42 @@ public function DeleteWebhook(string $id): void { ); } + /** + * Generate a new JWT token + * + * @param TokenRequest $request + * @return TokenResponse + */ + public function GenerateToken(TokenRequest $request): TokenResponse { + $path = '/auth/token'; + + $response = $this->sendRequest( + 'POST', + $path, + $request + ); + if (!is_object($response)) { + throw new RuntimeException('Invalid response'); + } + + return TokenResponse::FromObject($response); + } + + /** + * Revoke a JWT token + * + * @param string $jti + * @return void + */ + public function RevokeToken(string $jti): void { + $path = '/auth/token/' . $jti; + + $this->sendRequest( + 'DELETE', + $path + ); + } + /** * @param \AndroidSmsGateway\Interfaces\SerializableInterface|null $payload * @throws \Http\Client\Exception\HttpException diff --git a/src/Domain/TokenRequest.php b/src/Domain/TokenRequest.php new file mode 100644 index 0000000..7fcd7a7 --- /dev/null +++ b/src/Domain/TokenRequest.php @@ -0,0 +1,67 @@ +scopes = $scopes; + $this->ttl = $ttl; + } + + /** + * @return string[] + */ + public function Scopes(): array { + return $this->scopes; + } + + /** + * @param string[] $scopes + * @return self + */ + public function setScopes(array $scopes): self { + $this->scopes = $scopes; + return $this; + } + + public function TTL(): ?int { + return $this->ttl; + } + + public function setTtl(?int $ttl): self { + $this->ttl = $ttl; + return $this; + } + + public function toObject(): \stdClass { + $obj = new \stdClass(); + $obj->scopes = $this->scopes; + + if ($this->ttl !== null) { + $obj->ttl = $this->ttl; + } + + return $obj; + } + + /** + * @param object $obj + * @return self + */ + public static function FromObject(object $obj): self { + return new self( + $obj->scopes ?? [], + $obj->ttl ?? null + ); + } +} \ No newline at end of file diff --git a/src/Domain/TokenResponse.php b/src/Domain/TokenResponse.php new file mode 100644 index 0000000..76db928 --- /dev/null +++ b/src/Domain/TokenResponse.php @@ -0,0 +1,83 @@ +accessToken = $accessToken; + $this->tokenType = $tokenType; + $this->id = $id; + $this->expiresAt = $expiresAt; + } + + public function AccessToken(): string { + return $this->accessToken; + } + + public function setAccessToken(string $accessToken): self { + $this->accessToken = $accessToken; + return $this; + } + + public function TokenType(): string { + return $this->tokenType; + } + + public function setTokenType(string $tokenType): self { + $this->tokenType = $tokenType; + return $this; + } + + public function ID(): string { + return $this->id; + } + + public function setId(string $id): self { + $this->id = $id; + return $this; + } + + public function ExpiresAt(): string { + return $this->expiresAt; + } + + public function setExpiresAt(string $expiresAt): self { + $this->expiresAt = $expiresAt; + return $this; + } + + public function toObject(): \stdClass { + $obj = new \stdClass(); + $obj->access_token = $this->accessToken; + $obj->token_type = $this->tokenType; + $obj->id = $this->id; + $obj->expires_at = $this->expiresAt; + + return $obj; + } + + /** + * @param object $obj + * @return self + */ + public static function FromObject(object $obj): self { + return new self( + $obj->access_token ?? '', + $obj->token_type ?? '', + $obj->id ?? '', + $obj->expires_at ?? '' + ); + } +} \ No newline at end of file diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 3121b9b..01c21f7 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -8,6 +8,8 @@ use AndroidSmsGateway\Domain\MessageState; use AndroidSmsGateway\Domain\Settings; use AndroidSmsGateway\Domain\Webhook; +use AndroidSmsGateway\Domain\TokenRequest; +use AndroidSmsGateway\Domain\TokenResponse; use AndroidSmsGateway\Enums\ProcessState; use AndroidSmsGateway\Enums\WebhookEvent; use Http\Client\Curl\Client as CurlClient; @@ -384,6 +386,72 @@ public function testDeleteWebhook(): void { ); } + public function testGenerateToken(): void { + $tokenRequest = new TokenRequest(['read', 'write'], 3600); + + $responseMock = self::mockResponse( + '{"access_token":"test-token","token_type":"Bearer","id":"token-id","expires_at":"2023-12-31T23:59:59Z"}', + 201, + ['Content-Type' => 'application/json'] + ); + + $this->mockClient->addResponse($responseMock); + + $tokenResponse = $this->client->GenerateToken($tokenRequest); + $req = $this->mockClient->getLastRequest(); + $this->assertEquals('POST', $req->getMethod()); + $this->assertEquals('/3rdparty/v1/auth/token', $req->getUri()->getPath()); + $this->assertEquals( + 'Basic ' . base64_encode(self::MOCK_LOGIN . ':' . self::MOCK_PASSWORD), + $req->getHeaderLine('Authorization') + ); + $this->assertEquals( + 'application/json', + $req->getHeaderLine('Content-Type') + ); + + $this->assertInstanceOf(TokenResponse::class, $tokenResponse); + $this->assertEquals('test-token', $tokenResponse->AccessToken()); + $this->assertEquals('Bearer', $tokenResponse->TokenType()); + $this->assertEquals('token-id', $tokenResponse->ID()); + $this->assertEquals('2023-12-31T23:59:59Z', $tokenResponse->ExpiresAt()); + } + + public function testRevokeToken(): void { + $responseMock = self::mockResponse('', 204); + + $this->mockClient->addResponse($responseMock); + + $this->client->RevokeToken('token-id'); + $req = $this->mockClient->getLastRequest(); + $this->assertEquals('DELETE', $req->getMethod()); + $this->assertEquals('/3rdparty/v1/auth/token/token-id', $req->getUri()->getPath()); + $this->assertEquals( + 'Basic ' . base64_encode(self::MOCK_LOGIN . ':' . self::MOCK_PASSWORD), + $req->getHeaderLine('Authorization') + ); + } + + public function testClientWithJwtToken(): void { + $jwtToken = 'test-jwt-token'; + $client = new Client(null, $jwtToken, Client::DEFAULT_URL, $this->mockClient); + + $responseMock = self::mockResponse( + '{"id":"123","state":"Sent","recipients":[{"phoneNumber":"+79000000000","state":"Sent"}]}', + 201, + ['Content-Type' => 'application/json'] + ); + + $this->mockClient->addResponse($responseMock); + + $messageMock = $this->createMock(Message::class); + $messageMock->method('ToObject')->willReturn((object) []); + + $client->SendMessage($messageMock); + $req = $this->mockClient->getLastRequest(); + $this->assertEquals('Bearer ' . $jwtToken, $req->getHeaderLine('Authorization')); + } + public const MOCK_LOGIN = 'login'; public const MOCK_PASSWORD = 'password'; } \ No newline at end of file diff --git a/tests/Domain/TokenRequestTest.php b/tests/Domain/TokenRequestTest.php new file mode 100644 index 0000000..483ea76 --- /dev/null +++ b/tests/Domain/TokenRequestTest.php @@ -0,0 +1,91 @@ +assertEquals($scopes, $tokenRequest->Scopes()); + $this->assertEquals($ttl, $tokenRequest->TTL()); + } + + public function testTokenRequestCreationWithoutTtl(): void { + $scopes = ['read']; + + $tokenRequest = new TokenRequest($scopes); + + $this->assertEquals($scopes, $tokenRequest->Scopes()); + $this->assertNull($tokenRequest->TTL()); + } + + public function testTokenRequestSetters(): void { + $tokenRequest = new TokenRequest(['read']); + + $newScopes = ['read', 'write', 'admin']; + $newTtl = 7200; + + $tokenRequest->setScopes($newScopes); + $tokenRequest->setTtl($newTtl); + + $this->assertEquals($newScopes, $tokenRequest->Scopes()); + $this->assertEquals($newTtl, $tokenRequest->TTL()); + } + + public function testTokenRequestToObject(): void { + $scopes = ['read', 'write']; + $ttl = 3600; + + $tokenRequest = new TokenRequest($scopes, $ttl); + $obj = $tokenRequest->toObject(); + + $this->assertEquals($scopes, $obj->scopes); + $this->assertEquals($ttl, $obj->ttl); + } + + public function testTokenRequestToObjectWithoutTtl(): void { + $scopes = ['read']; + + $tokenRequest = new TokenRequest($scopes); + $obj = $tokenRequest->toObject(); + + $this->assertEquals($scopes, $obj->scopes); + $this->assertObjectNotHasProperty('ttl', $obj); + } + + public function testTokenRequestFromObject(): void { + $obj = new \stdClass(); + $obj->scopes = ['read', 'write']; + $obj->ttl = 3600; + + $tokenRequest = TokenRequest::FromObject($obj); + + $this->assertEquals($obj->scopes, $tokenRequest->Scopes()); + $this->assertEquals($obj->ttl, $tokenRequest->TTL()); + } + + public function testTokenRequestFromObjectWithoutTtl(): void { + $obj = new \stdClass(); + $obj->scopes = ['read']; + + $tokenRequest = TokenRequest::FromObject($obj); + + $this->assertEquals($obj->scopes, $tokenRequest->Scopes()); + $this->assertNull($tokenRequest->TTL()); + } + + public function testTokenRequestFromObjectWithDefaultValues(): void { + $obj = new \stdClass(); + + $tokenRequest = TokenRequest::FromObject($obj); + + $this->assertEquals([], $tokenRequest->Scopes()); + $this->assertNull($tokenRequest->TTL()); + } +} \ No newline at end of file diff --git a/tests/Domain/TokenResponseTest.php b/tests/Domain/TokenResponseTest.php new file mode 100644 index 0000000..59e3c1b --- /dev/null +++ b/tests/Domain/TokenResponseTest.php @@ -0,0 +1,82 @@ +assertEquals($accessToken, $tokenResponse->AccessToken()); + $this->assertEquals($tokenType, $tokenResponse->TokenType()); + $this->assertEquals($id, $tokenResponse->ID()); + $this->assertEquals($expiresAt, $tokenResponse->ExpiresAt()); + } + + public function testTokenResponseSetters(): void { + $tokenResponse = new TokenResponse('initial-token', 'Bearer', 'initial-id', '2023-01-01T00:00:00Z'); + + $newAccessToken = 'new-access-token'; + $newTokenType = 'JWT'; + $newId = 'new-token-id'; + $newExpiresAt = '2024-12-31T23:59:59Z'; + + $tokenResponse->setAccessToken($newAccessToken); + $tokenResponse->setTokenType($newTokenType); + $tokenResponse->setId($newId); + $tokenResponse->setExpiresAt($newExpiresAt); + + $this->assertEquals($newAccessToken, $tokenResponse->AccessToken()); + $this->assertEquals($newTokenType, $tokenResponse->TokenType()); + $this->assertEquals($newId, $tokenResponse->ID()); + $this->assertEquals($newExpiresAt, $tokenResponse->ExpiresAt()); + } + + public function testTokenResponseToObject(): void { + $accessToken = 'test-access-token'; + $tokenType = 'Bearer'; + $id = 'token-id'; + $expiresAt = '2023-12-31T23:59:59Z'; + + $tokenResponse = new TokenResponse($accessToken, $tokenType, $id, $expiresAt); + $obj = $tokenResponse->toObject(); + + $this->assertEquals($accessToken, $obj->access_token); + $this->assertEquals($tokenType, $obj->token_type); + $this->assertEquals($id, $obj->id); + $this->assertEquals($expiresAt, $obj->expires_at); + } + + public function testTokenResponseFromObject(): void { + $obj = new \stdClass(); + $obj->access_token = 'test-access-token'; + $obj->token_type = 'Bearer'; + $obj->id = 'token-id'; + $obj->expires_at = '2023-12-31T23:59:59Z'; + + $tokenResponse = TokenResponse::FromObject($obj); + + $this->assertEquals($obj->access_token, $tokenResponse->AccessToken()); + $this->assertEquals($obj->token_type, $tokenResponse->TokenType()); + $this->assertEquals($obj->id, $tokenResponse->ID()); + $this->assertEquals($obj->expires_at, $tokenResponse->ExpiresAt()); + } + + public function testTokenResponseFromObjectWithDefaultValues(): void { + $obj = new \stdClass(); + + $tokenResponse = TokenResponse::FromObject($obj); + + $this->assertEquals('', $tokenResponse->AccessToken()); + $this->assertEquals('', $tokenResponse->TokenType()); + $this->assertEquals('', $tokenResponse->ID()); + $this->assertEquals('', $tokenResponse->ExpiresAt()); + } +} \ No newline at end of file From ad711dd8abeff2fb577bb21928f29e136533b54d Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Sun, 23 Nov 2025 17:17:39 +0700 Subject: [PATCH 3/3] [docs] add JWT sections --- README.md | 115 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 97 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 7352f3f..1c3a520 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![PHP Version Require](https://img.shields.io/packagist/php-v/capcom6/android-sms-gateway?style=for-the-badge)](https://packagist.org/packages/capcom6/android-sms-gateway) [![Total Downloads](https://img.shields.io/packagist/dt/capcom6/android-sms-gateway.svg?style=for-the-badge)](https://packagist.org/packages/capcom6/android-sms-gateway) -A modern PHP client for seamless integration with the [SMS Gateway for Android](https://sms-gate.app) API. Send SMS messages, manage devices, and configure webhooks through your PHP applications with this intuitive library. +A modern PHP client for seamless integration with the [SMSGate](https://sms-gate.app) API. Send SMS messages, manage devices, and configure webhooks through your PHP applications with this intuitive library. ## 🔖 Table of Contents @@ -17,8 +17,15 @@ A modern PHP client for seamless integration with the [SMS Gateway for Android]( - [🚀 Quickstart](#-quickstart) - [Sending an SMS](#sending-an-sms) - [Managing Devices](#managing-devices) + - [🔐 Authentication](#-authentication) + - [Basic Authentication](#basic-authentication) + - [JWT Authentication](#jwt-authentication) + - [Generating a JWT Token](#generating-a-jwt-token) + - [Using a JWT Token](#using-a-jwt-token) + - [Revoking a JWT Token](#revoking-a-jwt-token) - [📚 Full API Reference](#-full-api-reference) - [Client Initialization](#client-initialization) + - [Basic Authentication](#basic-authentication-1) - [Core Methods](#core-methods) - [Builder Methods](#builder-methods) - [🔒 Security Notes](#-security-notes) @@ -36,6 +43,8 @@ A modern PHP client for seamless integration with the [SMS Gateway for Android]( - **Error Handling**: Structured exception management - **Type Safety**: Strict typing throughout the codebase - **Encryption Support**: End-to-end message encryption +- **Dual Authentication**: Support for both Basic and JWT authentication +- **Token Management**: Generate, use, and revoke JWT tokens with configurable scopes and TTL ## ⚙️ Prerequisites @@ -103,36 +112,106 @@ try { } ``` +## 🔐 Authentication + +The SMSGate client supports two authentication methods: Basic Authentication and JWT (JSON Web Token) authentication. Each method has its own use cases and benefits. + +### Basic Authentication + +```php +// Initialize client with Basic authentication +$client = new Client('your_login', 'your_password'); +``` + +### JWT Authentication + +JWT authentication uses bearer tokens for authentication. + +#### Generating a JWT Token + +```php +use AndroidSmsGateway\Client; +use AndroidSmsGateway\Domain\TokenRequest; + +// First, create a client with Basic authentication to generate a token +$basicClient = new Client('your_login', 'your_password'); + +// Create a token request with specific scopes and TTL +$tokenRequest = new TokenRequest( + ['messages:send', 'messages:read'], // Scopes for permissions + 3600 // Token TTL in seconds (optional) +); + +// Generate the token +$tokenResponse = $basicClient->GenerateToken($tokenRequest); +$jwtToken = $tokenResponse->AccessToken(); + +echo "Token generated! Expires at: " . $tokenResponse->ExpiresAt() . PHP_EOL; +``` + +#### Using a JWT Token + +```php +// Initialize client with JWT authentication +$jwtClient = new Client(null, $jwtToken); + +// Now use the client as usual +$message = (new MessageBuilder('Your message text here.', ['+1234567890']))->build(); +$messageState = $jwtClient->SendMessage($message); +``` + +#### Revoking a JWT Token + +```php +// Revoke a token using its ID (jti) +$basicClient->RevokeToken($tokenResponse->ID()); +echo "Token revoked successfully!" . PHP_EOL; +``` + ## 📚 Full API Reference ### Client Initialization + +The client supports two authentication methods: Basic Authentication and JWT Bearer Tokens. + +#### Basic Authentication ```php -$client = new Client( - string $login, +$clientBasic = new Client( + string $login, string $password, string $serverUrl = 'https://api.sms-gate.app/3rdparty/v1', ?\Psr\Http\Client\ClientInterface $httpClient = null, ?\AndroidSmsGateway\Encryptor $encryptor = null ); + +$clientJWT = new Client( + null, // Set login to null for JWT + string $jwtToken, // JWT token as the second parameter + string $serverUrl = 'https://api.sms-gate.app/3rdparty/v1', + ?\Psr\Http\Client\ClientInterface $httpClient = null, + ?\AndroidSmsGateway\Encryptor $encryptor = null +); ``` ### Core Methods -| Category | Method | Description | -| ------------ | ---------------------------------------------------- | --------------------------------- | -| **Messages** | `SendMessage(Message $message)` | Send SMS message | -| | `GetMessageState(string $id)` | Get message status by ID | -| | `RequestInboxExport(MessagesExportRequest $request)` | Request inbox export via webhooks | -| **Devices** | `ListDevices()` | List registered devices | -| | `RemoveDevice(string $id)` | Remove device by ID | -| **System** | `HealthCheck()` | Check API health status | -| | `GetLogs(?string $from, ?string $to)` | Retrieve system logs | -| **Settings** | `GetSettings()` | Get account settings | -| | `PatchSettings(Settings $settings)` | Partially update account settings | -| | `ReplaceSettings(Settings $settings)` | Replace account settings | -| **Webhooks** | `ListWebhooks()` | List registered webhooks | -| | `RegisterWebhook(Webhook $webhook)` | Register new webhook | -| | `DeleteWebhook(string $id)` | Delete webhook by ID | +| Category | Method | Description | +| ------------------ | ---------------------------------------------------- | --------------------------------- | +| **Messages** | `SendMessage(Message $message)` | Send SMS message | +| | `GetMessageState(string $id)` | Get message status by ID | +| | `RequestInboxExport(MessagesExportRequest $request)` | Request inbox export via webhooks | +| **Devices** | `ListDevices()` | List registered devices | +| | `RemoveDevice(string $id)` | Remove device by ID | +| **System** | `HealthCheck()` | Check API health status | +| | `GetLogs(?string $from, ?string $to)` | Retrieve system logs | +| **Settings** | `GetSettings()` | Get account settings | +| | `PatchSettings(Settings $settings)` | Partially update account settings | +| | `ReplaceSettings(Settings $settings)` | Replace account settings | +| **Webhooks** | `ListWebhooks()` | List registered webhooks | +| | `RegisterWebhook(Webhook $webhook)` | Register new webhook | +| | `DeleteWebhook(string $id)` | Delete webhook by ID | +| **Authentication** | `GenerateToken(TokenRequest $request)` | Generate a new JWT token | +| | `RevokeToken(string $jti)` | Revoke a JWT token by ID | ### Builder Methods ```php