Start Debugging

Решение: The antiforgery token could not be decrypted в ASP.NET Core

Ошибка означает, что Data Protection потерял ключ, которым был подписан токен. Сохраняйте ключи в общем долговечном хранилище и вызывайте SetApplicationName, чтобы каждый экземпляр читал одно и то же кольцо ключей.

The antiforgery token could not be decrypted, потому что ключ Data Protection, которым он был подписан, больше не находится в кольце ключей, которое читает ваше приложение. По умолчанию ASP.NET Core записывает эти ключи в папку, привязанную к машине и пользователю, которая не переживает перезапуск контейнера и не разделяется между экземплярами. Сохраняйте ключи в долговечном хранилище, которое может читать каждый экземпляр (файловая шара, база данных, Azure Blob, Redis), и фиксируйте SetApplicationName на одно и то же значение везде. Это полное решение. Остальная часть статьи объясняет, почему это происходит, и приводит точный код для каждого хранилища.

Это относится к ASP.NET Core 11 (.NET 11), но тот же стек Data Protection и то же решение восходят к ASP.NET Core 2.x.

Ошибка в контексте

Вы увидите одну из этих, обычно при POST сразу после развёртывания, горизонтального масштабирования или перезапуска контейнера:

Microsoft.AspNetCore.Antiforgery.AntiforgeryValidationException: The antiforgery token could not be decrypted.
 ---> System.Security.Cryptography.CryptographicException: The key {0cf0e637-...} was not found in the key ring.
   at Microsoft.AspNetCore.DataProtection.KeyManagement.KeyRingBasedDataProtector.UnprotectCore(...)
   at Microsoft.AspNetCore.Antiforgery.DefaultAntiforgeryTokenSerializer.Deserialize(String serializedToken)

Внутреннее исключение - это ключевая подсказка. The key {guid} was not found in the key ring означает, что токен ссылается на ключ, который этот экземпляр никогда не видел. Другое внутреннее сообщение, The payload was invalid, указывает на ключ, который существует, но не может расшифровать именно этот токен, что обычно означает, что два приложения разделяют кольцо ключей без совпадающего имени приложения (подробнее об этом ниже).

В Blazor и Razor Pages та же первопричина проявляется как HTTP 400 с Bad Request - antiforgery token validation failed, а в MVC - как цикл перенаправлений при входе, потому что cookie antiforgery никогда не может быть проверен.

Почему это происходит

Токены antiforgery шифруются и подписываются с помощью ASP.NET Core Data Protection. Проверка работает только если экземпляр, читающий токен, всё ещё хранит ключ, который его записал. Есть четыре способа потерять этот ключ:

Срок жизни ключей по умолчанию - 90 дней, поэтому легитимная ротация ключей почти никогда этого не вызывает. Если вы это видите, ключ отсутствует, а не истёк.

Минимальное воспроизведение

Это наименьшее приложение, которое воспроизводит ошибку при перезапуске. Запустите его, отправьте форму, затем перезапустите процесс перед повторной отправкой.

// .NET 11, ASP.NET Core 11
// No Data Protection configuration: keys are ephemeral.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAntiforgery();
var app = builder.Build();

app.MapGet("/", (HttpContext ctx, IAntiforgery af) =>
{
    var tokens = af.GetAndStoreTokens(ctx);
    return Results.Content($"""
        <form action="/submit" method="post">
          <input name="{tokens.FormFieldName}" type="hidden" value="{tokens.RequestToken}" />
          <button type="submit">Submit</button>
        </form>
        """, "text/html");
});

app.MapPost("/submit", () => "OK").RequireAntiforgery();

app.Run();

Загрузите /, скопируйте отрендеренную форму, перезапустите приложение, затем отправьте POST старой формы. Поскольку кольцо ключей в памяти было пересоздано при перезапуске, токен больше не расшифровывается, и вы получаете AntiforgeryValidationException. В одном долгоживущем процессе вы этого не увидите, что и есть причина, почему это проскальзывает через локальное тестирование и проявляется только в контейнерах и фермах.

Решение, подробно

Выберите хранилище, соответствующее тому, где вы выполняете приложение. Во всех случаях правило одно: место, которое могут читать и записывать все экземпляры, плюс стабильное имя приложения.

1. Файловая шара (UNC или смонтированный том): проще всего для виртуальных машин и физического железа

// .NET 11, ASP.NET Core 11
builder.Services.AddDataProtection()
    .PersistKeysToFileSystem(new DirectoryInfo(@"\\fileserver\dpkeys\myapp"))
    .SetApplicationName("MyApp");

В Kubernetes смонтируйте общий PersistentVolume (ReadWriteMany) и направьте PersistKeysToFileSystem на путь монтирования. Тогда ключи переживут любой отдельный pod. Это решение с наименьшим трением, когда у вас уже есть общее хранилище.

2. База данных через EF Core: без дополнительной инфраструктуры, если у вас уже есть БД

Добавьте пакет Microsoft.AspNetCore.DataProtection.EntityFrameworkCore и контекст, реализующий IDataProtectionKeyContext:

// .NET 11, EF Core 11
public class KeysDbContext : DbContext, IDataProtectionKeyContext
{
    public KeysDbContext(DbContextOptions<KeysDbContext> options) : base(options) { }
    public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
}
// Program.cs
builder.Services.AddDbContext<KeysDbContext>(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("Keys")));

builder.Services.AddDataProtection()
    .PersistKeysToDbContext<KeysDbContext>()
    .SetApplicationName("MyApp");

Один раз выполните миграцию, создающую таблицу DataProtectionKeys, и каждый экземпляр будет читать одно и то же кольцо. Это вариант, к которому я прибегаю чаще всего, потому что ему не нужно ничего, чего у приложения ещё нет.

3. Azure Blob Storage: чистый выбор для App Service и Azure Container Apps

Добавьте Azure.Extensions.AspNetCore.DataProtection.Blobs. Используйте управляемую идентичность вместо строки подключения:

// .NET 11, ASP.NET Core 11
builder.Services.AddDataProtection()
    .PersistKeysToAzureBlobStorage(
        new Uri("https://mystorage.blob.core.windows.net/dpkeys/keys.xml"),
        new DefaultAzureCredential())
    .SetApplicationName("MyApp");

Это также устраняет вариант со слотами развёртывания: когда staging и production указывают на один и тот же blob, переключение слотов больше не делает недействительными активные сессии. Для эшелонированной защиты оберните это в ProtectKeysWithAzureKeyVault (пакет Azure.Extensions.AspNetCore.DataProtection.Keys), чтобы само кольцо ключей было зашифровано в покое.

4. Redis: наименьшая задержка для больших ферм

Добавьте Microsoft.AspNetCore.DataProtection.StackExchangeRedis и переиспользуйте мультиплексор, который вы уже используете для кеширования:

// .NET 11, ASP.NET Core 11
var redis = ConnectionMultiplexer.Connect(builder.Configuration.GetConnectionString("Redis")!);
builder.Services.AddDataProtection()
    .PersistKeysToStackExchangeRedis(redis, "DataProtection-Keys")
    .SetApplicationName("MyApp");

Какое бы хранилище вы ни выбрали, SetApplicationName не является необязательным в сценарии с несколькими приложениями или слотами. Он задаёт дискриминатор приложения, который Data Protection подмешивает в вывод ключа, и значение должно быть побайтово идентичным на каждом экземпляре и слоте, которые должны доверять токенам друг друга.

Тонкости и варианты

The payload was invalid вместо key not found. Ключ существует, но дискриминатор приложения отличается. Два приложения разделяют хранилище, и у одного отсутствует SetApplicationName или задано другое значение. Установите одинаковое имя в обоих. Это также причина, почему копирование опубликованного приложения в новый путь корня контента может сломать токены: ASP.NET Core по умолчанию выводит имя приложения из пути корня контента, когда вы не задаёте его явно, поэтому сам путь становится дискриминатором.

Зашифрованные в покое ключи, которые другие экземпляры не могут прочитать. В Windows Data Protection по умолчанию шифрует кольцо ключей с помощью DPAPI, привязанного к текущему пользователю или машине. Ключ, записанный под одной учётной записью, не читается под другой, что даёт тот же симптом, хотя ключи физически присутствуют. Когда вы разделяете ключи между машинами, либо отключите шифрование, привязанное к машине, либо используйте явный переносимый защитник, такой как ProtectKeysWithCertificate.

Работает локально, падает в контейнере. Ожидаемо. Один процесс на вашей машине разработки держит эфемерное кольцо в памяти всю сессию, поэтому токен всегда расшифровывается. Баг проявляется только когда в игру вступает второй экземпляр или перезапуск. Воспроизведите его, запустив два экземпляра локально на разных портах без чего-либо впереди, или перезапуская между запросами, как в воспроизведении выше.

Не “чините” это отключением antiforgery. Удаление [ValidateAntiForgeryToken] или .RequireAntiforgery() заставляет ошибку исчезнуть и снова открывает дыру CSRF. Токен делает свою работу. Вместо этого сохраняйте ключи.

DisableAutomaticKeyGeneration на репликах только для чтения. Если вы запускаете worker, который должен потреблять ключи, но никогда их не создавать, вызовите DisableAutomaticKeyGeneration(), чтобы он не создавал конкурирующий ключ в общем кольце. Оставьте это выключенным на экземпляре, который владеет ротацией.

Связанное чтение

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад