.NET Tutorial
Middle6 мин чтенияВопрос 9 из 19

Authentication vs Authorization: JWT, Cookie, OAuth2 / OIDC?

Разница на примере

нет или невалиден

валиден

анонимный на защищённом
эндпоинте

известен, но нет прав

разрешено

Запрос

UseAuthentication
Кто это?
проверить cookie / токен

User = анонимный

User = ClaimsPrincipal
sub, name, roles, scopes

UseAuthorization
Можно ли?

401 Unauthorized
(Challenge)

403 Forbidden
(Forbid)

Эндпоинт

нет или невалиден

валиден

анонимный на защищённом
эндпоинте

известен, но нет прав

разрешено

Запрос

UseAuthentication
Кто это?
проверить cookie / токен

User = анонимный

User = ClaimsPrincipal
sub, name, roles, scopes

UseAuthorization
Можно ли?

401 Unauthorized
(Challenge)

403 Forbidden
(Forbid)

Эндпоинт

Аутентификация и авторизация в конвейере
Аутентификация Авторизация
Вопрос Кто ты? Что тебе можно?
Результат ClaimsPrincipal Разрешено / запрещено
Middleware UseAuthentication UseAuthorization
Ошибка 401 (challenge) 403 (forbid)
Конфигурация Схемы: Cookie, JwtBearer, OIDC... [Authorize], роли, политики
C#
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(o =>
    {
        o.Cookie.HttpOnly = true;                           // недоступна из JS (защита от XSS)
        o.Cookie.SecurePolicy = CookieSecurePolicy.Always;  // только HTTPS
        o.Cookie.SameSite = SameSiteMode.Lax;               // защита от CSRF
        o.ExpireTimeSpan = TimeSpan.FromHours(8);
        o.SlidingExpiration = true;
        o.LoginPath = "/account/login";
    });

// Вход после проверки пароля
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, user.Id.ToString()), new(ClaimTypes.Name, user.Email), new(ClaimTypes.Role, "Manager") };
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme));
await HttpContext.SignInAsync(principal);

Содержимое cookie — зашифрованный и подписанный тикет (Data Protection API). При нескольких экземплярах приложения ключи Data Protection нужно хранить в общем месте (Redis, БД, Blob) — иначе cookie, выданная одним экземпляром, не расшифруется другим.

JWT Bearer

C#
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.Authority = "https://auth.example.com/realms/shop";   // IdP: ключи подписи берутся из /.well-known/openid-configuration
        o.Audience = "orders-api";
        o.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromSeconds(30),
            NameClaimType = "preferred_username",
            RoleClaimType = "roles"
        };
    });
Identity ProviderOrders APIКлиентпри старте: GET/.well-known/openid-configurat-ion + JWKSпубличные ключи подписи(кэшируются)GET /orders, Authorization:Bearer eyJ...проверить подписьключом, iss, aud, expсформироватьClaimsPrincipal изclaims токена200 OK
Identity ProviderOrders APIКлиентпри старте: GET/.well-known/openid-configurat-ion + JWKSпубличные ключи подписи(кэшируются)GET /orders, Authorization:Bearer eyJ...проверить подписьключом, iss, aud, expсформироватьClaimsPrincipal изclaims токена200 OK
Проверка JWT без обращения к IdP на каждый запрос
Критерий Cookie JWT Bearer
Клиенты Браузер (тот же сайт) SPA, мобильные, сервис-сервис
Состояние на сервере Тикет в cookie (или сессия) Stateless
Отзыв Легко (удалить сессию, сменить security stamp) Сложно до истечения exp
XSS HttpOnly — токен недоступен JS Если хранить в localStorage — украдут при XSS
CSRF Нужна защита (SameSite, antiforgery) Не подвержен (заголовок не шлётся автоматически)
Размер Небольшой Растёт с числом claims

OAuth2 и OpenID Connect

  • OAuth2 решает задачу: «приложение хочет получить доступ к ресурсу от имени пользователя, не зная его пароля». Результат — access token для API.
  • OIDC добавляет стандартный способ узнать, кто пользователь: id_token (JWT с sub, name, email), эндпоинт userinfo, discovery-документ.
APIIdentity ProviderВеб-приложениеПользовательВойтиредирект на IdP сcode_challenge (PKCE)логин, пароль, 2FA, согласиередирект с authorization codecode + code_verifier (обменпо back-channel)id_token + access_token +refresh_tokenAuthorization: Bearer access_tokenданные
APIIdentity ProviderВеб-приложениеПользовательВойтиредирект на IdP сcode_challenge (PKCE)логин, пароль, 2FA, согласиередирект с authorization codecode + code_verifier (обменпо back-channel)id_token + access_token +refresh_tokenAuthorization: Bearer access_tokenданные
Authorization Code + PKCE (рекомендуемый поток)
Веб-приложение с входом через OIDC
builder.Services.AddAuthentication(o =>
{
    o.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    o.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(o =>
{
    o.Authority = "https://auth.example.com/realms/shop";
    o.ClientId = "shop-web";
    o.ClientSecret = builder.Configuration["Oidc:ClientSecret"];
    o.ResponseType = "code";                 // Authorization Code flow (+ PKCE по умолчанию)
    o.Scope.Add("orders.read");
    o.SaveTokens = true;                     // сохранить токены в cookie-тикете для вызова API
});

Авторизация

C#
[Authorize]                                   // любой аутентифицированный
[Authorize(Roles = "Admin,Manager")]          // одна из ролей
[Authorize(Policy = "CanRefund")]             // политика
[AllowAnonymous]                              // исключение

// Minimal API
app.MapDelete("/orders/{id}", Delete).RequireAuthorization("CanRefund");

// Требовать аутентификацию везде по умолчанию (безопасный дефолт)
builder.Services.AddAuthorizationBuilder()
    .SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())
    .AddPolicy("CanRefund", p => p.RequireClaim("permission", "orders.refund"));

Подробнее о ролях, политиках и claims — в следующем вопросе.

Несколько схем

C#
builder.Services.AddAuthentication()
    .AddJwtBearer("Internal", o => { /* токены от внутреннего IdP */ })
    .AddJwtBearer("Partners", o => { /* токены партнёров */ });

[Authorize(AuthenticationSchemes = "Partners")]
public class PartnerWebhooksController : ControllerBase { }

Вопросы на засыпку

Почему 401 называется Unauthorized, если это ошибка аутентификации?

Историческая неточность в HTTP-спецификации. По смыслу 401 — «не аутентифицирован» (нужно представиться, сервер отправляет WWW-Authenticate), 403 — «аутентифицирован, но доступ запрещён».

Что такое Challenge и Forbid?

Действия схемы аутентификации при отказе. Challenge — для анонимного пользователя: cookie-схема редиректит на страницу логина, JWT-схема возвращает 401 с WWW-Authenticate: Bearer. Forbid — для аутентифицированного: 403 или редирект на AccessDenied.

Где хранить JWT в SPA?

Лучше — нигде в браузере (BFF). Если нельзя: access token в памяти JS (короткое время жизни), refresh token — в HttpOnly-cookie. localStorage уязвим для XSS: любой внедрённый скрипт прочитает токен.

Чем OAuth2 Client Credentials отличается от Authorization Code?

Client Credentials — для сервис-сервис взаимодействия без пользователя: сервис предъявляет свой client_id/secret (или сертификат) и получает токен от своего имени. Authorization Code — когда действует пользователь через браузер.

Итог

Аутентификация устанавливает личность и заполняет HttpContext.User, авторизация решает, что этой личности разрешено. Cookie — для браузерных приложений и BFF, JWT — для API и сервисов. OAuth2 выдаёт токены доступа, OpenID Connect добавляет стандартизированную аутентификацию, а выдавать токены должен специализированный Identity Provider.