From 450c165fce7de8c5fbabc44103965b7b43929c61 Mon Sep 17 00:00:00 2001 From: "nikita.hohlov" Date: Sat, 5 Sep 2026 06:03:13 -0300 Subject: [PATCH] feat: agent token, auth.agent middleware, /mcp route with HadoServer skeleton Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw --- .../Console/Commands/AgentTokenCommand.php | 36 +++++++++++++++ .../app/Http/Middleware/AuthenticateAgent.php | 32 +++++++++++++ core/app/Mcp/HadoServer.php | 32 +++++++++++++ core/app/Models/AgentToken.php | 30 +++++++++++++ core/bootstrap/app.php | 4 +- .../2026_09_05_000000_create_agent_tokens.php | 24 ++++++++++ core/routes/ai.php | 7 +++ core/tests/Feature/AgentTokenCommandTest.php | 45 +++++++++++++++++++ core/tests/Feature/McpAuthTest.php | 39 ++++++++++++++++ 9 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 core/app/Console/Commands/AgentTokenCommand.php create mode 100644 core/app/Http/Middleware/AuthenticateAgent.php create mode 100644 core/app/Mcp/HadoServer.php create mode 100644 core/app/Models/AgentToken.php create mode 100644 core/database/migrations/2026_09_05_000000_create_agent_tokens.php create mode 100644 core/routes/ai.php create mode 100644 core/tests/Feature/AgentTokenCommandTest.php create mode 100644 core/tests/Feature/McpAuthTest.php diff --git a/core/app/Console/Commands/AgentTokenCommand.php b/core/app/Console/Commands/AgentTokenCommand.php new file mode 100644 index 0000000..284324a --- /dev/null +++ b/core/app/Console/Commands/AgentTokenCommand.php @@ -0,0 +1,36 @@ +ensureUser((string) $this->argument('login')); + $had = AgentToken::where('user_id', $user->id)->exists(); + + if ($this->option('revoke')) { + AgentToken::where('user_id', $user->id)->delete(); + $this->info($had ? "Токен агента для «{$user->login}» отозван." : "У «{$user->login}» не было токена агента."); + + return self::SUCCESS; + } + + $token = AgentToken::issue($user); + $this->info($had + ? "Токен агента для «{$user->login}» перевыпущен, старый больше не работает." + : "Токен агента для «{$user->login}» создан."); + $this->line('Показывается один раз, в базе только хэш.'); + $this->line("Token: {$token}"); + + return self::SUCCESS; + } +} diff --git a/core/app/Http/Middleware/AuthenticateAgent.php b/core/app/Http/Middleware/AuthenticateAgent.php new file mode 100644 index 0000000..ac83bdf --- /dev/null +++ b/core/app/Http/Middleware/AuthenticateAgent.php @@ -0,0 +1,32 @@ +bearerToken(); + if ($token === null || $token === '') { + return response()->json(['message' => 'Нужен Bearer-токен агента'], 401); + } + + $agent = AgentToken::findByToken($token); + if ($agent === null) { + return response()->json(['message' => 'Неизвестный токен агента'], 401); + } + + $request->attributes->set('user', $agent->user); + + return $next($request); + } +} diff --git a/core/app/Mcp/HadoServer.php b/core/app/Mcp/HadoServer.php new file mode 100644 index 0000000..fab1d72 --- /dev/null +++ b/core/app/Mcp/HadoServer.php @@ -0,0 +1,32 @@ +belongsTo(User::class); + } + + /** Выпускает (или перевыпускает) токен пользователя; возвращает открытый токен — показать один раз. */ + public static function issue(User $user): string + { + $token = 'hado_agent_'.bin2hex(random_bytes(32)); + static::updateOrCreate(['user_id' => $user->id], ['token_hash' => hash('sha256', $token)]); + + return $token; + } + + public static function findByToken(string $token): ?self + { + return static::where('token_hash', hash('sha256', $token))->first(); + } +} diff --git a/core/bootstrap/app.php b/core/bootstrap/app.php index 5e83651..250f801 100644 --- a/core/bootstrap/app.php +++ b/core/bootstrap/app.php @@ -1,5 +1,6 @@ trustProxies(at: '*'); $middleware->alias([ 'auth.source' => AuthenticateSource::class, + 'auth.agent' => AuthenticateAgent::class, 'remote.user' => RemoteUser::class, ]); $middleware->validateCsrfTokens(except: ['a/*', 'hooks/*', 'me/heartbeat']); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( - fn (Request $request) => $request->expectsJson() || $request->is('api/*') || $request->is('a/*') || $request->is('hooks/*'), + fn (Request $request) => $request->expectsJson() || $request->is('api/*') || $request->is('a/*') || $request->is('hooks/*') || $request->is('mcp'), ); })->create(); diff --git a/core/database/migrations/2026_09_05_000000_create_agent_tokens.php b/core/database/migrations/2026_09_05_000000_create_agent_tokens.php new file mode 100644 index 0000000..bf4d423 --- /dev/null +++ b/core/database/migrations/2026_09_05_000000_create_agent_tokens.php @@ -0,0 +1,24 @@ +id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->string('token_hash', 64)->unique(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('agent_tokens'); + } +}; diff --git a/core/routes/ai.php b/core/routes/ai.php new file mode 100644 index 0000000..11c51cc --- /dev/null +++ b/core/routes/ai.php @@ -0,0 +1,7 @@ +middleware(['auth.agent', 'throttle:60,1']); diff --git a/core/tests/Feature/AgentTokenCommandTest.php b/core/tests/Feature/AgentTokenCommandTest.php new file mode 100644 index 0000000..4211621 --- /dev/null +++ b/core/tests/Feature/AgentTokenCommandTest.php @@ -0,0 +1,45 @@ +artisan('hado:agent:token', ['login' => 'nikita']) + ->expectsOutputToContain('Token: hado_agent_') + ->assertSuccessful(); + + $user = User::where('login', 'nikita')->sole(); + $this->assertSame(64, strlen(AgentToken::where('user_id', $user->id)->sole()->token_hash)); + } + + public function test_reissue_replaces_the_old_token(): void + { + $this->artisan('hado:agent:token', ['login' => 'nikita'])->assertSuccessful(); + $old = AgentToken::sole()->token_hash; + + $this->artisan('hado:agent:token', ['login' => 'nikita']) + ->expectsOutputToContain('перевыпущен') + ->assertSuccessful(); + + $this->assertSame(1, AgentToken::count()); + $this->assertNotSame($old, AgentToken::sole()->token_hash); + } + + public function test_revoke_deletes_the_token(): void + { + $this->artisan('hado:agent:token', ['login' => 'nikita'])->assertSuccessful(); + + $this->artisan('hado:agent:token', ['login' => 'nikita', '--revoke' => true])->assertSuccessful(); + + $this->assertSame(0, AgentToken::count()); + } +} diff --git a/core/tests/Feature/McpAuthTest.php b/core/tests/Feature/McpAuthTest.php new file mode 100644 index 0000000..7ebdeeb --- /dev/null +++ b/core/tests/Feature/McpAuthTest.php @@ -0,0 +1,39 @@ + '2.0', 'id' => 1, 'method' => 'initialize', + 'params' => ['protocolVersion' => '2025-06-18', 'capabilities' => [], 'clientInfo' => ['name' => 'test', 'version' => '0']], + ]; + + public function test_requires_agent_token(): void + { + $this->postJson('/mcp', self::INITIALIZE)->assertStatus(401)->assertJsonPath('message', 'Нужен Bearer-токен агента'); + $this->postJson('/mcp', self::INITIALIZE, ['Authorization' => 'Bearer nope'])->assertStatus(401)->assertJsonPath('message', 'Неизвестный токен агента'); + } + + public function test_remote_user_header_is_not_enough(): void + { + $this->postJson('/mcp', self::INITIALIZE, ['X-Remote-User' => 'nikita'])->assertStatus(401); + } + + public function test_valid_token_reaches_the_server(): void + { + $user = User::create(['login' => 'nikita', 'tz' => 'UTC']); + AgentToken::create(['user_id' => $user->id, 'token_hash' => hash('sha256', 'secret')]); + + $this->postJson('/mcp', self::INITIALIZE, ['Authorization' => 'Bearer secret']) + ->assertOk() + ->assertJsonPath('result.serverInfo.name', 'Hado'); + } +}