Auth

Certificate Authentication та mTLS в ASP.NET Core

Аутентифікація через клієнтські TLS-сертифікати, Mutual TLS (mTLS), налаштування Kestrel та NGINX, ICertificateValidationService, прив

Certificate Authentication та mTLS в ASP.NET Core

Паролі та токени можуть бути вкрадені. Сертифікати — значно складніше, адже приватний ключ ніколи не залишає пристрій. mTLS (Mutual TLS) — обидві сторони аутентифікують одна одну за криптографічними сертифікатами. Це стандарт для Zero-Trust архітектур та сервіс-до-сервісної комунікації в production.

1. TLS vs mTLS

Стандартний TLS (одностороннє)

У звичайному HTTPS:

  • Сервер доводить свою ідентичність клієнту (SSL-сертифікат домену).
  • Клієнт не доводить нічого (анонімний).
Клієнт → Сервер: "Хто ти?"
Сервер → Клієнт: "Я api.myapp.com, ось мій сертифікат"
Клієнт: перевіряє підпис CA → довіряє
=== Захищений канал ===
Клієнт: анонімний

mTLS (двостороннє)

Клієнт → Сервер: "Хто ти?"
Сервер → Клієнт: "Я api.myapp.com, ось мій сертифікат. А хто ти?"
Клієнт → Сервер: "Я OrderService, ось мій сертифікат"
Сервер: перевіряє → обидва аутентифіковані
=== Захищений канал, обидві сторони ідентифіковані ===

Де використовується mTLS?

🏢 Мікросервіси

Service Mesh (Istio, Linkerd) автоматично встановлює mTLS між усіма сервісами. Жодного явного коду — управляє sidecar proxy.

🔌 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)

appsettings.json — Kestrel з mTLS
{
  "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"
      }
    }
  }
}
Program.cs — Kestrel mTLS
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
Program.cs — Certificate Auth middleware
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 списки тощо):

Security/CertificateValidationService.cs
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

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 з сертифікатом

Services/SecureHttpClientService.cs
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();
    }
}
Program.cs — HttpClient з клієнтським сертифікатом
// Завантажуємо клієнтський сертифікат
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;
    }
}

Читання сертифіката з заголовку (за проксі)

Program.cs — Certificate з проксі заголовку
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. Управління довіреними сертифікатами

Models/TrustedCertificate.cs
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; }
}
POST /admin/trusted-certs — реєстрація сертифіката
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
Services/CertificateRenewalService.cs — ACME автооновлення
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: Базовий

Рівень 2: Проєктування

Рівень 3: Архітектура


11. Резюме

mTLS = обидві сторони аутентифіковані

Сервер доводить ідентичність клієнту (звичайний TLS), а клієнт — серверу (mTLS). Ідеально для Zero Trust та сервіс-до-сервісу.

X509Certificate2 → Claims

OnCertificateValidated: витягуємо CN, Thumbprint, NotAfter, будуємо ClaimsPrincipal. Далі — стандартна авторизація.

Allow-list замість відкритого CA

Підписати CA недостатньо. Зберігайте Thumbprints у БД + IsActive. Відкликання = set IsActive = false.

CertificateForwarding для NGINX

NGINX термінує mTLS, передає cert у X-Client-Cert заголовку. AddCertificateForwarding читає і парсить його.

Далі: наступна стаття — RBAC vs ABAC vs ReBAC — три моделі контролю доступу з реальними прикладами реалізації в ASP.NET Core.

Copyright © 2026