Harden Telegram connect: one-time code, drop session, decode validation; switch icon to png; widen add-social dialog
This commit is contained in:
parent
2284596f9d
commit
d729c34c0e
9 changed files with 58 additions and 37 deletions
|
|
@ -15,8 +15,6 @@ class TelegramController extends SocialController
|
|||
{
|
||||
protected SocialPlatform $platform = SocialPlatform::Telegram;
|
||||
|
||||
private const SESSION_KEY = 'telegram_connect_code';
|
||||
|
||||
/**
|
||||
* Start a connection: issue a signed one-off code the user posts in their
|
||||
* channel (`/connect <code>`). The code carries the workspace, so the webhook
|
||||
|
|
@ -33,26 +31,23 @@ public function connect(Request $request): JsonResponse
|
|||
$this->ensureSocialAccountLimit($workspace);
|
||||
|
||||
$expiresAt = now()->addMinutes(15);
|
||||
$code = TelegramConnectCode::issue($workspace->id, $expiresAt);
|
||||
|
||||
$request->session()->put(self::SESSION_KEY, $code);
|
||||
|
||||
return response()->json([
|
||||
'code' => $code,
|
||||
'code' => TelegramConnectCode::issue($workspace->id, $expiresAt),
|
||||
'bot_username' => config('trypost.platforms.telegram.bot_username'),
|
||||
'expires_at' => $expiresAt->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll whether the channel issued in this session has been linked yet.
|
||||
* Poll whether the channel for the given code has been linked yet.
|
||||
*/
|
||||
public function status(Request $request): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
abort_if($workspace === null, SymfonyResponse::HTTP_CONFLICT, 'No active workspace.');
|
||||
|
||||
$payload = TelegramConnectCode::decode($request->session()->get(self::SESSION_KEY));
|
||||
$payload = TelegramConnectCode::decode($request->query('code'));
|
||||
|
||||
if ($payload === null) {
|
||||
return response()->json(['status' => TelegramConnectStatus::Unknown->value]);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
use App\Services\Social\TelegramConnectCode;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Laravel\Pennant\Feature;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
|
||||
|
|
@ -59,6 +60,11 @@ public function handle(Request $request): Response
|
|||
return response()->noContent();
|
||||
}
|
||||
|
||||
// Consume the code once so a leaked code can't be replayed to link another chat.
|
||||
if (! Cache::add("telegram:connect:{$payload['nonce']}", true, now()->addMinutes(15))) {
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
$workspace->socialAccounts()->updateOrCreate(
|
||||
[
|
||||
'platform' => SocialPlatform::Telegram->value,
|
||||
|
|
|
|||
|
|
@ -28,11 +28,11 @@ public static function issue(string $workspaceId, CarbonInterface $expiresAt): s
|
|||
|
||||
/**
|
||||
* Decode and validate a code, returning its payload or null when the code is
|
||||
* missing, tampered with, or expired.
|
||||
* missing, tampered with, malformed, or expired.
|
||||
*
|
||||
* @return array{workspace_id: string, nonce: string, expires_at: int}|null
|
||||
*/
|
||||
public static function decode(?string $code): ?array
|
||||
public static function decode(mixed $code): ?array
|
||||
{
|
||||
if (! is_string($code) || $code === '') {
|
||||
return null;
|
||||
|
|
@ -44,7 +44,12 @@ public static function decode(?string $code): ?array
|
|||
return null;
|
||||
}
|
||||
|
||||
if (! is_array($payload) || now()->getTimestamp() > (int) data_get($payload, 'expires_at')) {
|
||||
if (
|
||||
! is_array($payload)
|
||||
|| ! is_string(data_get($payload, 'workspace_id'))
|
||||
|| ! is_string(data_get($payload, 'nonce'))
|
||||
|| now()->getTimestamp() > (int) data_get($payload, 'expires_at')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
BIN
public/images/accounts/telegram.png
Normal file
BIN
public/images/accounts/telegram.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -1,10 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 240 240" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="tg" x1="120" y1="0" x2="120" y2="240" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2AABEE" />
|
||||
<stop offset="1" stop-color="#229ED9" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="120" cy="120" r="120" fill="url(#tg)" />
|
||||
<path fill="#fff" d="M54 118.7c35-15.2 58.3-25.3 70-30.2 33.3-13.9 40.2-16.3 44.7-16.4 1 0 3.2.2 4.7 1.4.8.7 1.2 1.6 1.4 2.6.2 1 .4 3.2.2 4.9-1.8 19-9.7 65.1-13.7 86.4-1.7 9-5 12.1-8.2 12.4-7 .6-12.3-4.6-19-9-10.6-7-16.6-11.3-26.9-18.1-11.9-7.8-4.2-12.1 2.6-19.1 1.8-1.8 32.5-29.8 33.1-32.3.1-.3.1-1.5-.6-2.1-.7-.6-1.7-.4-2.5-.2-1.1.2-17.9 11.4-50.6 33.4-4.8 3.3-9.1 4.9-13 4.8-4.3-.1-12.5-2.4-18.6-4.4-7.5-2.4-13.5-3.7-13-7.9.3-2.2 3.3-4.4 9.1-6.6z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 862 B |
|
|
@ -101,7 +101,7 @@ const platformTheme: Record<
|
|||
telegram: {
|
||||
bg: 'bg-sky-200',
|
||||
rotate: '-rotate-2',
|
||||
image: '/images/accounts/telegram.svg',
|
||||
image: '/images/accounts/telegram.png',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -130,7 +130,7 @@ const connectPlatform = (platformValue: string) => {
|
|||
<template>
|
||||
<div>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-3xl">
|
||||
<DialogContent class="sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{
|
||||
$t('accounts.add_social_title')
|
||||
|
|
@ -140,7 +140,7 @@ const connectPlatform = (platformValue: string) => {
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div
|
||||
class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4"
|
||||
class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5"
|
||||
>
|
||||
<div
|
||||
v-for="platform in platforms"
|
||||
|
|
|
|||
|
|
@ -57,7 +57,9 @@ const poll = async () => {
|
|||
if (phase.value !== 'ready') return;
|
||||
|
||||
try {
|
||||
const response = await httpStatus.get(telegramStatus.url());
|
||||
const response = await httpStatus.get(
|
||||
telegramStatus.url({ query: { code: code.value } }),
|
||||
);
|
||||
|
||||
if (response?.status === 'connected') {
|
||||
phase.value = 'connected';
|
||||
|
|
@ -123,7 +125,7 @@ onUnmounted(stopPolling);
|
|||
<DialogHeader>
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
src="/images/accounts/telegram.svg"
|
||||
src="/images/accounts/telegram.png"
|
||||
alt="Telegram"
|
||||
class="size-10"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const PLATFORM_LOGOS: Record<string, string> = {
|
|||
bluesky: '/images/accounts/bluesky.png',
|
||||
pinterest: '/images/accounts/pinterest.png',
|
||||
mastodon: '/images/accounts/mastodon.png',
|
||||
telegram: '/images/accounts/telegram.svg',
|
||||
telegram: '/images/accounts/telegram.png',
|
||||
};
|
||||
|
||||
const PLATFORM_LABELS: Record<string, string> = {
|
||||
|
|
@ -32,7 +32,11 @@ const PLATFORM_LABELS: Record<string, string> = {
|
|||
|
||||
const PLATFORM_CONTENT_TYPES: Record<string, string[]> = {
|
||||
instagram: ['instagram_feed', 'instagram_reel', 'instagram_story'],
|
||||
'instagram-facebook': ['instagram_feed', 'instagram_reel', 'instagram_story'],
|
||||
'instagram-facebook': [
|
||||
'instagram_feed',
|
||||
'instagram_reel',
|
||||
'instagram_story',
|
||||
],
|
||||
linkedin: ['linkedin_post', 'linkedin_carousel'],
|
||||
'linkedin-page': ['linkedin_page_post', 'linkedin_page_carousel'],
|
||||
facebook: ['facebook_post', 'facebook_reel', 'facebook_story'],
|
||||
|
|
|
|||
|
|
@ -43,12 +43,11 @@ function telegramUpdate(string $code, array $chat = []): array
|
|||
];
|
||||
}
|
||||
|
||||
it('issues a signed connect code and stores it in the session', function () {
|
||||
it('issues a signed connect code carrying the workspace', function () {
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson(route('app.social.telegram.connect'))
|
||||
->assertOk()
|
||||
->assertJsonStructure(['code', 'bot_username', 'expires_at'])
|
||||
->assertSessionHas('telegram_connect_code');
|
||||
->assertJsonStructure(['code', 'bot_username', 'expires_at']);
|
||||
|
||||
expect($response->json('bot_username'))->toBe('TryPostBot');
|
||||
expect(data_get(TelegramConnectCode::decode($response->json('code')), 'workspace_id'))
|
||||
|
|
@ -127,6 +126,23 @@ function telegramUpdate(string $code, array $chat = []): array
|
|||
)->toBe(1);
|
||||
});
|
||||
|
||||
it('consumes the code once so it cannot be replayed for another chat', function () {
|
||||
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
|
||||
|
||||
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
|
||||
->postJson(route('telegram.webhook'), telegramUpdate($code, ['id' => -1001111111111]))
|
||||
->assertNoContent();
|
||||
|
||||
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
|
||||
->postJson(route('telegram.webhook'), telegramUpdate($code, ['id' => -1002222222222]))
|
||||
->assertNoContent();
|
||||
|
||||
expect(SocialAccount::where('platform', Platform::Telegram)->count())->toBe(1);
|
||||
expect(
|
||||
SocialAccount::where('platform', Platform::Telegram)->where('platform_user_id', '-1001111111111')->exists()
|
||||
)->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects the webhook without the secret token', function () {
|
||||
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
|
||||
|
||||
|
|
@ -148,13 +164,12 @@ function telegramUpdate(string $code, array $chat = []): array
|
|||
expect(SocialAccount::where('platform', Platform::Telegram)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('reports the session connection status while pending and once connected', function () {
|
||||
it('reports the connection status for a code while pending and once connected', function () {
|
||||
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
|
||||
$nonce = data_get(TelegramConnectCode::decode($code), 'nonce');
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->withSession(['telegram_connect_code' => $code])
|
||||
->getJson(route('app.social.telegram.status'))
|
||||
->getJson(route('app.social.telegram.status', ['code' => $code]))
|
||||
->assertOk()
|
||||
->assertJson(['status' => 'pending']);
|
||||
|
||||
|
|
@ -164,17 +179,21 @@ function telegramUpdate(string $code, array $chat = []): array
|
|||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->withSession(['telegram_connect_code' => $code])
|
||||
->getJson(route('app.social.telegram.status'))
|
||||
->getJson(route('app.social.telegram.status', ['code' => $code]))
|
||||
->assertOk()
|
||||
->assertJson(['status' => 'connected']);
|
||||
});
|
||||
|
||||
it('reports unknown status without a session code', function () {
|
||||
it('reports unknown status without a valid code', function () {
|
||||
$this->actingAs($this->user)
|
||||
->getJson(route('app.social.telegram.status'))
|
||||
->assertOk()
|
||||
->assertJson(['status' => 'unknown']);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->getJson(route('app.social.telegram.status', ['code' => 'tampered']))
|
||||
->assertOk()
|
||||
->assertJson(['status' => 'unknown']);
|
||||
});
|
||||
|
||||
it('verifies a connected telegram account via getChat', function () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue