diff --git a/docs/openiddict-keys.md b/docs/openiddict-keys.md new file mode 100644 index 0000000..4f1747e --- /dev/null +++ b/docs/openiddict-keys.md @@ -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 совпадает), токен, выданный до рестарта, остаётся валиден. diff --git a/src/food-market.api/Infrastructure/Security/OpenIddictKeyConfigurator.cs b/src/food-market.api/Infrastructure/Security/OpenIddictKeyConfigurator.cs new file mode 100644 index 0000000..bcbb86f --- /dev/null +++ b/src/food-market.api/Infrastructure/Security/OpenIddictKeyConfigurator.cs @@ -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; + +/// Настройка ключей подписи/шифрования токенов OpenIddict. +/// +/// Development — persistent RSA-ключ в App_Data/openiddict-dev-key.xml +/// (как было): переживает рестарты, шифрование access-token выключено для удобства +/// отладки. Поведение не меняется. +/// +/// Production/Stage — настоящие X509-сертификаты (отдельные для подписи +/// и шифрования). Путь берётся из конфигурации (OpenIddict:SigningCertPath / +/// OpenIddict:EncryptionCertPath, можно через env), иначе — App_Data/*.pfx +/// (монтируется volume'ом, см. docker-compose). Если файла нет — генерируется +/// persistent self-signed сертификат (5 лет) и сохраняется по пути. Это +/// заменяет dev-ephemeral поведение: при рестарте берётся тот же сертификат, +/// поэтому ранее выданные токены остаются валидными. +/// +/// Опциональный пароль PFX — OpenIddict:CertPassword. +/// +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); + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 13a672b..7e1cd41 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -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()