Skip to content

Commit

Permalink
Add Telegram resource owner (#1966)
Browse files Browse the repository at this point in the history
  • Loading branch information
zorn-v authored Dec 5, 2023
1 parent c5651c0 commit 29abf68
Show file tree
Hide file tree
Showing 4 changed files with 346 additions and 2 deletions.
5 changes: 3 additions & 2 deletions docs/2-configuring_resource_owners.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -108,7 +109,7 @@ hwi_oauth:
options:
csrf: true
refresh_on_expire: true
state:
state:
some: parameter
some-other: parameter
```
Expand Down
35 changes: 35 additions & 0 deletions docs/resource_owners/telegram.md
Original file line number Diff line number Diff line change
@@ -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: <bot_token>
```
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).
116 changes: 116 additions & 0 deletions src/OAuth/ResourceOwner/TelegramResourceOwner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

/*
* This file is part of the HWIOAuthBundle package.
*
* (c) Hardware Info <opensource@hardware.info>
*
* 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 = '<script>location.href = "?code=" + new URLSearchParams(location.hash.substring(1)).get("tgAuthResult")</script>';
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,
]);
}
}
192 changes: 192 additions & 0 deletions tests/OAuth/ResourceOwner/TelegramResourceOwnerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
<?php

/*
* This file is part of the HWIOAuthBundle package.
*
* (c) Hardware Info <opensource@hardware.info>
*
* 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));
}
}

0 comments on commit 29abf68

Please sign in to comment.