RE:NODE

Приложения11 мин чтения

Аутентификация в ASP.NET Core: cookies, JWT и Identity

Выбор между cookie и JWT в ASP.NET Core, настройка Identity и хранение ключей data protection, чтобы повторный деплой не разлогинивал всех пользователей.

0 прочтений

Для сайта, где браузер и сервер - одно приложение, используйте аутентификацию через cookie. Для API, который вызывают мобильные приложения, другие серверы или single-page приложение на другом домене, используйте bearer-токены - обычно JWT, выданные провайдером идентификации. ASP.NET Core Identity - это хранилище пользователей и логика паролей, а не третья схема аутентификации; она работает под любой из двух. Что бы вы ни выбрали, большинство размещённых на хостинге приложений ломает вовсе не выбор, а ключи data protection, которые живут внутри контейнера и исчезают при повторном деплое, разлогинивая каждого пользователя и ломая каждую форму. Эта статья разбирает выбор, настройку каждого варианта, значения Identity по умолчанию и правильное хранение этих ключей.

Аутентификация, авторизация и порядок middleware#

Два слова, две задачи. Аутентификация выясняет, кто вызывающий, по cookie, токену или сертификату, и строит ClaimsPrincipal. Авторизация решает, можно ли этому principal делать то, что он просит. ASP.NET Core держит их раздельно: для первой - обработчик на каждую схему, для второй - политики.

csharp
builder.Services.AddAuthentication(/* default scheme */);builder.Services.AddAuthorization();var app = builder.Build();app.UseForwardedHeaders();   // behind a proxy: before anything that reads scheme or IPapp.UseRouting();app.UseAuthentication();app.UseAuthorization();app.MapControllers();

WebApplication сам добавляет middleware аутентификации и авторизации, когда их сервисы зарегистрированы, но если прописать их явно, порядок остаётся на виду, а порядок важен: аутентификация должна выполняться до авторизации, и обе - после маршрутизации, чтобы были известны метаданные эндпоинта вроде [Authorize]. Forwarded headers идут первыми, когда приложение стоит за обратным прокси, иначе приложение считает, что каждый запрос пришёл по обычному HTTP, и cookies с пометкой secure, URL перенаправлений и OAuth-коллбэки - всё идёт не так. Точная конфигурация есть в статье ASP.NET Core за обратным прокси.

Cookies или JWT: выбор по клиенту#

КлиентЧто использоватьПочему
Сайт с серверным рендерингом (Razor Pages, MVC, Blazor Server)CookiesБраузер сам их хранит и отправляет; в JavaScript ничего не нужно делать
SPA, отдаваемое с того же домена, что и APICookiesПо тем же причинам, плюс HttpOnly держит учётные данные подальше от скриптов
SPA на другом доменеТокены или backend-for-frontend с cookiesМежсайтовые cookies браузеры блокируют всё чаще
Мобильное или десктопное приложениеТокеныНет хранилища cookies, на которое можно положиться
Сервер к серверуТокены (client credentials)Нет ни пользователя, ни браузера

Довод, что JWT «stateless и потому лучше», в основном о масштабе, которого у вас нет. Подписанный cookie точно так же stateless - auth cookie в ASP.NET Core содержит зашифрованные claims, а не ID сессии, - и у него есть два свойства, которых нет у токенов в JavaScript: хранением и сроком действия занимается браузер, а cookie с HttpOnly не может прочитать внедрённый скрипт. Токены оправданы, когда клиент - не браузер или когда их выдаёт отдельный провайдер идентификации для нескольких API.

csharp
builder.Services    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)    .AddCookie(options =>    {        options.LoginPath = "/account/login";        options.AccessDeniedPath = "/account/denied";        options.ExpireTimeSpan = TimeSpan.FromDays(7);        options.SlidingExpiration = true;        options.Cookie.Name = "app_auth";        options.Cookie.HttpOnly = true;        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;        options.Cookie.SameSite = SameSiteMode.Lax;    });

Значения по умолчанию разумные: ExpireTimeSpan равен 14 дням, SlidingExpiration включён (cookie перевыпускается, когда прошло больше половины срока его жизни), cookie помечен HttpOnly, а SameSite равен Lax. Вход - это построение principal и передача его обработчику:

csharp
var claims = new List<Claim>{    new(ClaimTypes.NameIdentifier, user.Id.ToString()),    new(ClaimTypes.Name, user.Email),    new(ClaimTypes.Role, user.Role)};var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,    new ClaimsPrincipal(identity),    new AuthenticationProperties { IsPersistent = rememberMe });

IsPersistent = false создаёт сессионный cookie, который исчезает при закрытии браузера; true даёт ему срок действия, указанный выше.

На двух вещах люди спотыкаются. Для API-эндпоинта обработчик cookie по умолчанию отвечает на неаутентифицированный запрос редиректом 302 на страницу входа, а JavaScript-клиент получает это как HTML-страницу со статусом 200. Переопределите options.Events.OnRedirectToLogin, чтобы для путей API возвращался 401. И поскольку браузер отправляет cookies автоматически, аутентификации через cookie нужна защита от подделки запросов (antiforgery) для запросов, меняющих состояние: MVC и Razor Pages проверяют токены в формах, а начиная с .NET 8 эндпоинты minimal API, которые привязывают данные формы, защищены, как только вы добавите app.UseAntiforgery().

Аутентификация через JWT bearer#

bash
$ dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

С внешним провайдером идентификации - Auth0, Microsoft Entra ID, Keycloak, Duende IdentityServer и подобными - вы направляете обработчик на издателя, и он скачивает ключи подписи из метаданных провайдера:

csharp
builder.Services    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)    .AddJwtBearer(options =>    {        options.Authority = "https://login.example.com/";        options.Audience = "orders-api";        options.MapInboundClaims = false;    });

MapInboundClaims = false сохраняет имена claims такими, какие они в токене (sub, email, role), вместо того чтобы переводить их в длинные URI ClaimTypes, - именно этого большинство ожидает, когда читает User.FindFirst("sub"). Если вы отключаете сопоставление, скажите обработчику, в каких claims лежат имя и роли, через TokenValidationParameters.NameClaimType и RoleClaimType.

Если токены выдаёт ваше собственное приложение, вы проверяете их ключом, который храните сами:

csharp
options.TokenValidationParameters = new TokenValidationParameters{    ValidIssuer = "https://api.example.com",    ValidAudience = "orders-api",    IssuerSigningKey = new SymmetricSecurityKey(        Convert.FromBase64String(builder.Configuration["Jwt:SigningKey"]!)),    ClockSkew = TimeSpan.FromSeconds(30)};

ClockSkew по умолчанию равен пяти минутам, а значит, истёкший токен принимается ещё пять минут после его exp. Уменьшите его, когда будете доверять часам. Ключ подписи - такой же секрет, как пароль базы данных: переменная окружения или хранилище секретов, но никогда не appsettings.json в репозитории. Где ему место, разобрано в статье Конфигурация и секреты в ASP.NET Core.

Выдавать собственные JWT означает самому отвечать за refresh-токены, отзыв, ротацию ключей и хранение токенов на клиенте. Каждая из этих задач решаема, а все вместе они - причина, по которой для большинства небольших проектов совет звучит как «используйте cookies или провайдер идентификации». Если токены нужны, а запускать провайдер не хочется, следующий раздел предлагает средний путь.

ASP.NET Core Identity#

Identity - это база пользователей и логика вокруг неё: хеширование паролей, блокировка, подтверждение email, двухфакторные коды, внешние входы и роли. Она хранит пользователей через Entity Framework Core, так что работает с SQL Server, PostgreSQL, MySQL или чем угодно ещё, что поддерживает EF Core.

csharp
builder.Services.AddDbContext<AppDbContext>(o =>    o.UseSqlServer(builder.Configuration.GetConnectionString("Default")));builder.Services    .AddIdentityApiEndpoints<IdentityUser>()    .AddEntityFrameworkStores<AppDbContext>();var app = builder.Build();app.MapGroup("/account").MapIdentityApi<IdentityUser>();

AddIdentityApiEndpoints и MapIdentityApi, появившиеся в .NET 8, дают JSON API с /register, /login, /refresh, подтверждением email, сбросом пароля и управлением двухфакторной аутентификацией. Эндпоинт входа возвращает либо cookie (с ?useCookies=true), либо bearer-токен. Эти токены - не JWT: они непрозрачные, защищены системой data protection, описанной ниже, и прочитать их может только выдавшее их приложение. Для одного приложения и его собственного фронтенда это достоинство; когда один и тот же токен должны проверять несколько сервисов, это неподходящий инструмент.

Для приложений с серверным рендерингом классический путь - AddDefaultIdentity<IdentityUser>() со сгенерированным интерфейсом на Razor Pages.

Значения Identity по умолчанию стоит знать до выпуска:

ОпцияПо умолчаниюКомментарий
Password.RequiredLength6Поднимите; длина важнее классов символов
Password.RequireDigit, RequireUppercase, RequireLowercase, RequireNonAlphanumerictrueМногие команды ослабляют эти требования и поднимают длину
Lockout.MaxFailedAccessAttempts5Действует, только если вход вызывается с lockoutOnFailure: true
Lockout.DefaultLockoutTimeSpan5 минут
SignIn.RequireConfirmedEmailfalseВключите, как только сможете отправлять почту
User.RequireUniqueEmailfalseОбычно нужно true

Строку про блокировку стоит прочитать дважды. SignInManager.PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure: false), который использовали старые шаблоны, вообще не считает неудачные попытки, так что форму входа на нём можно перебирать бесконечно. Передавайте true и вдобавок поставьте ограничение частоты перед эндпоинтом входа.

Пароли хешируются через PBKDF2 классом PasswordHasher из Identity, с маркером формата, так что более строгие настройки в новых версиях перехешируют старые пароли при следующем успешном входе.

Ключи data protection: почему деплой разлогинивает всех#

Каждый cookie, который пишет обработчик аутентификации, каждый antiforgery-токен, каждый bearer-токен Identity и каждая ссылка для сброса пароля зашифрованы ключами из системы data protection ASP.NET Core. По умолчанию в Linux эти ключи генерируются при первом использовании и записываются в ~/.aspnet/DataProtection-Keys - внутри контейнера, в домашнем каталоге того пользователя, от имени которого запущено приложение. Если этот каталог не переживает пересборку или повторный деплой, ключи тоже не переживают. Новый деплой генерирует новые ключи, и всё, что было зашифровано старыми, разом становится нечитаемым.

Симптомы характерные: после деплоя разлогинены все пользователи, на формах, открытых до деплоя, появляется The antiforgery token could not be decrypted, а при запуске - предупреждения вроде этих:

code
warn: Microsoft.AspNetCore.DataProtection.Repositories.FileSystemXmlRepository[60]      Storing keys in a directory '/root/.aspnet/DataProtection-Keys' that may not be      persisted outside of the container. Protected data will be unavailable when      container is destroyed.warn: Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager[35]      No XML encryptor configured. Key may be persisted to storage in unencrypted form.

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

bash
$ dotnet add package Microsoft.AspNetCore.DataProtection.EntityFrameworkCore
csharp
public class AppDbContext(DbContextOptions<AppDbContext> options)    : IdentityDbContext<IdentityUser>(options), IDataProtectionKeyContext{    public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();}builder.Services.AddDataProtection()    .SetApplicationName("orders-web")    .PersistKeysToDbContext<AppDbContext>();

Добавьте миграцию для новой таблицы и задеплойте. Альтернативы - PersistKeysToStackExchangeRedis (пакет Microsoft.AspNetCore.DataProtection.StackExchangeRedis, который работает с Valkey, если этот сервер Valkey сохраняет данные на диск) или PersistKeysToFileSystem с каталогом, который, как вы точно знаете, переживает деплои. SetApplicationName важен, когда несколько экземпляров или приложений должны делить ключи: по умолчанию имя приложения выводится из пути к content root, так что две копии одного и того же приложения, задеплоенные по разным путям, не смогут прочитать cookies друг друга.

По умолчанию ключи ротируются каждые 90 дней, а старые сохраняются, чтобы существующие cookies по-прежнему расшифровывались. Для ключей в хранилище ProtectKeysWithCertificate шифрует их сертификатом X.509; без него любой, кто может прочитать таблицу ключей, может подделывать cookies, так что база, где они лежат, заслуживает той же заботы, что и сама таблица пользователей. Эта сторона разобрана в чек-листе безопасности баз данных.

Внешние входы, OpenID Connect и выход#

Если пользователи входят через уже имеющийся у них аккаунт - Google, Microsoft, GitHub или корпоративный single sign-on, - хранение паролей, сценарии сброса и двухфакторные запросы переходят к тем, для кого это основная работа. В ASP.NET Core это удалённая схема аутентификации поверх обработчика cookie: удалённая схема проводит обмен с провайдером, а схема cookie запоминает результат.

csharp
builder.Services    .AddAuthentication(options =>    {        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;        options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;    })    .AddCookie()    .AddOpenIdConnect(options =>    {        options.Authority = "https://login.example.com/";        options.ClientId = builder.Configuration["Oidc:ClientId"];        options.ClientSecret = builder.Configuration["Oidc:ClientSecret"];        options.ResponseType = "code";        options.SaveTokens = false;        options.MapInboundClaims = false;    });

Пакеты для конкретных провайдеров - Microsoft.AspNetCore.Authentication.Google, .MicrosoftAccount и другие - это тонкие обёртки с уже заполненными эндпоинтами. У каждой схемы есть путь коллбэка, на который провайдер перенаправляет обратно (/signin-oidc для OpenID Connect, /signin-google для Google), и именно этот URL, с https:// и вашим настоящим доменом, должен быть зарегистрирован у провайдера. За прокси, который терминирует TLS, приложение строит этот URL по запросу, который видит; без forwarded headers оно строит адрес http://, провайдер отклоняет его как незарегистрированный, и страница ошибки, которую вы видите, - страница провайдера, а не ваша.

SaveTokens = false не пускает access- и refresh-токены провайдера в auth cookie. Сохраняйте их, только если приложение вызывает API провайдера от имени пользователя, потому что они раздувают cookie - настолько, что при нескольких claims он разбивается на несколько cookie-частей.

Выход - это HttpContext.SignOutAsync() для схемы cookie плюс выход у провайдера, если вы хотите разлогинить пользователя и там. Поскольку auth cookie самодостаточен, удаление пользователя или смена его роли не влияет на уже выданный cookie: он остаётся действительным до истечения срока. Identity решает это через security stamp - значение, которое меняется при смене пароля или настроек безопасности и по умолчанию перепроверяется каждые 30 минут (SecurityStampValidatorOptions.ValidationInterval). Сократите интервал, если отзыв должен срабатывать быстрее, или проверяйте флаг «отключён» в собственном событии валидации cookie, чтобы эффект был немедленным. Двухфакторная аутентификация через приложение-аутентификатор встроена в Identity - UserManager генерирует и проверяет TOTP-коды и коды восстановления, - и её стоит включить для каждого аккаунта с административными правами.

Политики авторизации#

Аутентификация говорит, кто; политики решают, что можно. Роли работают и годятся для грубых правил:

csharp
builder.Services.AddAuthorizationBuilder()    .AddPolicy("Admin", p => p.RequireRole("admin"))    .AddPolicy("CanRefund", p => p.RequireClaim("permission", "refunds"))    .SetFallbackPolicy(new AuthorizationPolicyBuilder()        .RequireAuthenticatedUser()        .Build());app.MapPost("/orders/{id}/refund", Refund).RequireAuthorization("CanRefund");app.MapGet("/health", () => "ok").AllowAnonymous();

Fallback-политика - самая полезная строка здесь: она требует вошедшего пользователя на каждом эндпоинте, если тот явно не помечен [AllowAnonymous] или .AllowAnonymous(). Это переворачивает поведение по умолчанию с «забыл атрибут - значит, публично» на «забыл атрибут - значит, закрыто», а именно в эту сторону и должны проваливаться ошибки. Не забудьте пометить как анонимные страницу входа, статические ресурсы, которые вы отдаёте через эндпоинты, и health checks.

Политики, зависящие от ресурса, - «пользователи могут редактировать только свои заказы» - это авторизация на основе ресурсов, она делается через IAuthorizationService.AuthorizeAsync(User, order, "EditOrder") и обработчик, получающий заказ. Не кодируйте такие правила в ролях.

Решение проблем#

Вошёл, а потом сразу снова анонимный. Неправильный порядок middleware, cookie помечен как secure, а приложение считает запрос HTTP (не хватает forwarded headers), или SameSite=Strict отбрасывает cookie при межсайтовом редиректе обратно от провайдера входа.

`IDX10500: Signature validation failed` или `IDX10214: Audience validation failed`. Токен выдан для другой аудитории, или приложение проверяет его не тем ключом или не тем authority. Декодируйте токен локально и сравните iss и aud со своими настройками.

После каждого деплоя разлогинены все. Ключи data protection не сохраняются. См. выше.

`User.Identity.Name` равен null с JWT. Сопоставление claims выключено, а NameClaimType не указывает на claim, который используют ваши токены.

OAuth-коллбэк падает с ошибкой корреляции. Cookie корреляции не пережил обмен - обычно потому, что за прокси, терминирующим TLS, приложение сгенерировало redirect URI с http://. Сначала исправьте forwarded headers.

FAQ#

Безопасно ли хранить JWT в localStorage?

Это работает, и любой скрипт, выполняющийся на вашей странице, может его прочитать - так что одна XSS-уязвимость сливает все токены. Cookie с HttpOnly скрипты прочитать не могут. Для браузерного приложения на том же домене, что и его API, cookies - более безопасный вариант по умолчанию.

Нужна ли мне ASP.NET Core Identity?

Только если ваше приложение хранит собственных пользователей и пароли. Если пользователи входят через внешний провайдер или через уже работающую у вас систему single sign-on, одного обработчика cookie или JWT достаточно, а Identity добавит таблицы, которыми вы не будете пользоваться.

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

Почему ошибки antiforgery появляются только после деплоя?

Потому что токен был зашифрован ключом data protection, которого больше нет. Сохраняйте ключи в базе данных или другом хранилище, переживающем деплои, и ошибки прекратятся.

Могут ли два экземпляра моего приложения делить входы пользователей?

Да, если у них общие ключи data protection и одно и то же имя приложения. Храните ключи в общей базе данных или Valkey и вызывайте SetApplicationName с одинаковым значением на обоих.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000