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:
parent
00964f587a
commit
422b7ad5ea
65
docs/openiddict-keys.md
Normal file
65
docs/openiddict-keys.md
Normal 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 совпадает), токен, выданный до рестарта, остаётся валиден.
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue