Введение

В последнее время я работаю над инструментами, упрощающими взаимодействие с CLI-утилитами из PHP. Мой путь начался с создания библиотеки php-fluent-console, которая предоставляет элегантный "текучий" интерфейс для генерации команд командной строки, идеально подходящий для построения собственных PHP-библиотек, обертывающих CLI-приложения.

На основе php-fluent-console я создал CryptoProBuilder — вторую библиотеку, предназначенную для гибкой работы с утилитами "КриптоПро" через тот же интуитивно понятный текучий интерфейс и динамические методы. Это позволяет выполнять операции с криптографией без необходимости вручную формировать сложные команды. Обе библиотеки имеют MIT лицензию, документацию и доступны для скачивания на GitHub и Packagist.

Не так давно я также написал статью о создании аналога модуля КриптоПро PDF на C# для визуализации электронных подписей на PDF-документах. Это решение особенно актуально, учитывая, что коммерческие версии подобных продуктов могут стоить десятки тысяч рублей.

Теперь, с последними разработками, я готов представить, как все эти кусочки складываются в единое решение на базе Laravel.

В чем суть проблемы и наше решение?

Многие системы требуют, чтобы электронные подписи на документах были не только криптографически корректными, но и визуально отображались на самом документе. Это крайне важно для некоторых пользователей, которым нужно быстро убедиться в подлинности документа без использования специализированного ПО. Так же это позволит нам интегрироваться с внешними системами, которые требуют наличие визуализации.

Мое решение предлагает доступную для каждого разработчика альтернативу: выполнять всю работу по подписанию и визуализации на стороне сервера Laravel, используя возможности CryptoProBuilder для взаимодействия с утилитами КриптоПро и библиотеку mPDF для визуализации.

Ключевые преимущества такого подхода:

  • Никаких установок SDK на сервере: Это одно из самых больших преимуществ. CryptoProBuilder работает, вызывая стандартные CLI-утилиты КриптоПро (certmgr, csptest и т.д.), которые уже должны быть доступны в вашей серверной среде. Это значительно упрощает развертывание и обслуживание.

  • Текучий интерфейс: Благодаря php-fluent-console, работа с CLI-утилитами становится интуитивно понятной и читаемой. Вы строите команды как цепочку методов, что минимизирует ошибки и ускоряет разработку.

  • Собственная реализация визуализации: Отсутствие зависимости от сторонних платных решений для визуализации подписи на PDF. Вы сами контролируете, как выглядит штамп подписи, его расположение и содержимое, используя mPDF.

  • Полный контроль над процессом: Вы полностью контролируете процесс от выбора сертификата до размещения штампа и получения подписанного файла. Это позволяет создавать очень специфичные и кастомизированные решения.

  • Идеально для интеграций: Если ваша система должна интегрироваться с другими сервисами, которые ожидают PDF-документы с уже визуализированной подписью, это решение подходит идеально, поскольку вся подготовка документа происходит на вашей стороне.

Пример Реализации в Laravel

Структура проекта:

  • App\Http\Controllers\TestController.php: Основной контроллер для обработки запросов. Конечно, мы могли бы вынести реализацию в отдельные сервисы но для тестирования нам это не нужно.

  • routes/web.php: Определения маршрутов.

  • resources/views/signature.blade.php: Форма для выбора файла и сертификата.

  • resources/views/signed.blade.php: Отображение подписанного файла.

  • storage/app/public: Временное хранилище для файлов.

Laravel Контроллер (TestController.php)

<?php 

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use CryptoProBuilder\CryptoPro;
use Mpdf\Mpdf;

class TestController extends Controller
{
    public function index()
    {
        $certificates = [];

        try {
            $result = new CryptoPro("certmgr.exe")
                ->getCertificates()
                ->encoding('866')
                ->store('uMy')
                ->run();

            $certificates = array_merge($certificates, array_is_list($result) ? $result : [$result]);
        } catch (\Exception $e) {
            Log::info($e->getMessage());
        }

        return view('signature', ['certificates' => array_map(fn($item) => (object) $item, $certificates)]);

    }

    public function getCertificate(string $thumbprint): array
    {
        try {
            return (new CryptoPro("certmgr.exe"))
                ->getCertificateByTp()
                ->encoding('866')
                ->store('uMy')
                ->thumbprint($thumbprint)
                ->run();
        } catch (\Exception $e) {
            Log::info($e->getMessage());
            abort(404, 'Сертификат не найден');
        }
    }

    public function sign(Request $request)
    {
        $request->validate([
            'file' => 'required|file',
            'pass' => 'string|max:16|nullable',
            'certificate' => 'required|string'
        ]);

        $thumbprint = $request->input('certificate');
        $file = $request->file('file');
        $originalFileName = $file->getClientOriginalName();

        $certificateInfo = $this->getCertificate($thumbprint);

        $tempPath = storage_path('app/public/temp_' . $originalFileName);
        copy($file->getRealPath(), $tempPath);

        $visualizedPath = $this->visualization($tempPath, $originalFileName, $certificateInfo);
        $relativePath = $originalFileName;
        $absolutePath = storage_path('app/public/' . $relativePath);

        $signedOutputPath = storage_path('app/public/' . $originalFileName . '.sig');

        try {
            $cryptoPro = new CryptoPro();
            $cryptoPro->signDocument()
                ->in($absolutePath)
                ->out($signedOutputPath);

            if ($request->filled('pass')) {
                $cryptoPro->password($request->input('pass'));
            }

            $cryptoPro->my($thumbprint)
                ->detached()
                ->addsigtime()
                ->base64()
                ->silent()
                ->run();
        } catch (\Exception $e) {
            if (file_exists($tempPath)) {
                unlink($tempPath);
            }

            if (file_exists($absolutePath)) {
                unlink($absolutePath);
            }

            Log::info($e->getMessage());
            abort(500, 'Ошибка подписи файла');
        }

        if (file_exists($tempPath)) {
            unlink($tempPath);
        }

        return view('signed', ['signedfile' => $relativePath]);
    }

    public function visualization(string $sourcePath, string $originalName, array $cert): string
    {
        try {
            $mpdf = new Mpdf();
            $pageCount = $mpdf->setSourceFile($sourcePath);

            for ($i = 1; $i <= $pageCount; $i++) {
                $templateId = $mpdf->importPage($i);
                $mpdf->AddPage();
                $mpdf->UseTemplate($templateId);
            }

            $width = 100;
            $height = 30;
            $padding = 5;
            $innerPad = 2;
            $lineHeight = 4;

            $x = $mpdf->w - $width - $padding;
            $y = $mpdf->h - $height - $padding;

            $mpdf->SetDrawColor(58, 76, 192);
            $mpdf->SetTextColor(58, 76, 192);
            $mpdf->SetFont('dejavusans', '', 8);

            $mpdf->RoundedRect($x, $y, $width, $height - 10, 3, '1111');
            $mpdf->SetXY($x + $innerPad, $y + $innerPad);

            $mpdf->Cell($width - 2 * $innerPad, $lineHeight, 'Документ подписан электронной подписью', 0, 1, 'L');
            $mpdf->SetX($x + $innerPad);
            $mpdf->Cell($width - 2 * $innerPad, $lineHeight, 'Владелец: ' . $cert['subject'], 0, 1, 'L');
            $mpdf->SetX($x + $innerPad);
            $mpdf->Cell($width - 2 * $innerPad, $lineHeight, 'Сертификат: ' . $cert['serialNumber'], 0, 1, 'L');
            $mpdf->SetX($x + $innerPad);

            $validity = 'Срок: ' . date('Y-m-d', strtotime($cert['issued'])) . ' – ' . date('Y-m-d', strtotime($cert['expires']));
            $mpdf->Cell($width - 2 * $innerPad, $lineHeight, $validity, 0, 1, 'L');

            $outPath = storage_path('app/public/' . $originalName);
            $mpdf->Output($outPath, 'F');

            return $outPath;
        } catch (\Exception $e) {
            Log::error($e->getMessage());
            abort(500, 'Ошибка генерации PDF с визуализацией');
        }
    }
}

Краткое описание методов:

  • index - получает и выводит на страницу список доступных сертификатов.

  • getCertificate - находит сертификат по отпечатку и возвращает массив с полями, и значениями, которые будут использоваться для визуализации.

  • visualization - добавляет визуализацию на PDF документ и возвращает путь к обработанному документу.

  • sign - подписывает и выводит на страницу документ с визуализацией.

Laravel Роутер (routes/web.php)

<?php

use App\Http\Controllers\TestController;
use Illuminate\Support\Facades\Route;

// Маршрут для отображения формы подписи
Route::get('/signatures', [TestController::class, 'index'])->name('signatures');

// Маршрут для обработки запроса на подписание
Route::post('/sign', [TestController::class, 'sign'])->name('sign');

Blade-шаблон для формы подписания (resources/views/signature.blade.php)

<form method="POST" action="{{ route('sign')}}" enctype="multipart/form-data">
	@method('POST')
	@CSRF
	<div class="form-group">
		<label for="certificate" class="form-label">Сертификат</label>
		<select type="list" class="form-control @error('certificate') is-invalid @enderror" name="certificate" id="certificate">
			@if($certificates)
				@foreach($certificates as $cont)
					<option value="{{$cont->sha1}}">{{"$cont->subject, срок действия $cont->expires"}}</option>
				@endforeach
			@endif
		</select>
		@error('certificate')
			<div class="invalid-feedback">{{ $message }}</div>
		@enderror
	</div>
	<div class="form-group mt-4">
		<label for="pass" class="form-label">Пароль контейнера</label>
		<input type="password" name="pass" id="pass" class="form-control @error('pass') is-invalid @enderror">
		@error('pass')
			<div class="invalid-feedback">{{ $message }}</div>
		@enderror
	</div>
	<div class="form-group mt-4">
		<label for="file" class="form-label">PDF файл</label>
		<input type="file" name="file" id="file" class="form-control @error('file') is-invalid @enderror">
		@error('file')
			<div class="invalid-feedback">{{ $message }}</div>
		@enderror
	</div>
	<div class="form-group mt-4">
		<button type="submit" class="btn btn-success">Подписать</button>
	</div>
</form>

Blade-шаблон для отображения подписанного документа (resources/views/signed.blade.php)

@section('content')
	<iframe class="vw-100 vh-100" src="{{ asset('storage/' . $signedfile) }}"></iframe>
@endsection

Подготовка Среды

Для работы этого примера вам понадобится:

  1. Laravel 10/11: Установите новый проект Laravel.

  2. Composer: Для управления зависимостями.

  3. КриптоПро CSP: Установленный на сервере (Windows или Linux) с доступом к certmgr и csptest через системный PATH или указанием полного пути при инициализации CryptoPro.

  4. Библиотеки CryptoProBuilder и mPDF:

composer require mikhailovlab/crypto-pro-builder
composer require mpdf/mpdf
  1. Настройка storage:link: Убедитесь, что у вас есть симлинк public/storage на storage/app/public для доступа к файлам через веб.

php artisan storage:link
  1. Настройка шрифтов для mPDF: Для корректного отображения кириллицы в штампе подписи, убедитесь, что шрифт dejavusanscondensed или другой подходящий шрифт установлен в вашей инсталляции mPDF. Подробнее о шрифтах mPDF можно найти в их официальной документации.

Тестируем функционал:

Выбираем сертификат из списка, вводим пароль контейнера, выбираем pdf файл, жмем "подписать"

Документ подписан отсоединенной электронной подписью, визуализация добавлена.

При необходимости мы так же можем осуществить проверку подписи через инструменты КриптоПро, что бы удостовериться что подпись валидна.

Заключение

Эта статья наглядно продемонстрировала, как можно реализовать серверное подписание PDF-документов с динамической визуализацией электронных подписей в рамках экосистемы Laravel. Используя библиотеки php-fluent-console и CryptoProBuilder в связке с mPDF, мы показали не только техническую возможность, но и значительные преимущества такого подхода.

Мы обошли необходимость установки сложных SDK, заменив их гибким взаимодействием со стандартными утилитами КриптоПро через интуитивно понятный fluent-интерфейс. Это позволяет достичь высокого уровня кастомизации визуального представления подписи и полностью контролировать процесс обработки документов на сервере.

Представленный в статье код является рабочим тестовым примером, иллюстрирующим архитектурные принципы. Это не жесткий стандарт или единственно верное решение, а скорее отправная точка, которую разработчики могут адаптировать и расширять по своему усмотрению. Вы можете модифицировать логику работы с файлами, внешний вид визуализации, способы получения информации о сертификатах и интеграцию с другими частями вашей системы.

Надеюсь, что этот пример вдохновит вас на создание собственных мощных и гибких решений для работы с электронными подписями, открывая новые возможности для автоматизации документооборота в ваших проектах.

Ознакомиться с документацией для CryptoProBuilder и поставить здезды можно на:
GitHub
Packagist

PhpFluentConsole:
GitHub
Packagist

Если есть вопросы — пишите в комментариях, постараюсь помочь.

Комментарии (0)