diff --git a/docs/2-configuring_resource_owners.md b/docs/2-configuring_resource_owners.md index 936a9f508..3a4211a59 100644 --- a/docs/2-configuring_resource_owners.md +++ b/docs/2-configuring_resource_owners.md @@ -74,6 +74,7 @@ hwi_oauth: - [Stack Exchange](resource_owners/stack_exchange.md) - [Stereomood](resource_owners/stereomood.md) - [Strava](resource_owners/strava.md) +- [Telegram](resource_owners/telegram.md) - [Toshl](resource_owners/toshl.md) - [Trello](resource_owners/trello.md) - [Twitch](resource_owners/twitch.md) @@ -88,7 +89,7 @@ hwi_oauth: ### CSRF protection Set the _csrf_ option to **true** in the resource owner's configuration in order to protect your users from [CSRF](https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)) attacks. -This will be round-tripped to your application in the `state` parameter. +This will be round-tripped to your application in the `state` parameter. Other types of state can be configured under the `state` key, either as a single string or key/value pairs. State can also be passed directly in the `state` query parameter of your request, provided they don't override the configured keys @@ -108,7 +109,7 @@ hwi_oauth: options: csrf: true refresh_on_expire: true - state: + state: some: parameter some-other: parameter ``` diff --git a/docs/resource_owners/telegram.md b/docs/resource_owners/telegram.md new file mode 100644 index 000000000..0045539dd --- /dev/null +++ b/docs/resource_owners/telegram.md @@ -0,0 +1,35 @@ +Step 2x: Setup Telegram +==================== +First you will need to create bot via [BotFather](https://telegram.me/BotFather) and then set you site domain to it + +``` +/newbot +nameof_bot +``` + +Save token somewhere to put it as `client_secret` in config file + +``` +/setdomain +example.com +``` +Or you can do it via botfather buttons. + +Next configure a resource owner of type `telegram` with appropriate `client_secret` + +```yaml +# config/packages/hwi_oauth.yaml + +hwi_oauth: + resource_owners: + any_name: + type: telegram + client_id: NOT_REQUIRED # but required by bundle + client_secret: +``` + +When you're done, continue by configuring the security layer or go back to +setup more resource owners. + +- [Step 2: Configuring resource owners (Facebook, GitHub, Google, Windows Live and others](../2-configuring_resource_owners.md) +- [Step 3: Configuring the security layer](../3-configuring_the_security_layer.md). diff --git a/src/OAuth/ResourceOwner/TelegramResourceOwner.php b/src/OAuth/ResourceOwner/TelegramResourceOwner.php new file mode 100644 index 000000000..8404852ed --- /dev/null +++ b/src/OAuth/ResourceOwner/TelegramResourceOwner.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace HWI\Bundle\OAuthBundle\OAuth\ResourceOwner; + +use HWI\Bundle\OAuthBundle\OAuth\Response\PathUserResponse; +use HWI\Bundle\OAuthBundle\Security\Core\Authentication\Token\OAuthToken; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\LazyResponseException; + +/** + * @author zorn-v + */ +final class TelegramResourceOwner extends GenericOAuth2ResourceOwner +{ + public const TYPE = 'telegram'; + + protected array $paths = [ + 'identifier' => 'id', + 'nickname' => 'username', + 'realname' => 'first_name', + 'firstname' => 'first_name', + 'lastname' => 'last_name', + 'profilepicture' => 'photo_url', + ]; + + public function getAuthorizationUrl($redirectUri, array $extraParameters = []) + { + [$botId] = explode(':', $this->options['client_secret']); + $parameters = array_merge([ + 'bot_id' => $botId, + 'origin' => $redirectUri, + 'return_to' => $redirectUri, + ], $extraParameters); + + return $this->normalizeUrl($this->options['authorization_url'], $parameters); + } + + public function handles(Request $request) + { + if (!$request->query->has('code')) { + $js = ''; + throw new LazyResponseException(new Response($js)); + } + + return true; + } + + public function getAccessToken(Request $request, $redirectUri, array $extraParameters = []) + { + $token = $request->query->get('code', ''); + $token = str_pad(strtr($token, '-_', '+/'), \strlen($token) % 4, '=', \STR_PAD_RIGHT); + $authData = json_decode(base64_decode($token), true); + if (empty($authData['hash'])) { + throw new AuthenticationException('Invalid Telegram auth data'); + } + if (empty($authData['auth_date']) || (time() - $authData['auth_date']) > 300) { + throw new AuthenticationException('Telegram auth data expired'); + } + $botToken = $this->options['client_secret']; + $checkHash = $authData['hash']; + unset($authData['hash']); + ksort($authData); + $dataCheckStr = ''; + foreach ($authData as $k => $v) { + $dataCheckStr .= sprintf("\n%s=%s", $k, $v); + } + $dataCheckStr = substr($dataCheckStr, 1); + $secretKey = hash('sha256', $botToken, true); + $hash = hash_hmac('sha256', $dataCheckStr, $secretKey); + if ($hash !== $checkHash) { + throw new AuthenticationException('Telegram auth data check failed'); + } + + return ['access_token' => $token]; + } + + public function getUserInformation(array $accessToken, array $extraParameters = []) + { + $data = base64_decode($accessToken['access_token']); + $response = $this->getUserResponse(); + $response->setData($data); + $response->setResourceOwner($this); + $response->setOAuthToken(new OAuthToken($accessToken)); + + return $response; + } + + protected function configureOptions(OptionsResolver $resolver) + { + $resolver->setRequired([ + 'client_id', + 'client_secret', + 'authorization_url', + ]); + + $resolver->setDefaults([ + 'authorization_url' => 'https://oauth.telegram.org/auth', + 'auth_with_one_url' => true, + 'state' => null, + 'csrf' => false, + 'user_response_class' => PathUserResponse::class, + ]); + } +} diff --git a/tests/OAuth/ResourceOwner/TelegramResourceOwnerTest.php b/tests/OAuth/ResourceOwner/TelegramResourceOwnerTest.php new file mode 100644 index 000000000..129c0438a --- /dev/null +++ b/tests/OAuth/ResourceOwner/TelegramResourceOwnerTest.php @@ -0,0 +1,192 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace HWI\Bundle\OAuthBundle\Tests\OAuth\ResourceOwner; + +use HWI\Bundle\OAuthBundle\OAuth\ResourceOwner\TelegramResourceOwner; +use HWI\Bundle\OAuthBundle\OAuth\Response\AbstractUserResponse; +use HWI\Bundle\OAuthBundle\Test\Fixtures\CustomUserResponse; +use HWI\Bundle\OAuthBundle\Test\OAuth\ResourceOwner\GenericOAuth2ResourceOwnerTestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Exception\LazyResponseException; + +/** + * @author zorn-v + */ +final class TelegramResourceOwnerTest extends GenericOAuth2ResourceOwnerTestCase +{ + protected string $resourceOwnerClass = TelegramResourceOwner::class; + + protected array $options = [ + 'client_id' => 'clientid', + 'client_secret' => 'client:secret', + + 'authorization_url' => 'http://user.auth/?test=2', + ]; + + protected array $paths = [ + 'identifier' => 'id', + 'nickname' => 'username', + ]; + + protected array $tokenData = ['access_token' => 'eyJpZCI6MSwidXNlcm5hbWUiOiJiYXIifQ==']; + + protected string $authorizationUrlBasePart = 'http://user.auth/?test=2&bot_id=client&origin=http%3A%2F%2Fredirect.to%2F'; + protected string $redirectUrlPart = '&return_to=http%3A%2F%2Fredirect.to%2F'; + + public function testHandleRequest(): void + { + $resourceOwner = $this->createResourceOwner(); + + $request = new Request(['code' => 'test']); + $this->assertTrue($resourceOwner->handles($request)); + + $request = new Request(['code' => 'test', 'test' => 'test']); + $this->assertTrue($resourceOwner->handles($request)); + + $request = new Request(['test' => 'test']); + $this->expectException(LazyResponseException::class); + $resourceOwner->handles($request); + } + + public function testGetAccessToken(string $response = '', string $contentType = ''): void + { + $resourceOwner = $this->createResourceOwner( + [], + [], + [ + $this->createMockResponse($response, $contentType), + ] + ); + $token = $this->getAuthToken(['id' => 1, 'auth_date' => time()], $this->options['client_secret']); + + $request = new Request(['code' => $token]); + + $this->assertEquals( + ['access_token' => $token], + $resourceOwner->getAccessToken($request, 'http://redirect.to/') + ); + } + + public function testGetAccessTokenExpired(string $response = '', string $contentType = ''): void + { + $resourceOwner = $this->createResourceOwner( + [], + [], + [ + $this->createMockResponse($response, $contentType), + ] + ); + $token = $this->getAuthToken(['id' => 1, 'auth_date' => 123], $this->options['client_secret']); + + $request = new Request(['code' => $token]); + + $this->expectExceptionMessage('Telegram auth data expired'); + $resourceOwner->getAccessToken($request, 'http://redirect.to/'); + } + + public function testGetAccessTokenInvalidHash(string $response = '', string $contentType = ''): void + { + $resourceOwner = $this->createResourceOwner( + [], + [], + [ + $this->createMockResponse($response, $contentType), + ] + ); + $token = $this->getAuthToken(['id' => 1, 'auth_date' => time()], 'invalid'); + + $request = new Request(['code' => $token]); + + $this->expectExceptionMessage('Telegram auth data check failed'); + $resourceOwner->getAccessToken($request, 'http://redirect.to/'); + } + + public function testRefreshAccessToken($response = '', $contentType = ''): void + { + $this->markTestSkipped('There is no refresh tokens'); + } + + public function testRefreshAccessTokenInvalid(string $response = '', string $exceptionClass = ''): void + { + $this->markTestSkipped('There is no refresh tokens'); + } + + public function testGetUserInformation(): void + { + $resourceOwner = $this->createResourceOwner( + [], + [], + [ + $this->createMockResponse($this->userResponse), + ] + ); + + /** @var AbstractUserResponse $userResponse */ + $userResponse = $resourceOwner->getUserInformation($this->tokenData); + + $this->assertEquals('1', $userResponse->getUsername()); + $this->assertEquals('bar', $userResponse->getNickname()); + $this->assertEquals($this->tokenData['access_token'], $userResponse->getAccessToken()); + $this->assertNull($userResponse->getRefreshToken()); + $this->assertNull($userResponse->getExpiresIn()); + } + + public function testInvalidOptionValueThrowsException(): void + { + } + + public function testGetUserInformationFailure(): void + { + $this->markTestSkipped('There is no extra http request for get user information'); + } + + public function testGetAuthorizationUrlWithEnabledCsrf(): void + { + $this->markTestSkipped('No CSRF is available for this Resource Owner.'); + } + + public function testCustomResponseClass(): void + { + $class = CustomUserResponse::class; + + $resourceOwner = $this->createResourceOwner( + ['user_response_class' => $class], + [], + [ + $this->createMockResponse($this->userResponse), + ] + ); + + $userResponse = $resourceOwner->getUserInformation($this->tokenData); + + $this->assertInstanceOf($class, $userResponse); + $this->assertEquals('foo666', $userResponse->getUsername()); + $this->assertEquals('foo', $userResponse->getNickname()); + $this->assertEquals($this->tokenData['access_token'], $userResponse->getAccessToken()); + $this->assertNull($userResponse->getRefreshToken()); + $this->assertNull($userResponse->getExpiresIn()); + } + + private function getAuthToken(array $authData, string $secret) + { + ksort($authData); + $dataStr = ''; + foreach ($authData as $k => $v) { + $dataStr .= sprintf("\n%s=%s", $k, $v); + } + $dataStr = substr($dataStr, 1); + $secretKey = hash('sha256', $secret, true); + $authData['hash'] = hash_hmac('sha256', $dataStr, $secretKey); + + return base64_encode(json_encode($authData)); + } +}