feat(auth): prod X509-ключи OpenIddict с persistent self-signed (P0-1)

OpenIddictKeyConfigurator: dev — прежний RSA-ключ в App_Data (поведение не
менялось, шифрование access-token выключено); prod/stage — отдельные X509
сертификаты подписи и шифрования из конфига (OpenIddict:SigningCertPath /
EncryptionCertPath / CertPassword, можно env). Нет файла → генерируется
persistent self-signed (RSA 2048, 5 лет) и сохраняется в App_Data (volume),
а не dev-ephemeral — токены переживают рестарт.

Проверено: prod выдаёт 5-сегментный JWE, /api/me 200; рестарт → те же
сертификаты (fingerprint совпал), pre-restart токен валиден. dev — 3-сегментный
JWT, /api/me 200. docs/openiddict-keys.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-27 02:47:00 +05:00
parent 00964f587a
commit 422b7ad5ea
3 changed files with 173 additions and 21 deletions

65
docs/openiddict-keys.md Normal file
View file

@ -0,0 +1,65 @@
# Ключи OpenIddict (подпись и шифрование токенов)
Токены доступа/обновления подписываются (и в проде шифруются) ключами OpenIddict.
Конфигурация ключей — в `OpenIddictKeyConfigurator` (`src/food-market.api/Infrastructure/Security/`),
вызывается из `Program.cs` внутри `AddServer(...)`.
## Development
- Persistent RSA-ключ в `src/food-market.api/App_Data/openiddict-dev-key.xml`
(один и тот же для подписи и шифрования).
- Переживает рестарты — выданные токены остаются валидными между перезапусками.
- **Шифрование access-token выключено** (`DisableAccessTokenEncryption`) — токен это
обычный 3-сегментный JWT, удобно дебажить (можно прочитать на jwt.io).
- Файл `App_Data/` в `.gitignore` — ключ не коммитится.
## Production / Stage
- Отдельные **X509-сертификаты** для подписи и шифрования. Access-token шифруется
(5-сегментный JWE).
- Путь к сертификатам — из конфигурации:
| Ключ конфига | Env-переменная | Назначение | Дефолт |
|---|---|---|---|
| `OpenIddict:SigningCertPath` | `OpenIddict__SigningCertPath` | сертификат подписи | `App_Data/openiddict-signing.pfx` |
| `OpenIddict:EncryptionCertPath` | `OpenIddict__EncryptionCertPath` | сертификат шифрования | `App_Data/openiddict-encryption.pfx` |
| `OpenIddict:CertPassword` | `OpenIddict__CertPassword` | пароль PFX (опц.) | — |
- **Если файла нет** — генерируется persistent self-signed сертификат (RSA 2048, срок 5 лет)
и сохраняется по пути. При следующем старте берётся тот же файл, поэтому ранее
выданные токены остаются валидными (нет dev-ephemeral поведения, при котором каждый
рестарт инвалидировал бы все токены).
- `App_Data` смонтирован как volume (`api-data:/app/App_Data` в `docker-compose.yml`),
поэтому сертификаты переживают пересоздание контейнера.
### Принести собственные сертификаты
Положить готовые `.pfx` (с приватным ключом) по путям из конфига и, при наличии пароля,
задать `OpenIddict__CertPassword`. Приложение их подхватит вместо генерации self-signed.
```bash
# пример: смонтировать каталог с сертификатами и указать пути
OpenIddict__SigningCertPath=/run/secrets/oidc-signing.pfx
OpenIddict__EncryptionCertPath=/run/secrets/oidc-encryption.pfx
OpenIddict__CertPassword=<пароль или пусто>
```
### Ротация
1. Заменить/удалить `.pfx` файлы (или указать новые пути).
2. Рестарт API: при отсутствии файла сгенерируется новый сертификат.
3. **Важно:** ротация ключа подписи/шифрования инвалидирует все ранее выданные
токены — пользователям потребуется перелогиниться. Планировать на окно обслуживания.
### Проверка (smoke)
```bash
# 5 сегментов = JWE (шифрование включено) — норма для прода
curl -s -X POST $API/connect/token -H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password&username=...&password=...&client_id=food-market-web&scope=api" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'].count('.')+1,'сегментов')"
```
Проверено локально (2026-05-27): prod-режим генерирует оба сертификата в `App_Data`,
выдаёт 5-сегментный JWE, `/api/me` → 200; после рестарта сертификаты те же
(fingerprint совпадает), токен, выданный до рестарта, остаётся валиден.

View file

@ -0,0 +1,102 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Server;
namespace foodmarket.Api.Infrastructure.Security;
/// <summary>Настройка ключей подписи/шифрования токенов OpenIddict.
///
/// <para><b>Development</b> — persistent RSA-ключ в App_Data/openiddict-dev-key.xml
/// (как было): переживает рестарты, шифрование access-token выключено для удобства
/// отладки. Поведение не меняется.</para>
///
/// <para><b>Production/Stage</b> — настоящие X509-сертификаты (отдельные для подписи
/// и шифрования). Путь берётся из конфигурации (<c>OpenIddict:SigningCertPath</c> /
/// <c>OpenIddict:EncryptionCertPath</c>, можно через env), иначе — App_Data/*.pfx
/// (монтируется volume'ом, см. docker-compose). Если файла нет — генерируется
/// <b>persistent</b> self-signed сертификат (5 лет) и сохраняется по пути. Это
/// заменяет dev-ephemeral поведение: при рестарте берётся тот же сертификат,
/// поэтому ранее выданные токены остаются валидными.</para>
///
/// <para>Опциональный пароль PFX — <c>OpenIddict:CertPassword</c>.</para>
/// </summary>
public static class OpenIddictKeyConfigurator
{
private enum CertPurpose { Signing, Encryption }
public static void ConfigureSigningAndEncryption(
OpenIddictServerBuilder options, IConfiguration config, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
var rsa = LoadOrCreateDevRsa(
Path.Combine(env.ContentRootPath, "App_Data", "openiddict-dev-key.xml"));
var devKey = new RsaSecurityKey(rsa) { KeyId = "food-market-dev" };
options.AddEncryptionKey(devKey);
options.AddSigningKey(devKey);
options.DisableAccessTokenEncryption();
return;
}
var dataDir = Path.Combine(env.ContentRootPath, "App_Data");
var password = config["OpenIddict:CertPassword"];
var signingPath = config["OpenIddict:SigningCertPath"];
if (string.IsNullOrWhiteSpace(signingPath))
signingPath = Path.Combine(dataDir, "openiddict-signing.pfx");
var encryptionPath = config["OpenIddict:EncryptionCertPath"];
if (string.IsNullOrWhiteSpace(encryptionPath))
encryptionPath = Path.Combine(dataDir, "openiddict-encryption.pfx");
options.AddSigningCertificate(LoadOrCreateCertificate(signingPath, password, CertPurpose.Signing));
options.AddEncryptionCertificate(LoadOrCreateCertificate(encryptionPath, password, CertPurpose.Encryption));
}
private static RSA LoadOrCreateDevRsa(string keyPath)
{
var rsa = RSA.Create(2048);
if (File.Exists(keyPath))
{
rsa.FromXmlString(File.ReadAllText(keyPath));
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(keyPath)!);
File.WriteAllText(keyPath, rsa.ToXmlString(includePrivateParameters: true));
}
return rsa;
}
private static X509Certificate2 LoadOrCreateCertificate(string path, string? password, CertPurpose purpose)
{
// EphemeralKeySet — приватный ключ держим в памяти процесса, не пишем
// в системный keystore: в Linux-контейнере это самый переносимый вариант.
const X509KeyStorageFlags flags = X509KeyStorageFlags.EphemeralKeySet | X509KeyStorageFlags.Exportable;
if (File.Exists(path))
{
return new X509Certificate2(File.ReadAllBytes(path), password, flags);
}
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
$"CN=food-market-{purpose.ToString().ToLowerInvariant()}",
rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var usage = purpose == CertPurpose.Signing
? X509KeyUsageFlags.DigitalSignature
: X509KeyUsageFlags.KeyEncipherment;
request.CertificateExtensions.Add(new X509KeyUsageExtension(usage, critical: true));
using var generated = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(5));
var pfx = generated.Export(X509ContentType.Pfx, password);
File.WriteAllBytes(path, pfx);
return new X509Certificate2(pfx, password, flags);
}
}

View file

@ -72,27 +72,12 @@
opts.AcceptAnonymousClients();
opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api");
// Persistent dev keys: RSA key stored in src/food-market.api/App_Data/openiddict-dev-key.xml.
// Survives API restarts so issued tokens remain valid across rebuilds.
// Never commit this file (it's in .gitignore via App_Data/). Production must use real certificates.
var keyPath = Path.Combine(builder.Environment.ContentRootPath, "App_Data", "openiddict-dev-key.xml");
var rsa = System.Security.Cryptography.RSA.Create(2048);
if (File.Exists(keyPath))
{
rsa.FromXmlString(File.ReadAllText(keyPath));
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(keyPath)!);
File.WriteAllText(keyPath, rsa.ToXmlString(includePrivateParameters: true));
}
var devKey = new Microsoft.IdentityModel.Tokens.RsaSecurityKey(rsa) { KeyId = "food-market-dev" };
opts.AddEncryptionKey(devKey);
opts.AddSigningKey(devKey);
if (builder.Environment.IsDevelopment())
{
opts.DisableAccessTokenEncryption();
}
// Ключи подписи/шифрования: dev — persistent RSA-ключ в App_Data;
// prod/stage — X509-сертификаты из конфига (OpenIddict:SigningCertPath /
// EncryptionCertPath), persistent self-signed если файла нет.
// Подробности и dev-инвариант — в OpenIddictKeyConfigurator.
foodmarket.Api.Infrastructure.Security.OpenIddictKeyConfigurator
.ConfigureSigningAndEncryption(opts, builder.Configuration, builder.Environment);
opts.UseAspNetCore()
.EnableTokenEndpointPassthrough()