RE:NODE

Hosting aplikací11 min čtení

Přihlášení v ASP.NET Core: cookies, JWT a Identity

Vyber mezi cookie a JWT autentizací v ASP.NET Core, nastav Identity a ukládej klíče data protection tak, aby redeploy lidi neodhlašoval.

3 čtenáři

Pro web, kde jsou prohlížeč a server jedna aplikace, použij cookie autentizaci. Pro API, které volají mobilní aplikace, jiné servery nebo single-page aplikace na jiné doméně, použij bearer tokeny - obvykle JWT vydávané poskytovatelem identity. ASP.NET Core Identity je úložiště uživatelů a logika kolem hesel, ne třetí autentizační schéma; leží pod jedním i druhým. Ať vybereš cokoli, to, co rozbíjí většinu hostovaných aplikací, není volba samotná: jsou to klíče data protection, které žijí uvnitř kontejneru a při redeployi zmizí, takže odhlásí každého uživatele a rozbijí každý formulář. Tenhle příspěvek pokrývá výběr, nastavení obou variant, výchozí hodnoty Identity a to, jak ty klíče správně uložit.

Autentizace, autorizace a pořadí middlewarů#

Dvě slova, dvě práce. Autentizace zjistí, kdo volající je, z cookie, tokenu nebo certifikátu, a sestaví ClaimsPrincipal. Autorizace rozhodne, jestli smí tento principal dělat to, o co žádá. ASP.NET Core je drží odděleně: pro první je handler na každé schéma, pro druhou politiky.

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 přidá middleware pro autentizaci a autorizaci sám, jakmile jsou zaregistrované jejich služby, ale když je napíšeš výslovně, zůstane pořadí viditelné, a na pořadí záleží: autentizace musí běžet před autorizací a obě po routování, aby byla známa metadata endpointu, například [Authorize]. Forwarded headers jdou na začátek, když je aplikace za reverzní proxy; jinak si aplikace myslí, že každý požadavek přišel přes obyčejné HTTP, a cookies označené jako secure, adresy přesměrování i callbacky OAuth se pokazí. Přesnou konfiguraci najdeš v ASP.NET Core za reverzní proxy.

Cookies, nebo JWT: výběr podle klienta#

KlientPoužijProč
Web renderovaný na serveru (Razor Pages, MVC, Blazor Server)CookiesProhlížeč je ukládá a posílá; v JavaScriptu není co spravovat
SPA ze stejné domény jako APICookiesStejné důvody a navíc HttpOnly drží přihlašovací údaj mimo dosah skriptů
SPA na jiné doméněTokeny, nebo backend-for-frontend s cookiesCookies napříč weby prohlížeče čím dál víc blokují
Mobilní nebo desktopová aplikaceTokenyNemáš na co spolehnout úložiště cookies
Server-to-serverTokeny (client credentials)Žádný uživatel, žádný prohlížeč

Argument, že JWT jsou „bezstavové, a tedy lepší“, se většinou týká škály, kterou nemáš. Podepsaná cookie je stejně bezstavová - autentizační cookie v ASP.NET Core obsahuje šifrované claimy, ne ID session - a má dvě vlastnosti, které tokeny v JavaScriptu nemají: ukládání a expiraci řeší prohlížeč a cookie s HttpOnly nemůže přečíst vložený skript. Tokeny si své místo zaslouží, když klient není prohlížeč, nebo když je pro více API vydává samostatný poskytovatel identity.

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;    });

Výchozí hodnoty jsou rozumné: ExpireTimeSpan je 14 dní, SlidingExpiration je zapnutá (cookie se vydá znovu, když uplyne víc než polovina její životnosti), cookie je HttpOnly a SameSite je Lax. Přihlášení znamená sestavit principal a předat ho handleru:

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 vytvoří session cookie, která zmizí se zavřením prohlížeče; true jí dá výše uvedenou expiraci.

Dvě věci lidi překvapí. U endpointu API je výchozí odpověď cookie handleru na neověřený požadavek přesměrování 302 na přihlašovací stránku, které klient v JavaScriptu dostane jako stránku HTML se stavem 200. Přepiš options.Events.OnRedirectToLogin, aby pro cesty API vracel 401. A protože prohlížeč posílá cookies automaticky, potřebuje cookie autentizace ochranu antiforgery u požadavků, které mění stav: MVC a Razor Pages validují tokeny u formulářů a od .NET 8 jsou endpointy minimal API, které vážou data formuláře, chráněné poté, co přidáš app.UseAntiforgery().

Autentizace JWT bearer#

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

S externím poskytovatelem identity - Auth0, Microsoft Entra ID, Keycloak, Duende IdentityServer a podobně - nasměruješ handler na vydavatele a ten si podpisové klíče stáhne z metadat poskytovatele:

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

MapInboundClaims = false nechá názvy claimů tak, jak jsou v tokenu (sub, email, role), místo aby je převáděl na dlouhé URI z ClaimTypes, což většina lidí čeká, když čte User.FindFirst("sub"). Pokud mapování vypneš, řekni handleru, které claimy nesou jméno a role, přes TokenValidationParameters.NameClaimType a RoleClaimType.

Pokud tokeny vydává tvoje vlastní aplikace, ověřuješ je klíčem, který držíš:

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 je ve výchozím stavu pět minut, takže prošlý token se přijímá ještě pět minut po svém exp. Sniž ho, jakmile hodinám věříš. Podpisový klíč je tajemství jako heslo k databázi - proměnná prostředí nebo úložiště tajemství, nikdy appsettings.json v repozitáři. Kam patří, popisuje konfigurace a tajemství v ASP.NET Core.

Vydávat vlastní JWT znamená vlastnit refresh tokeny, odvolání, rotaci klíčů a ukládání tokenů na klientovi. Každé z toho jde vyřešit a dohromady jsou důvodem, proč pro většinu malých projektů platí rada „použij cookies, nebo poskytovatele identity“. Pokud tokeny potřebuješ a poskytovatele provozovat nechceš, další sekce ukazuje střední cestu.

ASP.NET Core Identity#

Identity je databáze uživatelů a logika kolem ní: hashování hesel, zamykání účtů, potvrzení e-mailu, dvoufaktorové kódy, externí přihlášení a role. Uživatele ukládá přes Entity Framework Core, takže funguje se SQL Serverem, PostgreSQL, MySQL i čímkoli dalším, co EF Core podporuje.

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 a MapIdentityApi, přidané v .NET 8, dávají API v JSON s /register, /login, /refresh, potvrzením e-mailu, obnovou hesla a správou dvoufaktorového ověření. Endpoint pro přihlášení vrací buď cookie (s ?useCookies=true), nebo bearer token. Tyto tokeny nejsou JWT: jsou neprůhledné, chráněné systémem data protection popsaným níže, a přečte je jen aplikace, která je vydala. Pro jedinou aplikaci a její vlastní frontend je to výhoda; je to špatný nástroj, když musí stejný token ověřovat několik služeb.

Pro aplikace renderované na serveru je klasická cesta AddDefaultIdentity<IdentityUser>() se scaffoldovaným uživatelským rozhraním v Razor Pages.

Výchozí hodnoty Identity stojí za to znát, než nasadíš:

VolbaVýchozíKomentář
Password.RequiredLength6Zvyš ji; délka vyhraje nad druhy znaků
Password.RequireDigit, RequireUppercase, RequireLowercase, RequireNonAlphanumerictrueMnoho týmů je uvolní a zvýší délku
Lockout.MaxFailedAccessAttempts5Platí jen tehdy, když se přihlášení volá s lockoutOnFailure: true
Lockout.DefaultLockoutTimeSpan5 minut
SignIn.RequireConfirmedEmailfalseZapni, jakmile umíš posílat e-maily
User.RequireUniqueEmailfalseObvykle chceš true

Řádek o zamykání si přečti dvakrát. SignInManager.PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure: false) - což používaly starší šablony - neúspěchy nepočítá vůbec, takže přihlašovací formulář postavený na něm se dá hádat donekonečna. Předej true a před endpoint přihlášení dej i rate limiting.

Hesla hashuje PasswordHasher z Identity pomocí PBKDF2 se značkou formátu, takže silnější nastavení v novějších verzích přehashují stará hesla při příštím úspěšném přihlášení.

Klíče data protection: proč redeploy všechny odhlásí#

Každá cookie, kterou autentizační handler zapíše, každý antiforgery token, každý bearer token z Identity i každý odkaz na obnovu hesla je zašifrovaný klíči ze systému data protection v ASP.NET Core. Na Linuxu se tyto klíče ve výchozím stavu vygenerují při prvním použití a zapíšou do ~/.aspnet/DataProtection-Keys - uvnitř kontejneru, v domovském adresáři uživatele, pod kterým aplikace běží. Pokud tento adresář nepřežije rebuild nebo redeploy, nepřežijí ani klíče. Nové nasazení vygeneruje nové klíče a všechno zašifrované těmi starými se najednou stane nečitelným.

Příznaky jsou nezaměnitelné: po nasazení jsou všichni uživatelé odhlášení, formuláře otevřené před nasazením hlásí The antiforgery token could not be decrypted a při startu se objevují varování jako toto:

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.

Řešení je uložit klíče někam, kde přežijí proces. Nejjednodušší místo je databáze, kterou aplikace už používá:

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>();

Přidej migraci pro novou tabulku a nasaď. Alternativy jsou PersistKeysToStackExchangeRedis (balíček Microsoft.AspNetCore.DataProtection.StackExchangeRedis, který funguje i s Valkey, pokud ten server Valkey ukládá na disk) nebo PersistKeysToFileSystem s adresářem, o kterém víš, že přežívá nasazení. SetApplicationName je důležité, když si musí klíče sdílet víc instancí nebo aplikací: ve výchozím stavu se název aplikace odvozuje z cesty ke kořeni obsahu, takže dvě kopie téže aplikace nasazené do různých cest si nepřečtou navzájem cookies.

Klíče se ve výchozím stavu rotují každých 90 dní a staré se uchovávají, aby se stávající cookies pořád daly dešifrovat. Pro klíče v klidu ProtectKeysWithCertificate zašifruje klíče certifikátem X.509; bez něj může každý, kdo přečte tabulku s klíči, padělat cookies, takže databáze, která je drží, si zaslouží stejnou péči jako samotná tabulka uživatelů. Této stránce se věnuje kontrolní seznam zabezpečení databáze.

Externí přihlášení, OpenID Connect a odhlášení#

Nechat uživatele přihlásit se účtem, který už mají - Google, Microsoft, GitHub nebo firemním jednotným přihlášením - přesouvá ukládání hesel, obnovu a dvoufaktorové výzvy na někoho, kdo to dělá na plný úvazek. V ASP.NET Core je to schéma vzdálené autentizace navrstvené na cookie handler: vzdálené schéma obstarává cestu k poskytovateli a zpět a schéma cookie si výsledek pamatuje.

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;    });

Balíčky pro konkrétní poskytovatele - Microsoft.AspNetCore.Authentication.Google, .MicrosoftAccount a další - jsou tenké obálky s předvyplněnými endpointy. Každé schéma má cestu callbacku, na kterou poskytovatel přesměruje zpět (/signin-oidc pro OpenID Connect, /signin-google pro Google), a přesně tato adresa, s https:// a tvou skutečnou doménou, musí být u poskytovatele zaregistrovaná. Za proxy, která ukončuje TLS, aplikace tuto adresu skládá z požadavku, který vidí; bez forwarded headers sestaví adresu s http://, poskytovatel ji odmítne jako neregistrovanou a chybová stránka, kterou uvidíš, je jeho, ne tvoje.

SaveTokens = false nechá přístupové a refresh tokeny poskytovatele mimo autentizační cookie. Ukládej je jen tehdy, když aplikace volá API poskytovatele jménem uživatele, protože cookie zvětšují - a to natolik, že se při několika claimech rozdělí na více cookies.

Odhlášení je HttpContext.SignOutAsync() pro schéma cookie plus odhlášení u poskytovatele, pokud chceš, aby byl uživatel odhlášený i tam. Protože je autentizační cookie samostatná, smazání uživatele nebo změna jeho role na už vydanou cookie nemá vliv: zůstává platná, dokud nevyprší. Identity to řeší security stamp, hodnotou, která se mění při změně hesla nebo nastavení zabezpečení a ve výchozím stavu se každých 30 minut znovu kontroluje (SecurityStampValidatorOptions.ValidationInterval). Zkrať interval, pokud má odvolání zabrat rychleji, nebo v události validace cookie zkontroluj vlastní příznak „disabled“ pro okamžitý účinek. Dvoufaktorové ověření přes aplikaci autentikátoru je v Identity zabudované - UserManager generuje a ověřuje kódy TOTP a záložní kódy - a vyplatí se ho zapnout u každého účtu s administrátorskými právy.

Autorizační politiky#

Autentizace říká kdo; politiky rozhodují co. Role fungují a pro hrubá pravidla jsou v pořádku:

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 politika je tu nejužitečnější řádek: každý endpoint pak vyžaduje přihlášeného uživatele, pokud není výslovně označen [AllowAnonymous] nebo .AllowAnonymous(). Tím se výchozí stav obrátí z „zapomenutý atribut znamená veřejné“ na „zapomenutý atribut znamená zamčené“, což je směr, kterým chceš, aby chyby selhávaly. Nezapomeň jako anonymní označit přihlašovací stránku, statické soubory servírované přes endpointy a health checks.

Politiky, které závisí na zdroji - „uživatelé smějí upravovat jen své vlastní objednávky“ - jsou autorizace založená na zdroji, dělá se přes IAuthorizationService.AuthorizeAsync(User, order, "EditOrder") a handler, který objednávku dostane. Taková pravidla do rolí nekóduj.

Řešení potíží#

Přihlášen, a vzápětí zase anonymní. Pořadí middlewarů je špatně, cookie je označená jako secure, ale aplikace si myslí, že požadavek je HTTP (chybí forwarded headers), nebo SameSite=Strict zahazuje cookie při přesměrování zpět od poskytovatele přihlášení napříč weby.

`IDX10500: Signature validation failed` nebo `IDX10214: Audience validation failed`. Token byl vydán pro jiné publikum, nebo aplikace ověřuje špatným klíčem či autoritou. Dekóduj token lokálně a porovnej iss a aud se svým nastavením.

Po každém nasazení jsou všichni odhlášení. Klíče data protection se neukládají trvale. Viz výše.

`User.Identity.Name` je u JWT null. Mapování claimů je vypnuté a NameClaimType není nastavený na claim, který tvoje tokeny používají.

Callback OAuth selže s chybou korelace. Korelační cookie nepřežila cestu tam a zpět - obvykle proto, že aplikace za proxy ukončující TLS vygenerovala redirect URI s http://. Nejdřív oprav forwarded headers.

FAQ#

Je bezpečné ukládat JWT do localStorage?

Funguje to a přečte ho jakýkoli skript, který běží na tvé stránce - takže jediná chyba typu cross-site scripting prozradí všechny tokeny. Cookie s HttpOnly skripty přečíst nemohou. Pro aplikaci v prohlížeči na stejné doméně jako její API jsou bezpečnější výchozí volbou cookies.

Potřebuju ASP.NET Core Identity?

Jen pokud tvoje aplikace ukládá vlastní uživatele a hesla. Pokud se uživatelé přihlašují přes externího poskytovatele nebo systém jednotného přihlášení, který už provozuješ, stačí samotný handler pro cookie nebo JWT a Identity přidá tabulky, které nevyužiješ.

Dost dlouho, aby to lidi neobtěžovalo, a dost krátce, aby ukradený notebook neznamenal trvalou session. Sedm až čtrnáct dní s klouzavou expirací vyhovuje většině webů; administrátorské části si zaslouží kratší životnost a dvoufaktorové ověření.

Proč mi antiforgery chyby naskakují až po nasazení?

Protože token byl zašifrován klíčem data protection, který už neexistuje. Ukládej klíče do databáze nebo jiného úložiště, které přežije nasazení, a chyby zmizí.

Mohou dvě instance mé aplikace sdílet přihlášení?

Ano, pokud sdílejí klíče data protection a stejný název aplikace. Ulož klíče do sdílené databáze nebo do Valkey a na obou zavolej SetApplicationName se stejnou hodnotou.


Komentáře

Zcela anonymně: žádný účet, žádný e-mail, žádné cookie. Ukládáme jen jméno, které napíšeš, text a čas - nic dalšího. Počet odkazů je omezený a značkování se nevykresluje.

0/2000