Туториали

Laravel Client Dashboards: Integrate Website Security Analyzer for Actionable Client Insights

Laravel клиентски контролни табли: Интегрирајте анализатор за безбедност на веб-страници за практични увидувања за клиентите

Безбедносниот извештај станува корисен само кога некој може да види што се променило, да разбере што е важно и да ги претвори препораките во завршена работа. За мала веб-агенција, тоа значи повеќе од сместување API-одговор во убава картичка. Контролната табла има потреба од трајна историја, извршување во заднина, експлицитни состојби на неуспех и задачи за санација што го преживуваат следното скенирање.

Овој туторијал го гради тој работен тек во Laravel и PHP 8.3+. Секој клиент има HTTPS веб-страница, хронолошка историја на скенирања, наоди групирани според сериозност, TLS-детали и применливи препораки. Website Security Analyzer извршува ограничена, неинвазивна анализа на јавната HTTPS и безбедносната положба на прелистувачот. Треба да поддржува рутинска хигиена и приоритизација, но никогаш не смее да се опишува како пенетрационо тестирање.

Добијте пристап пред да пишувате код за интеграција

  1. Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
  2. Отворете ја страницата на услугата Website Security Analyzer. Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
  3. Отворете ја официјалната документација за услугата. Најдете го панелот Service token и копирајте го токенот со опсег на услугата.
  4. Зачувајте го тој токен во околината на апликацијата. Неговото повторно генерирање го поништува претходно активниот токен, затоа координирајте ја ротацијата со распоредувањето наместо да го генерирате повторно неформално.

API-то прифаќа Bearer токен, заглавие X-API-Token или параметар за пребарување token. Овој проект користи Bearer токен бидејќи ја задржува автентикацијата надвор од URL-адресите, дневниците за пристап и историјата на прелистувачот.

Точното барање е POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website, со JSON-тело што содржи url. Проверете го пристапот со минимално барање:

curl --fail-with-body \
  --request POST \
  --url https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website \
  --header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
  --header "Content-Type: application/json" \
  --data '{"url":"https://client.example"}'

Сега ставете го акредитивот во .env, никогаш во PHP-изворот, фикстурите, сликите од екранот или дневниците:

WEBSITE_SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
QUEUE_CONNECTION=database

Додајте конфигурација поддржана од околината во config/services.php:

'website_security_analyzer' => [
    'endpoint' => 'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
    'token' => env('WEBSITE_SECURITY_ANALYZER_TOKEN'),
],

Изберете архитектура што ја зачувува историјата

Барањето не треба да чека да заврши надворешната анализа. Контролер создава скенирање во исчекување, а потоа испраќа задача во редицата. Задачата повикува наменски API-клиент, го проверува одговорот на границата на апликацијата, зачувува непроменлива снимка и создава отворени задачи за санација од препораките.

Овој дизајн чини неколку табели во базата и работник за редица, но овозможува побрзи одговори во прелистувачот, контролирани повторни обиди и ревизорска трага. Исто така, ги задржува транспортните грижи надвор од контролерите и приказите.

Предусловите се PHP 8.3+, Composer, поддржана Laravel база на податоци и позадина за редица. Почнете со нормална Laravel апликација и генерирајте ги главните класи:

composer create-project laravel/laravel agency-security-dashboard
cd agency-security-dashboard
php artisan make:model Client -m
php artisan make:model SecurityScan -m
php artisan make:model RemediationTask -m
php artisan make:controller ClientSecurityController
php artisan make:job AnalyzeClientWebsite
php artisan make:policy ClientPolicy --model=Client
php artisan make:test WebsiteSecurityAnalyzerTest --unit

Релевантната структура е намерно мала:

app/
  Data/WebsiteAnalysis.php
  Exceptions/AnalyzerFailure.php
  Http/Controllers/ClientSecurityController.php
  Jobs/AnalyzeClientWebsite.php
  Models/Client.php
  Models/SecurityScan.php
  Models/RemediationTask.php
  Services/WebsiteSecurityAnalyzer.php
resources/views/clients/security.blade.php
routes/web.php
tests/Unit/WebsiteSecurityAnalyzerTest.php

Зачувајте ги снимките и задачите одделно

Скенирањето е доказ снимен во одреден момент. Задачата за санација е променлива работа. Нивното раздвојување ѝ овозможува на агенцијата да затвори задача без да го препишува историскиот одговор што ја создал.

Schema::create('clients', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('name');
    $table->string('website_url', 2048);
    $table->timestamps();
});

Schema::create('security_scans', function (Blueprint $table) {
    $table->id();
    $table->foreignId('client_id')->constrained()->cascadeOnDelete();
    $table->string('status')->index(); // pending, running, completed, failed
    $table->double('score')->nullable();
    $table->json('findings')->nullable();
    $table->json('tls_details')->nullable();
    $table->json('recommendations')->nullable();
    $table->string('error_code')->nullable();
    $table->timestamp('completed_at')->nullable();
    $table->timestamps();
});

Schema::create('remediation_tasks', function (Blueprint $table) {
    $table->id();
    $table->foreignId('client_id')->constrained()->cascadeOnDelete();
    $table->foreignId('security_scan_id')->constrained()->cascadeOnDelete();
    $table->text('description');
    $table->timestamp('completed_at')->nullable();
    $table->timestamps();
});

Конфигурирајте ги моделите со соодветните релации hasMany и belongsTo. Претворете ги JSON-колоните во array, а временските ознаки во datetime. Или декларирајте ги доделените полиња во $fillable или користете експлицитно доделување својства насекаде.

Потврдете го API-одговорот на една граница

Обезбедениот договор изложува резултат, наоди групирани според сериозност, TLS-детали и препораки. Адаптерот не треба да дозволи неочекувана HTML-страница за грешка или променет JSON-облик да навлезе во доменот.

<?php

namespace App\Data;

use App\Exceptions\AnalyzerFailure;

final readonly class WebsiteAnalysis
{
    public function __construct(
        public int|float $score,
        public array $findings,
        public array $tlsDetails,
        public array $recommendations,
    ) {}

    public static function fromPayload(mixed $payload): self
    {
        if (! is_array($payload)
            || ! is_int($payload['score'] ?? null) && ! is_float($payload['score'] ?? null)
            || ! is_array($payload['findings'] ?? null)
            || ! is_array($payload['tls'] ?? null)
            || ! is_array($payload['recommendations'] ?? null)
        ) {
            throw new AnalyzerFailure('schema', 'Unexpected analyzer response.');
        }

        foreach ($payload['findings'] as $group => $items) {
            if (! is_string($group) || ! is_array($items)) {
                throw new AnalyzerFailure('schema', 'Invalid findings grouping.');
            }
        }

        $recommendations = array_values(array_filter(
            $payload['recommendations'],
            static fn (mixed $value): bool => is_string($value) && trim($value) !== ''
        ));

        return new self(
            $payload['score'],
            $payload['findings'],
            $payload['tls'],
            $recommendations,
        );
    }
}

Ако официјалната документација ги враќа овие полиња во документиран плик, отпакувајте го тој плик овде и никаде на друго место. Не расфрлајте претпоставени резервни решенија низ задачите и приказите.

Користете ограничени временски ограничувања и класифицирани неуспеси

<?php

namespace App\Services;

use App\Data\WebsiteAnalysis;
use App\Exceptions\AnalyzerFailure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;

final class WebsiteSecurityAnalyzer
{
    public function analyze(string $url): WebsiteAnalysis
    {
        $token = config('services.website_security_analyzer.token');

        if (! is_string($token) || $token === '') {
            throw new AnalyzerFailure('configuration', 'Analyzer token is missing.');
        }

        try {
            $response = Http::acceptJson()
                ->asJson()
                ->withToken($token)
                ->connectTimeout(5)
                ->timeout(30)
                ->post(
                    config('services.website_security_analyzer.endpoint'),
                    ['url' => $url],
                );
        } catch (ConnectionException $exception) {
            throw new AnalyzerFailure('temporary', 'Analyzer connection failed.', previous: $exception);
        }

        if ($response->successful()) {
            return WebsiteAnalysis::fromPayload($response->json());
        }

        $status = $response->status();

        if (in_array($status, [401, 403], true)) {
            throw new AnalyzerFailure('authentication', 'Analyzer authentication failed.');
        }

        if ($status === 429) {
            $header = $response->header('Retry-After');
            $delay = ctype_digit((string) $header)
                ? max(30, min(900, (int) $header))
                : 120;

            throw new AnalyzerFailure('rate_limit', 'Analyzer rate limit reached.', $delay);
        }

        if ($status === 422) {
            throw new AnalyzerFailure('validation', 'Analyzer rejected the website URL.');
        }

        if ($status >= 500) {
            throw new AnalyzerFailure('temporary', 'Analyzer is temporarily unavailable.');
        }

        throw new AnalyzerFailure('upstream', "Unexpected analyzer status: {$status}.");
    }
}

AnalyzerFailure е мала подкласа на RuntimeException што изложува јавни readonly-својства kind и nullable retryAfter. Нејзиниот конструктор треба да го прифати PHP-овиот именуван аргумент previous како што е прикажано.

Извршувајте скенирања во редицата

Само неуспесите на поврзувањето, неуспесите на серверот и ограничувањата на стапката заслужуваат уште еден обид. Неуспесите на валидацијата и автентикацијата бараат човечка интервенција, па нивното повторување само троши квота и го крие вистинскиот проблем.

final class AnalyzeClientWebsite implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 4;

    public function __construct(public SecurityScan $scan) {}

    public function backoff(): array
    {
        return [30, 120, 300];
    }

    public function handle(WebsiteSecurityAnalyzer $analyzer): void
    {
        $this->scan->update(['status' => 'running']);

        try {
            $result = $analyzer->analyze($this->scan->client->website_url);
        } catch (AnalyzerFailure $failure) {
            Log::warning('Website analysis failed', [
                'scan_id' => $this->scan->id,
                'client_id' => $this->scan->client_id,
                'kind' => $failure->kind,
                'attempt' => $this->attempts(),
            ]);

            if ($failure->kind === 'rate_limit' && $this->attempts() < $this->tries) {
                $this->release($failure->retryAfter ?? 120);
                return;
            }

            if ($failure->kind === 'temporary') {
                throw $failure;
            }

            $this->scan->update([
                'status' => 'failed',
                'error_code' => $failure->kind,
                'completed_at' => now(),
            ]);

            return;
        }

        DB::transaction(function () use ($result): void {
            $this->scan->update([
                'status' => 'completed',
                'score' => $result->score,
                'findings' => $result->findings,
                'tls_details' => $result->tlsDetails,
                'recommendations' => $result->recommendations,
                'completed_at' => now(),
            ]);

            foreach ($result->recommendations as $recommendation) {
                $this->scan->remediationTasks()->create([
                    'client_id' => $this->scan->client_id,
                    'description' => $recommendation,
                ]);
            }
        });
    }

    public function failed(?Throwable $failure): void
    {
        $this->scan->update([
            'status' => 'failed',
            'error_code' => 'retry_exhausted',
            'completed_at' => now(),
        ]);
    }
}

Дејството на контролерот треба да овласти пристап, да го заклучи редот на клиентот во трансакција, да отфрли второ скенирање во исчекување или извршување, да го создаде записот во исчекување и да ја испрати задачата по потврдувањето:

public function store(Client $client): RedirectResponse
{
    Gate::authorize('update', $client);

    DB::transaction(function () use ($client): void {
        $locked = Client::query()->lockForUpdate()->findOrFail($client->id);

        abort_if(
            $locked->securityScans()
                ->whereIn('status', ['pending', 'running'])
                ->exists(),
            409,
            'A scan is already in progress.'
        );

        $scan = $locked->securityScans()->create(['status' => 'pending']);
        AnalyzeClientWebsite::dispatch($scan)->afterCommit();
    });

    return back()->with('status', 'Security analysis queued.');
}

Дефинирајте рути за контролната табла, создавање скенирање и завршување задачи. Политиката мора да осигури дека автентицираниот корисник е сопственик на клиентот; врзувањето на моделот со рутата само по себе не е авторизација.

Route::middleware('auth')->group(function (): void {
    Route::get('/clients/{client}/security', [ClientSecurityController::class, 'show'])
        ->name('clients.security.show');

    Route::post('/clients/{client}/security/scans', [ClientSecurityController::class, 'store'])
        ->name('clients.security.scans.store');

    Route::patch('/clients/{client}/tasks/{task}', [ClientSecurityController::class, 'complete'])
        ->scopeBindings()
        ->name('clients.security.tasks.complete');
});

Blade-приказот може да остане едноставен: прикажете го најновиот резултат и времето на завршување, повторете низ findings по група на сериозност, прикажете ги TLS-деталите како означени вредности, наведете ги отворените задачи за санација со форми за завршување заштитени со CSRF и поставете ги претходните скенирања под тековната снимка. Екранирајте ги обичните вредности со Blade-овото {{ }}; не прикажувајте API-текст преку {!! !!}.

Тестирајте ја границата без да повикувате продукција

Laravel-овиот HTTP fake ги прави транспортните тестови детерминистички и докажува дека тајните и телата на барањата се составени правилно.

public function test_it_maps_a_successful_analysis(): void
{
    config()->set('services.website_security_analyzer.token', 'test-token');
    config()->set(
        'services.website_security_analyzer.endpoint',
        'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website'
    );

    Http::fake([
        'https://ai.mihajlo.mk/*' => Http::response([
            'score' => 82,
            'findings' => ['high' => [], 'medium' => []],
            'tls' => [],
            'recommendations' => ['Review the site security configuration.'],
        ], 200),
    ]);

    $result = app(WebsiteSecurityAnalyzer::class)
        ->analyze('https://client.example');

    $this->assertSame(82, $result->score);
    $this->assertCount(1, $result->recommendations);

    Http::assertSent(fn ($request) =>
        $request->method() === 'POST'
        && $request['url'] === 'https://client.example'
        && $request->hasHeader('Authorization', 'Bearer test-token')
    );
}

public function test_authentication_failure_is_not_retried(): void
{
    config()->set('services.website_security_analyzer.token', 'invalid');
    Http::fake(['https://ai.mihajlo.mk/*' => Http::response([], 401)]);

    try {
        app(WebsiteSecurityAnalyzer::class)->analyze('https://client.example');
        $this->fail('Expected an authentication failure.');
    } catch (AnalyzerFailure $failure) {
        $this->assertSame('authentication', $failure->kind);
    }

    Http::assertSentCount(1);
}

Додајте тестови за задачи со Queue::fake() за испраќање и Http::fake() за завршување, ограничување на стапката, невалиден JSON и исцрпени привремени неуспеси. Потврдете ја состојбата на базата на податоци, а не внатрешните повици на методи.

Продукциски заштитни мерки и распоредување

  • Прифаќајте само нормализирани јавни https:// URL-адреси на клиенти. Отфрлете акредитиви во URL-адреси, имиња localhost и приватни или резервирани IP-одредишта.
  • Шифрирајте ги тајните на околината преку платформата за распоредување и ограничете кој може да ги прегледува. За време на ротацијата, распоредете го повторно генерираниот токен насекаде каде што се користи редицата пред старите работници да продолжат со обработката.
  • Никогаш не го запишувајте во дневник токенот, заглавието за авторизација, целосниот одговор или URL-адресата на клиентот. Стабилните ID-а на скенирањата, ID-а на клиентите, категории на неуспех, обиди, траење и категории на HTTP-статус обезбедуваат корисна набљудливост.
  • Поставете предупредувања за повторени неуспеси на автентикација, неуспеси на шемата, исцрпени повторни обиди и растечка редица. Неуспех на шемата е аларм за интеграцијата, а не празен успешен извештај.
  • Јасно означете ја контролната табла како ограничена, неинвазивна анализа на безбедноста на веб-страница. Избегнувајте јазик што подразбира потврдување на експлоатирање, покриеност на внатрешна мрежа или пенетрационо тестирање.

Распоредете ги миграциите на базата на податоци, повторно изградете ја конфигурацијата и рестартирајте ги работниците за да го вчитаат новиот токен и код:

php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --tries=4 --timeout=90

Во продукција, извршувајте го работникот под надзорник на процеси. Неговото временско ограничување на процесот мора да остане удобно подолго од временското ограничување на HTTP-одговорот. Вообичаените неуспеси обично се директни: 401 или 403 укажува на проблем со конфигурацијата или ротацијата на токенот; 422 укажува на неприфатлива URL-адреса; 429 бара одложен повторен обид или преглед на планот; повторените 5xx или неуспеси на поврзувањето укажуваат на привремен проблем нагоре по текот; а неуспех на шемата значи дека адаптерот мора да се спореди со тековната официјална документација.

Конечна контролна листа за проверка

  • Клиентот може да стави едно скенирање во редица без да го држи отворено барањето на прелистувачот.
  • Истовремените кликови не можат да создадат повеќе активни скенирања за истиот клиент.
  • Успешните резултати ги задржуваат резултатот, групираните наоди, TLS-деталите, препораките и времето на завршување.
  • Препораките стануваат задачи што можат да се затворат, додека оригиналното скенирање останува непроменливо.
  • Авторизацијата спречува еден корисник на агенцијата да ги прегледува или ажурира клиентите на друг корисник.
  • Неуспесите на автентикацијата и валидацијата запираат веднаш; преодните неуспеси користат ограничен backoff.
  • Тестовите не прават мрежни повици и не содржат вистински токен за услугата.
  • Контролната табла точно го опишува резултатот и никогаш не го нарекува пенетрационо тестирање.

Трајната вредност не е копчето за скенирање. Таа е циклусот околу него: сними ограничена проценка, зачувај го она што е забележано, претвори ги препораките во работа со јасен сопственик и врати се подоцна за да измериш што се променило. Тоа претвора безбедносен API од еднократен извештај во смирена, повторлива услуга за клиенти.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.