first commit
This commit is contained in:
165
app/Services/Audit/AuditService.php
Normal file
165
app/Services/Audit/AuditService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
53
app/Services/Auth/PasswordManager.php
Normal file
53
app/Services/Auth/PasswordManager.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
85
app/Services/Auth/TwoFactorService.php
Normal file
85
app/Services/Auth/TwoFactorService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
60
app/Services/Crypto/GostCipher.php
Normal file
60
app/Services/Crypto/GostCipher.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
31
app/Services/Crypto/LaravelAesCipher.php
Normal file
31
app/Services/Crypto/LaravelAesCipher.php
Normal 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';
|
||||
}
|
||||
}
|
||||
28
app/Services/Crypto/PdnCipher.php
Normal file
28
app/Services/Crypto/PdnCipher.php
Normal 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;
|
||||
}
|
||||
63
app/Services/Pdn/PdnAnonymizer.php
Normal file
63
app/Services/Pdn/PdnAnonymizer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user