Certificate Authentication та mTLS в ASP.NET Core
Certificate Authentication та mTLS в ASP.NET Core
1. TLS vs mTLS
Стандартний TLS (одностороннє)
У звичайному HTTPS:
- Сервер доводить свою ідентичність клієнту (SSL-сертифікат домену).
- Клієнт не доводить нічого (анонімний).
Клієнт → Сервер: "Хто ти?"
Сервер → Клієнт: "Я api.myapp.com, ось мій сертифікат"
Клієнт: перевіряє підпис CA → довіряє
=== Захищений канал ===
Клієнт: анонімний
mTLS (двостороннє)
Клієнт → Сервер: "Хто ти?"
Сервер → Клієнт: "Я api.myapp.com, ось мій сертифікат. А хто ти?"
Клієнт → Сервер: "Я OrderService, ось мій сертифікат"
Сервер: перевіряє → обидва аутентифіковані
=== Захищений канал, обидві сторони ідентифіковані ===
Де використовується mTLS?
🏢 Мікросервіси
🔌 IoT пристрої
🏦 B2B API
🛡️ Zero Trust
2. Генерація сертифікатів для розробки
OpenSSL: CA, Server, Client
# === 1. Створюємо власний Certificate Authority (CA) ===
# Приватний ключ CA (2048 bits)
openssl genrsa -out ca.key 2048
# Самопідписаний сертифікат CA (10 років)
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt \
-subj "/C=UA/O=MyApp Dev CA/CN=MyApp Root CA"
# === 2. Серверний сертифікат ===
# Ключ сервера
openssl genrsa -out server.key 2048
# Certificate Signing Request (CSR)
openssl req -new -key server.key -out server.csr \
-subj "/C=UA/O=MyApp/CN=localhost"
# Підписуємо CA
openssl x509 -req -days 365 -in server.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-out server.crt
# PFX для Kestrel (приватний ключ + сертифікат)
openssl pkcs12 -export -out server.pfx \
-inkey server.key -in server.crt \
-passout pass:serverpassword
# === 3. Клієнтський сертифікат ===
# Ключ клієнта
openssl genrsa -out client.key 2048
# CSR з ідентифікатором (CN = назва сервісу)
openssl req -new -key client.key -out client.csr \
-subj "/C=UA/O=MyApp/CN=OrderService/OU=Services"
# Підписуємо тим самим CA
openssl x509 -req -days 365 -in client.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-out client.crt
# PFX для .NET HttpClient
openssl pkcs12 -export -out client.pfx \
-inkey client.key -in client.crt \
-passout pass:clientpassword
.NET PowerShell (Windows)
# Альтернатива для Windows розробки
# CA Certificate
$caParams = @{
Subject = "CN=MyApp Dev CA"
KeyAlgorithm = "RSA"
KeyLength = 2048
CertStoreLocation = "Cert:\CurrentUser\My"
NotAfter = (Get-Date).AddYears(10)
}
$ca = New-SelfSignedCertificate @caParams
# Client Certificate підписаний CA
$clientParams = @{
Subject = "CN=OrderService"
Signer = $ca
CertStoreLocation = "Cert:\CurrentUser\My"
TextExtension = @("2.5.29.37={text}1.3.6.1.5.5.7.3.2") # ClientAuth EKU
NotAfter = (Get-Date).AddYears(1)
}
$client = New-SelfSignedCertificate @clientParams
# Експортуємо у PFX
$pwd = ConvertTo-SecureString -String "clientpassword" -Force -AsPlainText
Export-PfxCertificate -Cert $client -FilePath "client.pfx" -Password $pwd
3. Налаштування Kestrel для Certificate Auth
ASP.NET Core Server (Resource Server)
{
"Kestrel": {
"Endpoints": {
"Https": {
"Url": "https://0.0.0.0:7001",
"Certificate": {
"Path": "certs/server.pfx",
"Password": "serverpassword"
},
"ClientCertificateMode": "RequireCertificate"
},
"HttpsOptional": {
"Url": "https://0.0.0.0:7002",
"Certificate": {
"Path": "certs/server.pfx",
"Password": "serverpassword"
},
"ClientCertificateMode": "AllowCertificate"
}
}
}
}
builder.WebHost.ConfigureKestrel(options =>
{
options.ConfigureHttpsDefaults(https =>
{
// RequireCertificate — без клієнтського cert → TLS handshake fail
https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
// Де шукати довірені CA
https.ClientCertificateValidation = (cert, chain, errors) =>
{
// Власна валідація (детальніше нижче)
// Тут лише приклад: accept all (НЕ для production!)
return true;
};
});
});
ClientCertificateMode варіанти
| Значення | Опис |
|---|---|
NoCertificate | Сертифікат не вимагається і не приймається |
AllowCertificate | Сертифікат необов'язковий. Якщо наданий — перевіряється |
RequireCertificate | Сертифікат обов'язковий. Без нього — TLS handshake fail |
DelayCertificate | Сертифікат опціональний, запитується пізніше (renegotiation) |
4. Certificate Authentication Middleware
dotnet add package Microsoft.AspNetCore.Authentication.Certificate
builder.Services
.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
.AddCertificate(options =>
{
// Тип сертифікатів, які приймаємо
options.AllowedCertificateTypes = CertificateTypes.All;
// Або більш суворо:
// options.AllowedCertificateTypes = CertificateTypes.Chained;
// Чи перевіряти термін дії?
options.ValidateCertificateUse = true;
// Чи перевіряти revocation (CRL/OCSP)?
options.RevocationMode = X509RevocationMode.Online; // Prod
// options.RevocationMode = X509RevocationMode.NoCheck; // Dev
// Кастомна валідація — ОСН0ВНИЙ МЕХАНІЗМ БЕЗПЕКИ
options.Events = new CertificateAuthenticationEvents
{
OnCertificateValidated = ctx =>
{
// ctx.ClientCertificate — X509Certificate2
// 1. Перевіряємо, чи підписаний нашим CA
var chain = new X509Chain();
chain.ChainPolicy.ExtraStore.Add(LoadCACert());
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
if (!chain.Build(ctx.ClientCertificate))
{
ctx.Fail("Certificate chain validation failed.");
return Task.CompletedTask;
}
// 2. Перевіряємо CN (назву сервісу)
var cn = ctx.ClientCertificate
.GetNameInfo(X509NameType.SimpleName, false);
// Білий список дозволених сервісів
var allowedServices = new[]
{
"OrderService", "PaymentService", "NotificationService"
};
if (!allowedServices.Contains(cn))
{
ctx.Fail($"Service '{cn}' is not allowed.");
return Task.CompletedTask;
}
// 3. Будуємо ClaimsPrincipal на основі сертифіката
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier,
ctx.ClientCertificate.Thumbprint),
new Claim(ClaimTypes.Name, cn),
new Claim("service_name", cn),
new Claim("cert_expiry",
ctx.ClientCertificate.NotAfter.ToString("O")),
new Claim("cert_thumbprint",
ctx.ClientCertificate.Thumbprint),
new Claim("cert_issuer",
ctx.ClientCertificate.Issuer)
};
ctx.Principal = new ClaimsPrincipal(
new ClaimsIdentity(
claims,
CertificateAuthenticationDefaults.AuthenticationScheme));
ctx.Success();
return Task.CompletedTask;
},
OnAuthenticationFailed = ctx =>
{
var logger = ctx.HttpContext.RequestServices
.GetRequiredService<ILogger<Program>>();
logger.LogWarning(
"Certificate authentication failed: {Error}",
ctx.Exception?.Message);
return Task.CompletedTask;
}
};
});
static X509Certificate2 LoadCACert()
=> new("certs/ca.crt");
5. ICertificateValidationService: кастомна валідація
Для більш складної логіки (перевірка в БД, CRL списки тощо):
using System.Security.Cryptography.X509Certificates;
public interface ICertificateValidationService
{
Task<CertValidationResult> ValidateAsync(X509Certificate2 certificate);
}
public class CertificateValidationService : ICertificateValidationService
{
private readonly AppDbContext _db;
private readonly ILogger<CertificateValidationService> _logger;
// Список довірених thumbprints (можна завантажити з конфігурації або БД)
private static readonly HashSet<string> RevokedThumbprints = [];
public CertificateValidationService(
AppDbContext db,
ILogger<CertificateValidationService> logger)
{
_db = db;
_logger = logger;
}
public async Task<CertValidationResult> ValidateAsync(
X509Certificate2 certificate)
{
// 1. Термін дії
if (DateTime.UtcNow < certificate.NotBefore ||
DateTime.UtcNow > certificate.NotAfter)
{
return CertValidationResult.Fail(
$"Certificate expired or not yet valid. " +
$"Valid: {certificate.NotBefore:O} — {certificate.NotAfter:O}");
}
// 2. Revocation check (проти нашого власного списку)
var thumbprint = certificate.Thumbprint.ToUpper();
if (RevokedThumbprints.Contains(thumbprint))
{
_logger.LogWarning(
"Access attempt with revoked certificate: {Thumbprint}",
thumbprint);
return CertValidationResult.Fail("Certificate has been revoked.");
}
// 3. Перевірка в БД (allow-list)
var isAllowed = await _db.TrustedCertificates
.AnyAsync(c => c.Thumbprint == thumbprint && c.IsActive);
if (!isAllowed)
{
return CertValidationResult.Fail(
$"Certificate with thumbprint {thumbprint} is not trusted.");
}
// 4. Key usage check (клієнтська аутентифікація)
var hasClientAuth = certificate.Extensions
.OfType<X509EnhancedKeyUsageExtension>()
.SelectMany(e => e.EnhancedKeyUsages.Cast<Oid>())
.Any(oid => oid.Value == "1.3.6.1.5.5.7.3.2"); // ClientAuth OID
if (!hasClientAuth)
{
return CertValidationResult.Fail(
"Certificate does not have Client Authentication key usage.");
}
var serviceName = certificate.GetNameInfo(
X509NameType.SimpleName, false);
_logger.LogInformation(
"Certificate validated: {ServiceName} ({Thumbprint})",
serviceName, thumbprint);
return CertValidationResult.Success(serviceName, thumbprint);
}
}
public record CertValidationResult(
bool IsValid,
string? ServiceName,
string? Thumbprint,
string? Error)
{
public static CertValidationResult Success(
string serviceName, string thumbprint)
=> new(true, serviceName, thumbprint, null);
public static CertValidationResult Fail(string error)
=> new(false, null, null, error);
}
Реєстрація в Program.cs
builder.Services.AddDbContext<AppDbContext>();
builder.Services.AddScoped<ICertificateValidationService,
CertificateValidationService>();
builder.Services
.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
.AddCertificate(options =>
{
options.AllowedCertificateTypes = CertificateTypes.All;
options.Events = new CertificateAuthenticationEvents
{
OnCertificateValidated = async ctx =>
{
var validationService = ctx.HttpContext.RequestServices
.GetRequiredService<ICertificateValidationService>();
var result = await validationService
.ValidateAsync(ctx.ClientCertificate);
if (!result.IsValid)
{
ctx.Fail(result.Error!);
return;
}
var claims = new[]
{
new Claim(ClaimTypes.Name, result.ServiceName!),
new Claim("service_name", result.ServiceName!),
new Claim("cert_thumbprint", result.Thumbprint!),
};
ctx.Principal = new ClaimsPrincipal(
new ClaimsIdentity(
claims,
CertificateAuthenticationDefaults.AuthenticationScheme));
ctx.Success();
}
};
})
// Кешування результатів валідації (щоб не ходити в БД на кожен запит)
.AddCertificateCache(options =>
{
options.CacheSize = 1024;
options.CacheEntryExpiration = TimeSpan.FromMinutes(10);
});
6. Клієнтська сторона: HttpClient з сертифікатом
public class SecureHttpClientService
{
private readonly HttpClient _httpClient;
public SecureHttpClientService(HttpClient httpClient)
=> _httpClient = httpClient;
public async Task<string> GetDataAsync(string url)
{
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
}
// Завантажуємо клієнтський сертифікат
var clientCertificate = new X509Certificate2(
"certs/client.pfx",
"clientpassword");
// Реєструємо HttpClient з сертифікатом
builder.Services.AddHttpClient<SecureHttpClientService>(client =>
{
client.BaseAddress = new Uri("https://api.internal/");
})
.ConfigurePrimaryHttpMessageHandler(() =>
{
var handler = new HttpClientHandler();
// Додаємо клієнтський сертифікат
handler.ClientCertificates.Add(clientCertificate);
// Для dev: довіряємо самопідписаним сертифікатам (тільки dev!)
handler.ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
return handler;
});
7. mTLS через NGINX Reverse Proxy
У реальних deployment Kestrel часто стоїть за NGINX, який термінує TLS та передає інформацію про сертифікат у заголовках:
# nginx.conf — конфігурація mTLS
server {
listen 443 ssl;
server_name api.myapp.com;
# Серверний сертифікат
ssl_certificate /etc/nginx/certs/server.crt;
ssl_certificate_key /etc/nginx/certs/server.key;
# Вимагаємо клієнтський сертифікат, підписаний нашим CA
ssl_client_certificate /etc/nginx/certs/ca.crt;
ssl_verify_client on; # on = required, optional = AllowCertificate
ssl_verify_depth 2; # Глибина ланцюжка CA
location / {
proxy_pass https://localhost:7001;
# Передаємо інформацію про сертифікат у заголовках
proxy_set_header X-Client-Cert $ssl_client_escaped_cert;
proxy_set_header X-Client-Cert-Dn $ssl_client_s_dn;
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_set_header X-Client-Fingerprint $ssl_client_fingerprint;
}
}
Читання сертифіката з заголовку (за проксі)
builder.Services
.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
.AddCertificate()
// Дозволяємо читати сертифікат з заголовку (для proxy сценарію)
.AddCertificateForwarding(options =>
{
options.CertificateHeader = "X-Client-Cert";
// NGINX передає URL-encoded PEM
options.HeaderConverter = headerValue =>
{
if (string.IsNullOrEmpty(headerValue)) return null;
var decoded = Uri.UnescapeDataString(headerValue);
var bytes = Encoding.UTF8.GetBytes(decoded);
return new X509Certificate2(bytes);
};
});
app.UseCertificateForwarding(); // Перед UseAuthentication
app.UseAuthentication();
app.UseAuthorization();
8. Управління довіреними сертифікатами
public class TrustedCertificate
{
public Guid Id { get; set; } = Guid.NewGuid();
public string ServiceName { get; set; } = null!; // "OrderService"
public string Thumbprint { get; set; } = null!; // SHA-1 fingerprint
public string Subject { get; set; } = null!; // Distinguished Name
public string Issuer { get; set; } = null!;
public DateTime NotBefore { get; set; }
public DateTime NotAfter { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public string? AddedBy { get; set; } // Хто додав
public string? Notes { get; set; }
}
app.MapPost("/admin/trusted-certs",
async (IFormFile certFile,
AppDbContext db) =>
{
using var ms = new MemoryStream();
await certFile.CopyToAsync(ms);
var certBytes = ms.ToArray();
var cert = new X509Certificate2(certBytes);
// Перевіряємо дублікати
var exists = await db.TrustedCertificates
.AnyAsync(c => c.Thumbprint == cert.Thumbprint);
if (exists)
return Results.Conflict(new
{
error = "Certificate already registered."
});
var trusted = new TrustedCertificate
{
ServiceName = cert.GetNameInfo(X509NameType.SimpleName, false),
Thumbprint = cert.Thumbprint,
Subject = cert.Subject,
Issuer = cert.Issuer,
NotBefore = cert.NotBefore.ToUniversalTime(),
NotAfter = cert.NotAfter.ToUniversalTime(),
};
db.TrustedCertificates.Add(trusted);
await db.SaveChangesAsync();
return Results.CreatedAtRoute(/* ... */,
new { trusted.Id, trusted.Thumbprint });
}).RequireAuthorization("admin");
9. Автоматичне оновлення сертифікатів (ACME / Let's Encrypt)
Для серверних сертифікатів — Let's Encrypt через Certes або CertificateManager:
dotnet add package Certes
public class CertificateRenewalService : BackgroundService
{
private readonly IConfiguration _config;
private readonly ILogger<CertificateRenewalService> _logger;
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await CheckAndRenewAsync(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Certificate renewal check failed");
}
// Перевіряємо щодня
await Task.Delay(TimeSpan.FromDays(1), ct);
}
}
private async Task CheckAndRenewAsync(CancellationToken ct)
{
var certPath = _config["Kestrel:Certificate:Path"]!;
var cert = new X509Certificate2(certPath);
// Оновлюємо якщо залишилось менше 30 днів
var daysLeft = (cert.NotAfter - DateTime.UtcNow).TotalDays;
if (daysLeft > 30)
{
_logger.LogDebug(
"Certificate expires in {Days} days, no renewal needed.",
(int)daysLeft);
return;
}
_logger.LogInformation(
"Certificate expires in {Days} days, starting renewal...",
(int)daysLeft);
// ACME запит до Let's Encrypt (спрощено)
// Реальна реалізація: Certes library або cert-manager у Kubernetes
await RenewWithAcmeAsync();
}
private Task RenewWithAcmeAsync() => Task.CompletedTask; // ...
}
10. Практичні завдання
Рівень 1: Базовий
- Згенеруйте CA, серверний та клієнтський сертифікати через OpenSSL
- Налаштуйте Kestrel з
ClientCertificateMode.RequireCertificate - Спробуйте доступ без сертифіката → TLS handshake fail
- Спробуйте з сертифікатом (curl:
--cert client.crt --key client.key) - Декодуйте сертифікат:
openssl x509 -in client.crt -text -noout
- Налаштуйте
AddCertificateзOnCertificateValidated - Витягніть CN, Thumbprint, NotAfter з сертифіката
- Ендпоінт
GET /meповертає claims:serviceName,thumbprint,expiresAt - Перевірте через curl з клієнтським сертифікатом
- Спробуйте сертифікат з іншим CN — чи відхиляється?
Рівень 2: Проєктування
- Таблиця
TrustedCertificates(Thumbprint, ServiceName, IsActive) ICertificateValidationService→ перевірка в БДPOST /admin/trusted-certs→ завантаження PEM файлу, парсинг, збереженняDELETE /admin/trusted-certs/{id}→ відкликання (set IsActive = false)- Кеш:
IMemoryCacheстроком 10 хвилин, invalidation при зміні в БД
Реалізуйте клієнта, що викликає API через mTLS:
- Два проєкти:
ServerApiтаClientService ClientServiceзавантажуєclient.pfxта налаштовуєHttpClientHandlerGET /api/dataна сервері → перевіряє CN = "ClientService"- Протестуйте: звичайний браузер отримує 403,
ClientService— 200 - Додайте логування: яка версія TLS? який cipher suite?
Рівень 3: Архітектура
Повноцінне mTLS через NGINX:
- Налаштуйте NGINX для термінації mTLS (клієнтські certs)
- NGINX передає
X-Client-Certзаголовок до ASP.NET Core - ASP.NET Core читає cert з заголовку через
AddCertificateForwarding - Додайте monitoring: скільки запитів з яким CN за останню годину
- Certificate expiry alerting: фонова служба попереджає за 30 днів до спливання довірених certs
11. Резюме
mTLS = обидві сторони аутентифіковані
X509Certificate2 → Claims
OnCertificateValidated: витягуємо CN, Thumbprint, NotAfter, будуємо ClaimsPrincipal. Далі — стандартна авторизація.Allow-list замість відкритого CA
IsActive. Відкликання = set IsActive = false.CertificateForwarding для NGINX
X-Client-Cert заголовку. AddCertificateForwarding читає і парсить його.Далі: наступна стаття — RBAC vs ABAC vs ReBAC — три моделі контролю доступу з реальними прикладами реалізації в ASP.NET Core.
Refresh Token Rotation в ASP.NET Core
Повна реалізація Refresh Token Rotation: JWT access tokens, rotating refresh tokens, сімейство токенів (token family), sliding expiration, захист від крадіжки токенів та відкликання.
RBAC, ABAC та ReBAC в ASP.NET Core
Три моделі контролю доступу: Role-Based (RBAC), Attribute-Based (ABAC) та Relationship-Based (ReBAC). Реалізація в ASP.NET Core через Policy, Requirements, IAuthorizationHandler та кастомні AuthorizationAttribute.