feat: encrypting & config
This commit is contained in:
@@ -6,3 +6,6 @@ POSTGRES_USER=webl
|
||||
POSTGRES_PASSWORD=webl_dev_password
|
||||
UID=1000
|
||||
GID=1000
|
||||
APP_ENV=local
|
||||
# для APP_KEY: make keygen
|
||||
APP_KEY=
|
||||
|
||||
@@ -5,7 +5,7 @@ include .env
|
||||
export
|
||||
endif
|
||||
|
||||
.PHONY: help init build up down ps logs clean setup composer migrate migrate-status migrate-rollback sh psql
|
||||
.PHONY: help init build up down ps logs clean setup composer migrate migrate-status migrate-rollback keygen config-show sh psql
|
||||
|
||||
help:
|
||||
@echo "Доступные команды:"
|
||||
@@ -21,6 +21,8 @@ help:
|
||||
@echo " make migrate - применить миграции базы данных (php bin/migrate up)"
|
||||
@echo " make migrate-status - показать статус миграций (php bin/migrate status)"
|
||||
@echo " make migrate-rollback - откатить последнюю миграцию (php bin/migrate down)"
|
||||
@echo " make keygen - сгенерировать APP_KEY (php bin/config keygen)"
|
||||
@echo " make config-show - показать итоговый конфиг (php bin/config show)"
|
||||
@echo " make sh - открыть shell в контейнере приложения"
|
||||
@echo " make psql - подключиться к PostgreSQL (psql)"
|
||||
|
||||
@@ -68,6 +70,14 @@ migrate-status:
|
||||
migrate-rollback:
|
||||
docker compose exec app php bin/migrate down
|
||||
|
||||
keygen:
|
||||
@KEY=$$(docker compose exec -T app php bin/config keygen | head -n1); \
|
||||
sed -i.bak -e "s|^APP_KEY=.*|APP_KEY=$$KEY|" .env && rm -f .env.bak; \
|
||||
echo "APP_KEY обновлён в .env. Перезапусти app: make up (старые enc:-значения нужно перешифровать)"
|
||||
|
||||
config-show:
|
||||
docker compose exec app php bin/config show
|
||||
|
||||
sh:
|
||||
docker compose exec app sh
|
||||
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
Дз по лекциям
|
||||
|
||||
## Возможности
|
||||
|
||||
1. **Заметки** - создание и просмотр заметок через веб-интерфейс (`app/public/index.php`, модель `App\Models\Note`).
|
||||
2. **Миграции БД** - версионирование схемы PostgreSQL через `php bin/migrate` и Eloquent.
|
||||
3. **Логирование запросов** - каждый HTTP-запрос пишется с `method`, `uri`, `status`, `body`.
|
||||
4. **Статический конфиг с шифрованными значениями** - файлы конфигурации по окружениям (`app/config/*.php`), шифрование значений через `App\Crypto\Encryptor` (openssl AES-256-GCM) и CLI-утилиты.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```sh
|
||||
@@ -25,7 +32,7 @@ make migrate # применить миграции
|
||||
## Команды
|
||||
|
||||
| Команда | Описание |
|
||||
|-------------------------|--------------------------------------------------------------|
|
||||
|-------------------------|-----------------------------------------------------------------------|
|
||||
| `make help` | Показать список всех команд |
|
||||
| `make init` | Создать `.env` из `.env.example`, подставить UID/GID |
|
||||
| `make build` | Собрать Docker-образы |
|
||||
@@ -39,6 +46,8 @@ make migrate # применить миграции
|
||||
| `make migrate` | Применить миграции (`php bin/migrate up`) |
|
||||
| `make migrate-status` | Статус миграций (`php bin/migrate status`) |
|
||||
| `make migrate-rollback` | Откатить последнюю миграцию (`php bin/migrate down`) |
|
||||
| `make keygen` | Сгенерировать `APP_KEY` и записать в `.env` (`php bin/config keygen`) |
|
||||
| `make config-show` | Показать конфиг текущего окружения (`php bin/config show`) |
|
||||
| `make sh` | Shell внутри контейнера приложения |
|
||||
| `make psql` | Подключиться к PostgreSQL |
|
||||
|
||||
@@ -49,6 +58,67 @@ make migrate # применить миграции
|
||||
- Консоль: `docker compose logs app`
|
||||
- Файлы: `app/storage/logs/app-*.log` (ротация 7 дней)
|
||||
|
||||
## Конфиг и шифрование
|
||||
|
||||
Окружение и ключ шифрования читаются из переменных окружения (`.env` -> `compose.yaml`):
|
||||
|
||||
| Переменная | Назначение | По умолчанию |
|
||||
|------------|------------------------------------------------------------------|--------------|
|
||||
| `APP_ENV` | Окружение: `local`, `dev` или `prod` | `local` |
|
||||
| `APP_KEY` | base64-ключ длиной 32 байта для AES-256-GCM | - |
|
||||
|
||||
Ключ: `.env` в `.gitignore`, а в `.env.example` лежит только плейсхолдер. Сгенерировать и записать ключ в `.env` можно командой `make keygen`. После смены ключа перезапустите приложение (`make up`) и перешифруйте `enc:`-значения - под новым ключом старые не расшифруются.
|
||||
|
||||
Файлы конфигурации лежат в `app/config/*.php` (например, `app.php`, `database.php`). Каждый файл возвращает массив с секциями по окружениям; значения окружения накладываются поверх `default` (`array_replace_recursive`):
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
return [
|
||||
'default' => [
|
||||
// общие значения для всех окружений
|
||||
],
|
||||
'local' => [
|
||||
// переопределения для APP_ENV=local
|
||||
],
|
||||
'dev' => [],
|
||||
'prod' => [],
|
||||
];
|
||||
```
|
||||
|
||||
Значения, начинающиеся с `enc:v1:`, расшифровываются автоматически при загрузке конфига. Конверт (`envelope`) имеет вид `enc:v1:` + `base64(iv | tag | ciphertext)`, шифр - `aes-256-gcm` (`App\Crypto\Encryptor`). В не-production окружениях битый шифротекст или отсутствующий ключ деградирует до `null` с предупреждением в лог; в `prod` в этом случае выбрасывается исключение.
|
||||
|
||||
Консольные утилиты (`bin/config`):
|
||||
|
||||
```sh
|
||||
docker compose exec -T app php bin/config keygen # сгенерировать APP_KEY
|
||||
docker compose exec -T app php bin/config encrypt "значение"
|
||||
docker compose exec -T app php bin/config decrypt "enc:v1:..."
|
||||
docker compose exec -T app php bin/config show # показать конфиг для APP_ENV
|
||||
|
||||
make keygen
|
||||
make config-show
|
||||
```
|
||||
|
||||
Внутри контейнера также доступен CLI `openssl` (`/usr/bin/openssl`, версия 3.5.7) - для ручных операций
|
||||
|
||||
## Схема БД
|
||||
|
||||
| Таблица | Ключевые колонки |
|
||||
|-----------------|-----------------------------------------------------------------------------------------------------------|
|
||||
| `users` | `user_id` (PK), `email`, `password_hash`, `name`, `created_datetime`, `updated_datetime` |
|
||||
| `boards` | `board_id` (PK), `title`, `owner_user_id` → `users.user_id`, `is_public`, `created_datetime`, `updated_datetime` |
|
||||
| `board_columns` | `column_id` (PK), `board_id` → `boards.board_id`, `ordinal_position`, `title`, `color` |
|
||||
| `cards` | `card_id` (PK), `board_id` → `boards.board_id`, `column_id` → `board_columns.column_id`, `sort_index`, `title`, `content`, `color`, `is_archived`, `created_datetime`, `updated_datetime` |
|
||||
|
||||
| Индекс | Тип | Таблица / колонки |
|
||||
|------------------------|-----------|------------------------------------------------------|
|
||||
| `idx_email` | UNIQUE | `users (email)` |
|
||||
| `idx_board_id` | обычный | `board_columns (board_id)` |
|
||||
| `idx_active_board_id` | PARTIAL | `cards (board_id)` WHERE `is_archived = false` |
|
||||
|
||||
Миграции лежат в `app/database/migrations`, применяются командой `make migrate` и откатываются `make migrate-rollback`.
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
@@ -59,7 +129,10 @@ make migrate # применить миграции
|
||||
└── app/ # PHP-код (монтируется в контейнер)
|
||||
├── public/ # index.php, router.php
|
||||
├── src/ # исходники
|
||||
├── bin/ # мигратор
|
||||
│ ├── Config/ # App\Config\Config - загрузка и слияние конфигов
|
||||
│ └── Crypto/ # App\Crypto\Encryptor - AES-256-GCM
|
||||
├── config/ # конфиги по окружениям (app.php, database.php, ...)
|
||||
├── bin/ # мигратор (migrate) и CLI конфига (config)
|
||||
├── database/ # миграции
|
||||
└── storage/logs/ # логи
|
||||
```
|
||||
@@ -67,7 +140,7 @@ make migrate # применить миграции
|
||||
## Технологии
|
||||
|
||||
- PHP 8.5 (`php:8.5-cli`, встроенный сервер)
|
||||
- Eloquent — illuminate/database 12 (standalone)
|
||||
- Eloquent - illuminate/database 12 (standalone)
|
||||
- Monolog 3
|
||||
- PostgreSQL 18 (`postgres:18-alpine`)
|
||||
- Docker Compose v2
|
||||
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Config\Config;
|
||||
use App\Crypto\Encryptor;
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
$format = static function (mixed $value): string {
|
||||
return match (true) {
|
||||
is_bool($value) => $value ? 'true' : 'false',
|
||||
$value === null => 'null',
|
||||
is_array($value) => json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]',
|
||||
default => (string) $value,
|
||||
};
|
||||
};
|
||||
|
||||
$command = $argv[1] ?? null;
|
||||
|
||||
try {
|
||||
switch ($command) {
|
||||
case 'keygen':
|
||||
echo Encryptor::generateKey(), "\n";
|
||||
echo "Помести ключ в APP_KEY (.env)\n";
|
||||
break;
|
||||
|
||||
case 'encrypt':
|
||||
if (!isset($argv[2])) {
|
||||
throw new \RuntimeException('Укажи значение: config encrypt <value>');
|
||||
}
|
||||
echo Encryptor::fromEnv()->encrypt($argv[2]), "\n";
|
||||
break;
|
||||
|
||||
case 'decrypt':
|
||||
if (!isset($argv[2])) {
|
||||
throw new \RuntimeException('Укажи значение: config decrypt <value>');
|
||||
}
|
||||
echo Encryptor::fromEnv()->decrypt($argv[2]), "\n";
|
||||
break;
|
||||
|
||||
case 'show':
|
||||
$config = Config::load();
|
||||
$filter = $argv[2] ?? null;
|
||||
$values = $config->all();
|
||||
|
||||
if ($filter !== null) {
|
||||
$values = array_filter(
|
||||
$values,
|
||||
static fn (string $key): bool => $key === $filter || str_starts_with($key, $filter . '.'),
|
||||
ARRAY_FILTER_USE_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
if ($values === []) {
|
||||
fwrite(STDERR, "Значения не найдены\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$width = max(array_map('strlen', array_keys($values)));
|
||||
foreach ($values as $key => $value) {
|
||||
echo str_pad($key, $width), ' = ', $format($value), "\n";
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
fwrite(STDERR, "Использование: config <keygen|encrypt|decrypt|show> [args]\n");
|
||||
fwrite(STDERR, " keygen - сгенерировать новый APP_KEY\n");
|
||||
fwrite(STDERR, " encrypt <value> - зашифровать значение\n");
|
||||
fwrite(STDERR, " decrypt <value> - расшифровать значение\n");
|
||||
fwrite(STDERR, " show [key] - показать конфиг текущего APP_ENV\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, 'Ошибка конфигурации: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'default' => [
|
||||
'name' => 'webl',
|
||||
'debug' => false,
|
||||
'timezone' => 'Europe/Moscow',
|
||||
],
|
||||
'local' => [
|
||||
'debug' => true,
|
||||
],
|
||||
'dev' => [
|
||||
'debug' => true,
|
||||
],
|
||||
'prod' => [],
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'default' => [
|
||||
'driver' => 'pgsql',
|
||||
'host' => 'db',
|
||||
'port' => 5432,
|
||||
'database' => 'webl',
|
||||
'username' => 'webl',
|
||||
],
|
||||
'local' => [
|
||||
'driver' => 'pgsql',
|
||||
'host' => 'db',
|
||||
'port' => 5432,
|
||||
'database' => 'webl',
|
||||
'username' => 'webl',
|
||||
],
|
||||
'prod' => [
|
||||
'debug_sql' => false,
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
use App\Crypto\Encryptor;
|
||||
|
||||
final class Config {
|
||||
private static ?self $instance = null;
|
||||
|
||||
private function __construct(
|
||||
private readonly string $env,
|
||||
private readonly array $sections,
|
||||
private readonly array $data,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function load(?string $env = null): self {
|
||||
$env ??= (getenv('APP_ENV') ?: 'local');
|
||||
|
||||
$encryptor = null;
|
||||
$sections = [];
|
||||
|
||||
foreach (glob(__DIR__ . '/../../config/*.php') ?: [] as $file) {
|
||||
$section = basename($file, '.php');
|
||||
$data = require $file;
|
||||
if (!is_array($data)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sections[$section] = array_replace_recursive($data['default'] ?? [], $data[$env] ?? []);
|
||||
}
|
||||
|
||||
foreach ($sections as $section => $values) {
|
||||
$sections[$section] = self::decryptValues($values, $section, $env, $encryptor);
|
||||
}
|
||||
|
||||
$data = [];
|
||||
foreach ($sections as $section => $values) {
|
||||
foreach (self::flatten($values, $section) as $key => $value) {
|
||||
$data[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return self::$instance = new self($env, $sections, $data);
|
||||
}
|
||||
|
||||
public static function instance(): self {
|
||||
return self::$instance ??= self::load();
|
||||
}
|
||||
|
||||
public static function get(string $key, mixed $default = null): mixed {
|
||||
$config = self::instance();
|
||||
|
||||
if (array_key_exists($key, $config->data)) {
|
||||
return $config->data[$key];
|
||||
}
|
||||
|
||||
$value = $config->sections;
|
||||
foreach (explode('.', $key) as $segment) {
|
||||
if (!is_array($value) || !array_key_exists($segment, $value)) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$segment];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function all(): array {
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
public function env(): string {
|
||||
return $this->env;
|
||||
}
|
||||
|
||||
private static function decryptValues(array $values, string $prefix, string $env, ?Encryptor &$encryptor): array {
|
||||
foreach ($values as $key => $value) {
|
||||
$dotKey = $prefix . '.' . $key;
|
||||
|
||||
if (is_array($value)) {
|
||||
$values[$key] = self::decryptValues($value, $dotKey, $env, $encryptor);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_string($value) || !str_starts_with($value, Encryptor::MARKER)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$encryptor ??= Encryptor::fromEnv();
|
||||
$values[$key] = $encryptor->decrypt($value);
|
||||
} catch (\Throwable $e) {
|
||||
if ($env === 'prod') {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
\App\Logger::get()->warning('config.decrypt_failed', [
|
||||
'key' => $dotKey,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$values[$key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
private static function flatten(array $values, string $prefix): array {
|
||||
$flat = [];
|
||||
foreach ($values as $key => $value) {
|
||||
$dotKey = $prefix . '.' . $key;
|
||||
if (is_array($value) && $value !== []) {
|
||||
$flat += self::flatten($value, $dotKey);
|
||||
continue;
|
||||
}
|
||||
$flat[$dotKey] = $value;
|
||||
}
|
||||
|
||||
return $flat;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Crypto;
|
||||
|
||||
final class Encryptor {
|
||||
public const MARKER = 'enc:';
|
||||
public const CIPHER = 'aes-256-gcm';
|
||||
public const VERSION = 'v1';
|
||||
|
||||
private const IV_LENGTH = 12;
|
||||
private const TAG_LENGTH = 16;
|
||||
|
||||
private function __construct(private readonly string $key) {
|
||||
}
|
||||
|
||||
public static function create(string $base64Key): self {
|
||||
$key = base64_decode($base64Key, true);
|
||||
if ($key === false || strlen($key) !== 32) {
|
||||
throw new \RuntimeException('APP_KEY должен быть base64 строкой из 32 байт, для генерации используй: make keygen');
|
||||
}
|
||||
|
||||
return new self($key);
|
||||
}
|
||||
|
||||
public static function fromEnv(): self {
|
||||
$key = getenv('APP_KEY');
|
||||
if ($key === false || $key === '') {
|
||||
throw new \RuntimeException('Нету APP_KEY, сгенерируйте при помощи: make keygen');
|
||||
}
|
||||
|
||||
return self::create($key);
|
||||
}
|
||||
|
||||
public static function generateKey(): string {
|
||||
return base64_encode(random_bytes(32));
|
||||
}
|
||||
|
||||
public function encrypt(string $plaintext): string {
|
||||
$iv = random_bytes(self::IV_LENGTH);
|
||||
$tag = '';
|
||||
|
||||
$ciphertext = openssl_encrypt($plaintext, self::CIPHER, $this->key, OPENSSL_RAW_DATA, $iv, $tag, '', self::TAG_LENGTH);
|
||||
if ($ciphertext === false) {
|
||||
throw new \RuntimeException('Не удалось зашифровать');
|
||||
}
|
||||
|
||||
return self::MARKER . self::VERSION . ':' . base64_encode($iv . $tag . $ciphertext);
|
||||
}
|
||||
|
||||
public function decrypt(string $payload): string {
|
||||
if (str_starts_with($payload, self::MARKER)) {
|
||||
$payload = substr($payload, strlen(self::MARKER));
|
||||
}
|
||||
|
||||
if (!str_starts_with($payload, self::VERSION . ':')) {
|
||||
throw new \RuntimeException('Неизвестная версия шифра');
|
||||
}
|
||||
|
||||
$raw = base64_decode(substr($payload, strlen(self::VERSION) + 1), true);
|
||||
if ($raw === false || strlen($raw) < self::IV_LENGTH + self::TAG_LENGTH) {
|
||||
throw new \RuntimeException('Битый шифр');
|
||||
}
|
||||
|
||||
$iv = substr($raw, 0, self::IV_LENGTH);
|
||||
$tag = substr($raw, self::IV_LENGTH, self::TAG_LENGTH);
|
||||
$ciphertext = substr($raw, self::IV_LENGTH + self::TAG_LENGTH);
|
||||
|
||||
$plaintext = openssl_decrypt($ciphertext, self::CIPHER, $this->key, OPENSSL_RAW_DATA, $iv, $tag);
|
||||
if ($plaintext === false) {
|
||||
throw new \RuntimeException('Не удалось расшифровать: тег не тот');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
|
||||
public function isEncrypted(string $value): bool {
|
||||
return str_starts_with($value, self::MARKER);
|
||||
}
|
||||
|
||||
public function decryptIfEncrypted(string $value): string {
|
||||
return $this->isEncrypted($value) ? $this->decrypt($value) : $value;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ services:
|
||||
DB_NAME: ${POSTGRES_DB}
|
||||
DB_USER: ${POSTGRES_USER}
|
||||
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
APP_ENV: ${APP_ENV:-local}
|
||||
APP_KEY: ${APP_KEY:-}
|
||||
volumes:
|
||||
- ./app:/var/www/html
|
||||
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/zz-dev.ini:ro
|
||||
|
||||
@@ -13,3 +13,5 @@ RUN groupmod -o -g "${GID}" www-data \
|
||||
&& usermod -o -u "${UID}" -g "${GID}" www-data
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
RUN php -r 'exit((int)!extension_loaded("openssl"));'
|
||||
|
||||
Reference in New Issue
Block a user