Make Telegram connect codes stateless: drop the model/table, use a signed code + session

This commit is contained in:
Paulo Castellano 2026-06-14 08:57:38 -03:00
parent 816d367dbe
commit 2284596f9d
9 changed files with 134 additions and 208 deletions

View file

@ -4,25 +4,19 @@
namespace App\Enums\SocialAccount;
use App\Models\TelegramConnectRequest;
use App\Models\SocialAccount;
enum TelegramConnectStatus: string
{
case Unknown = 'unknown';
case Pending = 'pending';
case Connected = 'connected';
case Expired = 'expired';
/**
* Derive the connection status the frontend polls for from a connect request.
* Connected once the channel has been linked to an account; otherwise still pending.
*/
public static function for(?TelegramConnectRequest $request): self
public static function for(?SocialAccount $account): self
{
return match (true) {
$request === null => self::Unknown,
$request->social_account_id !== null => self::Connected,
$request->isExpired() => self::Expired,
default => self::Pending,
};
return $account === null ? self::Pending : self::Connected;
}
}

View file

@ -6,20 +6,21 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\TelegramConnectStatus;
use App\Http\Requests\App\Auth\TelegramStatusRequest;
use App\Models\TelegramConnectRequest;
use App\Services\Social\TelegramConnectCode;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class TelegramController extends SocialController
{
protected SocialPlatform $platform = SocialPlatform::Telegram;
private const SESSION_KEY = 'telegram_connect_code';
/**
* Start a connection: issue a one-time code the user posts in their channel
* (`/connect <code>`) so the webhook can tie the channel to this workspace.
* 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
* can link the channel without any persisted state.
*/
public function connect(Request $request): JsonResponse
{
@ -31,35 +32,39 @@ public function connect(Request $request): JsonResponse
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
$connectRequest = TelegramConnectRequest::create([
'workspace_id' => $workspace->id,
'user_id' => $request->user()->id,
'code' => Str::lower(Str::random(12)),
'expires_at' => now()->addMinutes(15),
]);
$expiresAt = now()->addMinutes(15);
$code = TelegramConnectCode::issue($workspace->id, $expiresAt);
$request->session()->put(self::SESSION_KEY, $code);
return response()->json([
'code' => $connectRequest->code,
'code' => $code,
'bot_username' => config('trypost.platforms.telegram.bot_username'),
'expires_at' => $connectRequest->expires_at->toIso8601String(),
'expires_at' => $expiresAt->toIso8601String(),
]);
}
/**
* Poll whether the channel has been linked yet.
* Poll whether the channel issued in this session has been linked yet.
*/
public function status(TelegramStatusRequest $request): JsonResponse
public function status(Request $request): JsonResponse
{
$workspace = $request->user()->currentWorkspace;
abort_if($workspace === null, SymfonyResponse::HTTP_CONFLICT, 'No active workspace.');
$connectRequest = TelegramConnectRequest::query()
->where('workspace_id', $workspace->id)
->where('code', $request->validated('code'))
$payload = TelegramConnectCode::decode($request->session()->get(self::SESSION_KEY));
if ($payload === null) {
return response()->json(['status' => TelegramConnectStatus::Unknown->value]);
}
$account = $workspace->socialAccounts()
->where('platform', SocialPlatform::Telegram->value)
->where('meta->connect_nonce', data_get($payload, 'nonce'))
->first();
return response()->json([
'status' => TelegramConnectStatus::for($connectRequest)->value,
'status' => TelegramConnectStatus::for($account)->value,
]);
}
}

View file

@ -8,8 +8,8 @@
use App\Enums\SocialAccount\Status;
use App\Features\SocialAccountLimit;
use App\Http\Controllers\Controller;
use App\Models\TelegramConnectRequest;
use App\Models\Workspace;
use App\Services\Social\TelegramConnectCode;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Laravel\Pennant\Feature;
@ -19,8 +19,8 @@ class TelegramWebhookController extends Controller
{
/**
* Receives Bot API updates. The only update we act on is a `/connect <code>`
* message/channel_post: it ties the originating channel to the workspace that
* generated the code. Everything else is acknowledged and ignored.
* message/channel_post: the signed code carries the workspace, so we link the
* originating channel to it. Everything else is acknowledged and ignored.
*/
public function handle(Request $request): Response
{
@ -39,21 +39,16 @@ public function handle(Request $request): Response
return response()->noContent();
}
$connectRequest = TelegramConnectRequest::query()
->whereNull('social_account_id')
->where('code', $matches[1])
->where('expires_at', '>', now())
->first();
$payload = TelegramConnectCode::decode($matches[1]);
$workspace = $payload === null ? null : Workspace::find(data_get($payload, 'workspace_id'));
if ($connectRequest === null) {
if ($workspace === null) {
return response()->noContent();
}
$chatId = (string) data_get($chat, 'id');
$username = data_get($chat, 'username');
$workspace = $connectRequest->workspace;
// Mirror the controller's limit gate: block only brand-new accounts, never reconnects.
$isNewAccount = ! $workspace->socialAccounts()
->where('platform', SocialPlatform::Telegram->value)
@ -64,7 +59,7 @@ public function handle(Request $request): Response
return response()->noContent();
}
$account = $workspace->socialAccounts()->updateOrCreate(
$workspace->socialAccounts()->updateOrCreate(
[
'platform' => SocialPlatform::Telegram->value,
'platform_user_id' => $chatId,
@ -83,12 +78,11 @@ public function handle(Request $request): Response
'chat_id' => $chatId,
'username' => $username,
'type' => data_get($chat, 'type'),
'connect_nonce' => data_get($payload, 'nonce'),
],
],
);
$connectRequest->update(['social_account_id' => $account->id]);
return response()->noContent();
}

View file

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Auth;
use Illuminate\Foundation\Http\FormRequest;
class TelegramStatusRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'code' => ['required', 'string'],
];
}
}

View file

@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TelegramConnectRequest extends Model
{
use HasFactory;
use HasUuids;
protected $guarded = [];
protected function casts(): array
{
return [
'expires_at' => 'datetime',
];
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
use Carbon\CarbonInterface;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
/**
* A stateless, signed `/connect` code. It carries the workspace it belongs to,
* a one-off nonce (so the polling UI can recognise its own connection) and an
* expiry encrypted with the app key, so the webhook can trust it without any
* database lookup.
*/
class TelegramConnectCode
{
public static function issue(string $workspaceId, CarbonInterface $expiresAt): string
{
return Crypt::encryptString((string) json_encode([
'workspace_id' => $workspaceId,
'nonce' => Str::lower(Str::random(16)),
'expires_at' => $expiresAt->getTimestamp(),
]));
}
/**
* Decode and validate a code, returning its payload or null when the code is
* missing, tampered with, or expired.
*
* @return array{workspace_id: string, nonce: string, expires_at: int}|null
*/
public static function decode(?string $code): ?array
{
if (! is_string($code) || $code === '') {
return null;
}
try {
$payload = json_decode(Crypt::decryptString($code), true);
} catch (DecryptException) {
return null;
}
if (! is_array($payload) || now()->getTimestamp() > (int) data_get($payload, 'expires_at')) {
return null;
}
return $payload;
}
}

View file

@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('telegram_connect_requests', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->foreignUuid('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('code')->unique();
// Set by the webhook once the channel is linked; null while pending.
$table->foreignUuid('social_account_id')->nullable()->constrained('social_accounts')->nullOnDelete();
$table->timestamp('expires_at');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('telegram_connect_requests');
}
};

View file

@ -23,7 +23,7 @@ import {
const open = defineModel<boolean>('open', { required: true });
type Phase = 'loading' | 'ready' | 'connected' | 'expired' | 'error';
type ConnectStatus = 'unknown' | 'pending' | 'connected' | 'expired';
type ConnectStatus = 'unknown' | 'pending' | 'connected';
interface ConnectResponse {
code: string;
@ -57,9 +57,7 @@ const poll = async () => {
if (phase.value !== 'ready') return;
try {
const response = await httpStatus.get(
telegramStatus.url({ query: { code: code.value } }),
);
const response = await httpStatus.get(telegramStatus.url());
if (response?.status === 'connected') {
phase.value = 'connected';
@ -72,7 +70,8 @@ const poll = async () => {
return;
}
if (response?.status === 'expired' || response?.status === 'unknown') {
// The signed code expired (or the session was lost): prompt a fresh one.
if (response?.status === 'unknown') {
phase.value = 'expired';
stopPolling();
return;

View file

@ -5,10 +5,10 @@
use App\Enums\SocialAccount\Platform;
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
use App\Models\TelegramConnectRequest;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Social\ConnectionVerifier;
use App\Services\Social\TelegramConnectCode;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
@ -43,31 +43,23 @@ function telegramUpdate(string $code, array $chat = []): array
];
}
it('issues a connect code', function () {
it('issues a signed connect code and stores it in the session', function () {
$response = $this->actingAs($this->user)
->postJson(route('app.social.telegram.connect'))
->assertOk()
->assertJsonStructure(['code', 'bot_username', 'expires_at']);
->assertJsonStructure(['code', 'bot_username', 'expires_at'])
->assertSessionHas('telegram_connect_code');
expect($response->json('bot_username'))->toBe('TryPostBot');
$this->assertDatabaseHas('telegram_connect_requests', [
'workspace_id' => $this->workspace->id,
'code' => $response->json('code'),
'social_account_id' => null,
]);
expect(data_get(TelegramConnectCode::decode($response->json('code')), 'workspace_id'))
->toBe($this->workspace->id);
});
it('links the channel when the webhook receives a matching /connect', function () {
$request = TelegramConnectRequest::create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'code' => 'abc123code',
'expires_at' => now()->addMinutes(15),
]);
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate('abc123code'))
->postJson(route('telegram.webhook'), telegramUpdate($code))
->assertNoContent();
$account = SocialAccount::where('workspace_id', $this->workspace->id)
@ -79,20 +71,15 @@ function telegramUpdate(string $code, array $chat = []): array
expect($account->display_name)->toBe('My Channel');
expect($account->username)->toBe('mychannel');
expect(data_get($account->meta, 'chat_id'))->toBe('-1001234567890');
expect($request->fresh()->social_account_id)->toBe($account->id);
expect(data_get($account->meta, 'connect_nonce'))
->toBe(data_get(TelegramConnectCode::decode($code), 'nonce'));
});
it('links a private channel that has no username', function () {
TelegramConnectRequest::create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'code' => 'privatecode',
'expires_at' => now()->addMinutes(15),
]);
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate('privatecode', ['username' => null]))
->postJson(route('telegram.webhook'), telegramUpdate($code, ['username' => null]))
->assertNoContent();
$account = SocialAccount::where('platform', Platform::Telegram)->first();
@ -107,15 +94,10 @@ function telegramUpdate(string $code, array $chat = []): array
SocialAccount::factory()->count(5)->create(['workspace_id' => $this->workspace->id]);
TelegramConnectRequest::create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'code' => 'limitcode',
'expires_at' => now()->addMinutes(15),
]);
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate('limitcode'))
->postJson(route('telegram.webhook'), telegramUpdate($code))
->assertNoContent();
expect($this->workspace->socialAccounts()->count())->toBe(5);
@ -133,15 +115,10 @@ function telegramUpdate(string $code, array $chat = []): array
'platform_user_id' => '-1001234567890',
]);
TelegramConnectRequest::create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'code' => 'reconnectcode',
'expires_at' => now()->addMinutes(15),
]);
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate('reconnectcode'))
->postJson(route('telegram.webhook'), telegramUpdate($code))
->assertNoContent();
expect($this->workspace->socialAccounts()->count())->toBe(5);
@ -150,58 +127,56 @@ function telegramUpdate(string $code, array $chat = []): array
)->toBe(1);
});
it('requires a code to check connection status', function () {
$this->actingAs($this->user)
->getJson(route('app.social.telegram.status'))
->assertStatus(422);
});
it('rejects the webhook without the secret token', function () {
$this->postJson(route('telegram.webhook'), telegramUpdate('whatever'))
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
$this->postJson(route('telegram.webhook'), telegramUpdate($code))
->assertForbidden();
});
it('ignores the webhook for an unknown or expired code', function () {
TelegramConnectRequest::create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'code' => 'expiredcode',
'expires_at' => now()->subMinute(),
]);
it('ignores the webhook for a tampered or expired code', function () {
$expired = TelegramConnectCode::issue($this->workspace->id, now()->subMinute());
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate('expiredcode'))
->postJson(route('telegram.webhook'), telegramUpdate($expired))
->assertNoContent();
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate('does-not-exist'))
->postJson(route('telegram.webhook'), telegramUpdate('not-a-valid-code'))
->assertNoContent();
expect(SocialAccount::where('platform', Platform::Telegram)->count())->toBe(0);
});
it('reports connection status while pending and once connected', function () {
$request = TelegramConnectRequest::create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'code' => 'statuscode',
'expires_at' => now()->addMinutes(15),
]);
it('reports the session connection status 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)
->getJson(route('app.social.telegram.status', ['code' => 'statuscode']))
->withSession(['telegram_connect_code' => $code])
->getJson(route('app.social.telegram.status'))
->assertOk()
->assertJson(['status' => 'pending']);
$account = SocialAccount::factory()->telegram()->create(['workspace_id' => $this->workspace->id]);
$request->update(['social_account_id' => $account->id]);
SocialAccount::factory()->telegram()->create([
'workspace_id' => $this->workspace->id,
'meta' => ['chat_id' => '-1001234567890', 'username' => 'mychannel', 'type' => 'channel', 'connect_nonce' => $nonce],
]);
$this->actingAs($this->user)
->getJson(route('app.social.telegram.status', ['code' => 'statuscode']))
->withSession(['telegram_connect_code' => $code])
->getJson(route('app.social.telegram.status'))
->assertOk()
->assertJson(['status' => 'connected']);
});
it('reports unknown status without a session code', function () {
$this->actingAs($this->user)
->getJson(route('app.social.telegram.status'))
->assertOk()
->assertJson(['status' => 'unknown']);
});
it('verifies a connected telegram account via getChat', function () {
config(['trypost.platforms.telegram.bot_token' => 'TESTTOKEN']);