first commit

This commit is contained in:
brusnitsyn
2026-06-24 17:20:43 +09:00
commit 43499acf1c
165 changed files with 25929 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
<?php
namespace App\Services\Audit;
use App\Models\AuditLog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
/**
* Журналирование событий безопасности (меры РСБ.*).
*
* - РСБ.2: запись событий с обязательными атрибутами (время, субъект, IP,
* объект, результат);
* - РСБ.3: HMAC-подпись каждой записи + хеш-цепочка (prev_hash) для контроля
* целостности и выявления удаления записей;
* - РСБ.7: дублирование в SIEM-канал;
*
* ВАЖНО: в журнал не записываются сами персональные данные только
* идентификаторы объектов (раздел 7.3 гайда).
*/
class AuditService
{
/**
* Зарегистрировать событие безопасности.
*
* @param array<string,mixed> $details Контекст без ПДн.
*/
public function log(
string $eventType,
string $action = '',
?string $resource = null,
string $result = 'success',
array $details = [],
): AuditLog {
$request = $this->currentRequest();
$entry = new AuditLog([
'event_id' => (string) Str::uuid(),
'event_type' => $eventType,
'user_id' => optional(auth()->user())->getAuthIdentifier(),
'ip' => $request?->ip(),
'user_agent' => $request ? Str::limit((string) $request->userAgent(), 500, '') : null,
'session_id' => $this->sessionId($request),
'resource' => $resource,
'action' => $action !== '' ? $action : $eventType,
'result' => $result,
'details' => $details,
]);
// Фиксируем время с точностью до секунды, чтобы подпись считалась от той
// же величины, что сохранится в колонке created_at (без микросекунд).
$entry->created_at = now()->startOfSecond();
$entry->prev_hash = $this->lastHash();
$entry->signature = $this->sign($entry);
$entry->save();
$this->mirrorToSiem($entry);
return $entry;
}
/**
* Проверить целостность журнала (HMAC + цепочка). Используется командой
* проверки и при аудите. Возвращает массив id повреждённых записей.
*
* @return array<int, mixed>
*/
public function verifyIntegrity(): array
{
$broken = [];
$prevHash = null;
AuditLog::query()->orderBy('id')->each(function (AuditLog $entry) use (&$broken, &$prevHash): void {
$chainOk = config('audit.chain') ? $entry->prev_hash === $prevHash : true;
$sigOk = hash_equals($entry->signature, $this->sign($entry));
if (! $chainOk || ! $sigOk) {
$broken[] = $entry->getKey();
}
$prevHash = $entry->signature;
});
return $broken;
}
/**
* Каноничное представление записи для подписи только хранимые колонки
* в фиксированном порядке, время берётся из created_at.
*/
private function sign(AuditLog $entry): string
{
$canonical = [
'event_id' => $entry->event_id,
'event_time' => optional($entry->created_at)?->startOfSecond()->toIso8601String(),
'event_type' => $entry->event_type,
'user_id' => $entry->user_id,
'ip' => $entry->ip,
'user_agent' => $entry->user_agent,
'session_id' => $entry->session_id,
'resource' => $entry->resource,
'action' => $entry->action,
'result' => $entry->result,
'details' => $entry->details,
'prev_hash' => $entry->prev_hash,
];
$json = json_encode($canonical, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return hash_hmac('sha256', (string) $json, $this->hmacKey());
}
private function lastHash(): ?string
{
if (! config('audit.chain')) {
return null;
}
return AuditLog::query()->orderByDesc('id')->value('signature');
}
private function hmacKey(): string
{
$key = (string) config('audit.hmac_key');
if ($key === '') {
throw new RuntimeException('Не задан AUDIT_HMAC_KEY для подписи журнала аудита.');
}
return str_starts_with($key, 'base64:')
? (string) base64_decode(substr($key, 7), true)
: $key;
}
private function mirrorToSiem(AuditLog $entry): void
{
if (! config('audit.siem.enabled')) {
return;
}
try {
Log::channel(config('audit.siem.channel', 'siem'))->info('audit', $entry->getAttributes());
} catch (Throwable $e) {
// Сбой SIEM не должен прерывать основную запись журнала.
Log::error('audit.siem_failed', ['error' => $e->getMessage()]);
}
}
private function currentRequest(): ?Request
{
return app()->bound('request') ? app('request') : null;
}
private function sessionId(?Request $request): ?string
{
if ($request === null || ! $request->hasSession()) {
return null;
}
return $request->session()->getId();
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Services\Auth;
use App\Models\User;
use App\Support\PasswordPolicy;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
/**
* Управление сменой пароля с ведением истории (меры ИАФ.3).
*/
class PasswordManager
{
/**
* Установить новый пароль: сохранить старый в историю, обновить метку
* времени смены и подрезать историю до лимита.
*/
public function change(User $user, string $newPassword): void
{
DB::transaction(function () use ($user, $newPassword): void {
// Сохраняем текущий пароль в историю до перезаписи.
if ($user->password) {
$user->passwordHistories()->create([
'password_hash' => $user->password,
'created_at' => now(),
]);
}
$user->forceFill([
'password' => Hash::make($newPassword),
'password_changed_at' => now(),
])->save();
$this->trimHistory($user);
});
}
private function trimHistory(User $user): void
{
$limit = PasswordPolicy::historyLimit();
$ids = $user->passwordHistories()
->orderByDesc('created_at')
->skip($limit)
->take(PHP_INT_MAX)
->pluck('id');
if ($ids->isNotEmpty()) {
$user->passwordHistories()->whereIn('id', $ids)->delete();
}
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Services\Auth;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use PragmaRX\Google2FA\Google2FA;
/**
* Многофакторная аутентификация по TOTP (мера ИАФ.4).
*
* Секрет и резервные коды хранятся в зашифрованном виде (ЗНИ) поля помечены
* как encrypted в модели User. Резервные коды дополнительно хешируются.
*/
class TwoFactorService
{
public function __construct(private readonly Google2FA $engine) {}
public function generateSecret(): string
{
return $this->engine->generateSecretKey();
}
/**
* URI для QR-кода (otpauth://) добавляется в приложение-аутентификатор.
*/
public function provisioningUri(User $user, string $secret): string
{
return $this->engine->getQRCodeUrl(
(string) config('app.name'),
(string) $user->email,
$secret,
);
}
public function verify(string $secret, string $code): bool
{
// verifyKey возвращает позицию окна (int) при успехе либо false.
return $this->engine->verifyKey($secret, $code) !== false;
}
/**
* Сгенерировать набор одноразовых резервных кодов.
*
* @return array<int, string>
*/
public function generateRecoveryCodes(): array
{
$count = (int) config('security.mfa.recovery_codes');
return Collection::times($count, fn () => Str::upper(Str::random(5).'-'.Str::random(5)))
->all();
}
/**
* @param array<int, string> $codes
* @return array<int, string>
*/
public function hashRecoveryCodes(array $codes): array
{
return array_map(static fn (string $code) => Hash::make($code), $codes);
}
/**
* Проверить и «погасить» резервный код. Возвращает новый список хешей или
* null, если код не подошёл.
*
* @param array<int, string> $hashedCodes
* @return array<int, string>|null
*/
public function consumeRecoveryCode(array $hashedCodes, string $input): ?array
{
foreach ($hashedCodes as $index => $hash) {
if (Hash::check($input, $hash)) {
unset($hashedCodes[$index]);
return array_values($hashedCodes);
}
}
return null;
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Services\Crypto;
use RuntimeException;
/**
* ЗАГЛУШКА драйвера шифрования по ГОСТ Р 34.12-2015 («Кузнечик»/«Магма»).
*
* Мера ФСТЭК/ФСБ: ЗИС.16, приказ ФСБ №378 применение сертифицированных СКЗИ.
*
* Для государственных ИСПДн встроенный AES не является сертифицированным СКЗИ.
* Реальная реализация должна вызывать внешний криптопровайдер (КриптоПро CSP,
* VipNet) например через CLI `csptest`, расширение PHP с поддержкой ГОСТ или
* gRPC/REST-шлюз к СКЗИ. Здесь намеренно оставлен каркас, чтобы:
* - не создавать ложного ощущения сертифицированной защиты;
* - дать понятную точку интеграции при аттестации.
*
* Чтобы активировать: SECURITY_PDN_CIPHER_DRIVER=gost и реализовать методы.
*/
class GostCipher implements PdnCipher
{
/**
* @param array{binary:?string,container:?string} $config
*/
public function __construct(private readonly array $config) {}
public function encrypt(string $plaintext): string
{
throw new RuntimeException(
'Драйвер ГОСТ не реализован. Подключите сертифицированное СКЗИ '
.'(КриптоПро CSP / VipNet) в App\Services\Crypto\GostCipher. '
.'См. docs/security/crypto-gost.md.'
);
}
public function decrypt(string $ciphertext): string
{
throw new RuntimeException(
'Драйвер ГОСТ не реализован. Подключите сертифицированное СКЗИ '
.'(КриптоПро CSP / VipNet) в App\Services\Crypto\GostCipher.'
);
}
public function algorithm(): string
{
return 'GOST-R-34.12-2015';
}
/**
* Настроено ли внешнее СКЗИ (путь к бинарю и контейнер ключа).
* Используйте в health-check перед включением драйвера gost.
*/
public function isAvailable(): bool
{
return ! empty($this->config['binary'])
&& ! empty($this->config['container'])
&& is_executable((string) $this->config['binary']);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Services\Crypto;
use Illuminate\Contracts\Encryption\Encrypter;
/**
* Драйвер шифрования на встроенном шифраторе Laravel (AES-256-GCM).
*
* Применяется по умолчанию. Для государственных ИС, требующих
* сертифицированные СКЗИ, замените на {@see GostCipher}.
*/
class LaravelAesCipher implements PdnCipher
{
public function __construct(private readonly Encrypter $encrypter) {}
public function encrypt(string $plaintext): string
{
return $this->encrypter->encryptString($plaintext);
}
public function decrypt(string $ciphertext): string
{
return $this->encrypter->decryptString($ciphertext);
}
public function algorithm(): string
{
return 'AES-256-GCM';
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Services\Crypto;
/**
* Контракт криптографического драйвера для защиты персональных данных.
*
* Мера ФСТЭК: ЗНИ (защита носителей информации), ОЦЛ.2.
* Абстракция позволяет менять реализацию (AES Laravel ГОСТ/КриптоПро)
* без изменения моделей и бизнес-логики (мера ЗИС.16).
*/
interface PdnCipher
{
/**
* Зашифровать значение.
*/
public function encrypt(string $plaintext): string;
/**
* Расшифровать значение.
*/
public function decrypt(string $ciphertext): string;
/**
* Идентификатор алгоритма (для аудита и маркировки).
*/
public function algorithm(): string;
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Services\Pdn;
use RuntimeException;
/**
* Псевдонимизация и маскирование персональных данных.
*
* Мера ФСТЭК / 152-ФЗ ст. 3 п.9: псевдонимизация для аналитики и логов,
* чтобы исключить прямые идентификаторы (раздел 5.3 гайда).
*/
class PdnAnonymizer
{
/**
* Детерминированный псевдоним субъекта (HMAC-SHA256).
* Один и тот же вход даёт один и тот же псевдоним пригодно для join'ов
* в аналитике без раскрытия исходного идентификатора.
*/
public function pseudonym(string $value): string
{
$key = (string) config('security.encryption.pseudonym_key');
if ($key === '') {
throw new RuntimeException(
'Не задан SECURITY_PSEUDONYM_KEY для псевдонимизации ПДн.'
);
}
return hash_hmac('sha256', $value, $this->normalizeKey($key));
}
/**
* Маскирование значения для отображения (например в журналах/UI оператора).
*
* Пример: "Иванов" -> "И****", "ivan@mail.ru" -> "iv***@mail.ru".
*/
public function mask(string $value, int $visible = 2): string
{
if (str_contains($value, '@')) {
[$local, $domain] = explode('@', $value, 2);
return $this->mask($local, $visible).'@'.$domain;
}
$length = mb_strlen($value);
if ($length <= $visible) {
return str_repeat('*', max($length, 1));
}
return mb_substr($value, 0, $visible).str_repeat('*', $length - $visible);
}
private function normalizeKey(string $key): string
{
if (str_starts_with($key, 'base64:')) {
return (string) base64_decode(substr($key, 7), true);
}
return $key;
}
}