Añadir inicio de sesión a una aplicación ASP.NET Core
Esta guía rápida añade el inicio de sesión de KeenAuth a una aplicación ASP.NET Core 10 Razor Pages con Microsoft.AspNetCore.Authentication.OpenIdConnect, el cliente estándar de OpenID Connect de ASP.NET Core. KeenAuth todavía no tiene un SDK para .NET, y no hace falta: KeenAuth es un proveedor estándar de OpenID Connect. Su aplicación es el cliente OAuth y guarda todos los tokens en el servidor; el navegador solo recibe una cookie que contiene una clave de sesión aleatoria. Usted configurará el inicio de sesión, protegerá una página, llamará a una API protegida con el token de acceso del usuario, cerrará la sesión y probará la aplicación sin conexión.
La aplicación tiene estas rutas:
| Ruta | Qué hace |
|---|---|
GET / |
Página de inicio pública: un enlace para iniciar sesión, o el nombre del usuario y un botón para cerrar sesión |
GET /login |
Inicia el inicio de sesión: redirige a KeenAuth |
GET /signin-oidc |
Atendida por el manejador de OpenID Connect: la devolución de llamada |
GET /dashboard |
Requiere una sesión. Muestra el usuario y los mensajes que devuelve su API |
POST /logout |
Cierra la sesión aquí y en KeenAuth. Requiere el token antifalsificación |
Antes de empezar
- Un entorno de KeenAuth. Su emisor tiene un aspecto como
https://your-environment.eu.keenauth.app. - Una aplicación web en el entorno (Crear una aplicación) con un secreto de cliente. El manejador envía el secreto en el cuerpo de la solicitud de token, así que elija
client_secret_post. En un entornodevelopment, registre el URI de redirecciónhttp://localhost/signin-oidcy el URI de redirección tras cerrar sesiónhttp://localhost/; un URI de bucle local se registra sin puerto y coincide con cualquier puerto, como8080. - Una API registrada en el entorno (Crear una API) con el ámbito
messages:read. Suidentifier, por ejemplohttps://api.example.com, es la audiencia de sus tokens de acceso. La guía rápida de Express crea una API que acepta estos tokens. - El SDK de .NET 10 (10.0.403 o un parche posterior).
1. Añadir el paquete
La aplicación tiene un solo paquete, fijado a una versión exacta. El archivo de bloqueo (packages.lock.json, activado con RestorePackagesWithLockFile) fija todos los demás paquetes, con su hash de contenido.
<!-- The standard OpenID Connect client of ASP.NET Core (MIT). Exact version; the lock file pins the rest. -->
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="[10.0.14]" />
Restaure en modo bloqueado, para que un cambio en el grafo de dependencias falle en lugar de pasar en silencio:
dotnet restore --locked-mode
2. Configurar
La aplicación lee su configuración solo de variables de entorno. No guarde nunca sus valores en el control de versiones.
| Variable | Obligatoria | Significado |
|---|---|---|
KEENAUTH_ISSUER |
sí | El emisor de su entorno, https |
KEENAUTH_CLIENT_ID |
sí | El client_id de la aplicación web |
KEENAUTH_CLIENT_SECRET |
sí | Su secreto de cliente |
APP_BASE_URL |
sí | La URL pública de la aplicación. El URI de redirección se construye a partir de ella. http solo para localhost, 127.0.0.1 o [::1] |
API_URL |
sí | La API a la que llama la aplicación, por ejemplo http://localhost:3001 |
KEENAUTH_API_AUDIENCE |
sí | El identificador de esa API en KeenAuth |
SESSION_SECURE_COOKIE |
no | false solo para http://localhost sin cifrar |
KeenAuthSettings.From lee y comprueba estos valores al arrancar. Un valor que falte o no sea utilizable detiene la aplicación, y ningún mensaje de error contiene un valor.
public static KeenAuthSettings From(Func<string, string?> env) => new(
Url(Need(env, "KEENAUTH_ISSUER"), "KEENAUTH_ISSUER", httpsOnly: true, anyScheme: false),
Need(env, "KEENAUTH_CLIENT_ID"),
Need(env, "KEENAUTH_CLIENT_SECRET"),
Url(Need(env, "APP_BASE_URL"), "APP_BASE_URL", httpsOnly: false, anyScheme: false),
Url(Need(env, "API_URL"), "API_URL", httpsOnly: false, anyScheme: false),
// RFC 8707: the access token is requested for your API, which then accepts it as its audience.
Url(Need(env, "KEENAUTH_API_AUDIENCE"), "KEENAUTH_API_AUDIENCE", httpsOnly: false, anyScheme: true),
// The session cookie is Secure unless this is explicitly "false" (plain http://localhost).
!string.Equals(env("SESSION_SECURE_COOKIE"), "false", StringComparison.OrdinalIgnoreCase));
3. Mantener la sesión en el servidor
El esquema de cookies inicia la sesión del usuario en su aplicación. Su cookie contiene solo una clave aleatoria; el usuario y los tokens permanecen en un almacén de tickets en el servidor (MemoryTicketStore, en la memoria de un proceso).
.AddCookie(options =>
{
// The cookie holds only a random key; the user and the tokens stay in MemoryTicketStore.
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = settings.SecureCookie ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = false;
})
4. Configurar el manejador de OpenID Connect
El manejador lee el descubrimiento (<issuer>/.well-known/openid-configuration) y las claves de firma de su entorno. Utiliza el flujo de código de autorización con PKCE (S256), state y nonce, y los comprueba en el camino de vuelta. Si el descubrimiento indica un endpoint de solicitud de autorización enviada, el manejador lo utiliza (RFC 9126).
// The endpoints and the signing keys come from discovery: <issuer>/.well-known/openid-configuration.
options.Authority = settings.Issuer;
options.ClientId = settings.ClientId;
options.ClientSecret = settings.ClientSecret;
options.ResponseType = OpenIdConnectResponseType.Code;
options.ResponseMode = OpenIdConnectResponseMode.Query;
// PKCE (RFC 7636) with S256; the handler also sends state and nonce and checks them on the way back.
options.UsePkce = true;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
// messages:read is a scope of your API; the example API requires it on /api/messages.
options.Scope.Add("messages:read");
options.SaveTokens = true;
options.MapInboundClaims = false;
options.GetClaimsFromUserInfoEndpoint = false;
// Pushed authorization requests (RFC 9126) when the environment offers the endpoint.
options.PushedAuthorizationBehavior = PushedAuthorizationBehavior.UseIfAvailable;
options.CallbackPath = "/signin-oidc";
// ID tokens must be signed, with the keys of the environment and one of these algorithms.
options.TokenValidationParameters.RequireSignedTokens = true;
options.TokenValidationParameters.ValidateIssuerSigningKey = true;
options.TokenValidationParameters.ValidAlgorithms = ["RS256", "ES256", "PS256"];
options.TokenValidationParameters.NameClaimType = "name";
// These cookies carry the state and the nonce between the redirect and the callback.
var cookiePolicy = settings.SecureCookie ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
options.NonceCookie.SameSite = SameSiteMode.Lax;
options.NonceCookie.SecurePolicy = cookiePolicy;
options.CorrelationCookie.SameSite = SameSiteMode.Lax;
options.CorrelationCookie.SecurePolicy = cookiePolicy;
Tres eventos completan el manejador. El primero añade el identificador de su API como resource (RFC 8707); sin él, el token de acceso no está destinado a su API y la API lo rechaza. El segundo comprueba el parámetro iss de la respuesta de autorización (RFC 9207): ASP.NET Core no lo comprueba, y KeenAuth lo anuncia.
OnRedirectToIdentityProvider = context =>
{
// Build the redirect URI from the setting, not from the Host header of the request.
context.ProtocolMessage.RedirectUri = settings.RedirectUri;
// RFC 8707: an access token for your API.
context.ProtocolMessage.SetParameter("resource", settings.ApiAudience);
return Task.CompletedTask;
},
OnMessageReceived = async context =>
{
// RFC 9207: the authorization response must name the issuer that answered.
var configuration = await context.Options.ConfigurationManager!
.GetConfigurationAsync(context.HttpContext.RequestAborted);
var iss = context.ProtocolMessage.Iss;
if (iss is not null ? iss != configuration.Issuer : configuration.AuthorizationResponseIssParameterSupported)
{
context.Fail("The authorization response has a wrong or missing iss parameter.");
}
},
El tercero rechaza un token de ID que no esté firmado por una clave de su entorno. ASP.NET Core omite la comprobación de firma de un token de ID que llega del endpoint de tokens, lo que OpenID Connect Core permite sobre TLS; esta aplicación la comprueba igualmente.
OnTokenResponseReceived = async context =>
{
// ASP.NET Core skips the signature check of an ID token that comes from the token endpoint (OpenID
// Connect Core 3.1.3.7 allows it over TLS). This app does not: the token must be signed by a key
// of the environment, with an allowed algorithm.
var configuration = await context.Options.ConfigurationManager!
.GetConfigurationAsync(context.HttpContext.RequestAborted);
var parameters = context.Options.TokenValidationParameters.Clone();
parameters.ValidIssuer = configuration.Issuer;
parameters.IssuerSigningKeys = configuration.SigningKeys;
parameters.ValidAudience = settings.ClientId;
var result = await new JsonWebTokenHandler().ValidateTokenAsync(
context.TokenEndpointResponse.IdToken ?? "", parameters);
if (!result.IsValid)
{
context.Fail("The ID token is not signed by a key of the environment.");
}
},
Los dos últimos eventos incluyen el URI de redirección tras cerrar sesión en la solicitud de cierre de sesión, y devuelven un inicio de sesión fallido a la página de inicio sin mostrar el motivo:
OnRedirectToIdentityProviderForSignOut = context =>
{
context.ProtocolMessage.PostLogoutRedirectUri = settings.PostLogoutRedirectUri;
return Task.CompletedTask;
},
OnRemoteFailure = context =>
{
// Do not show the reason in the browser; the logs have it.
context.Response.Redirect("/?error=signin");
context.HandleResponse();
return Task.CompletedTask;
},
5. Iniciar sesión, proteger una página y cerrar sesión
/login inicia el inicio de sesión y, después, vuelve al panel, una ruta local fija:
public class LoginModel : PageModel
{
/// <summary>Starts sign-in: redirects to KeenAuth, and back to the dashboard afterwards (a fixed local path).</summary>
public IActionResult OnGet() =>
Challenge(new AuthenticationProperties { RedirectUri = "/dashboard" }, OpenIdConnectDefaults.AuthenticationScheme);
}
El panel necesita una sesión: sin ella, ASP.NET Core inicia el inicio de sesión. Cerrar sesión es una POST, y Razor Pages comprueba el token antifalsificación. Termina la sesión aquí y, después, redirige a KeenAuth (cierre de sesión iniciado por RP de OpenID Connect con id_token_hint y post_logout_redirect_uri):
public class LogoutModel : PageModel
{
/// <summary>
/// POST only, and Razor Pages checks the antiforgery token. Ends the session here, then redirects to KeenAuth
/// (OpenID Connect RP-Initiated Logout with id_token_hint and post_logout_redirect_uri).
/// </summary>
public IActionResult OnPost() =>
SignOut(new AuthenticationProperties(), CookieAuthenticationDefaults.AuthenticationScheme,
OpenIdConnectDefaults.AuthenticationScheme);
}
La página de inicio enlaza al inicio de sesión y envía el formulario de cierre de sesión; el tag helper de formulario añade el token antifalsificación:
@if (User.Identity?.IsAuthenticated == true)
{
<p>Signed in as @Model.DisplayName.</p>
<p><a href="/dashboard">Dashboard</a></p>
<!-- The form tag helper adds the antiforgery token. -->
<form method="post" asp-page="/Logout"><button type="submit">Sign out</button></form>
}
else
{
<p><a href="/login">Sign in</a></p>
}
6. Llamar a su API
El panel toma el token de acceso de la sesión y llama a su API con él. El token permanece en el servidor; el navegador recibe solo el HTML.
/// <summary>Needs a session. The access token stays on the server; the browser receives only the HTML.</summary>
[Authorize]
public class DashboardModel(MessagesClient messages) : PageModel
{
public string DisplayName => User.Identity?.Name ?? User.FindFirst("sub")?.Value ?? "";
public int Status { get; private set; }
public string? Messages { get; private set; }
public async Task OnGetAsync()
{
var accessToken = await HttpContext.GetTokenAsync("access_token");
(Status, Messages) = await messages.FetchAsync(accessToken ?? "", HttpContext.RequestAborted);
}
}
public async Task<(int Status, string? Body)> FetchAsync(string accessToken, CancellationToken cancel)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "api/messages");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
try
{
using var response = await http.SendAsync(request, cancel);
return ((int)response.StatusCode, response.IsSuccessStatusCode ? await response.Content.ReadAsStringAsync(cancel) : null);
}
catch (HttpRequestException)
{
return (502, null);
}
}
El ejemplo no tiene token de actualización: cuando caduca el token de acceso, su API responde 401 y el panel muestra ese estado. Inicie sesión de nuevo.
7. Ejecutar la aplicación
Inicie la API de la guía rápida de Express (o cualquier API que acepte los tokens) y ejecute la aplicación con las variables exportadas:
ASPNETCORE_URLS=http://localhost:8080 dotnet run --project App
Abra http://localhost:8080, elija Sign in, inicie sesión en KeenAuth y abra el panel.
8. Probar sin red
Las pruebas no necesitan red ni una cuenta de KeenAuth. Un pequeño servidor Kestrel en un puerto de bucle local simula su entorno (descubrimiento, un conjunto de claves creado para la ejecución de las pruebas, el endpoint de tokens, las solicitudes de autorización enviadas, el endpoint de cierre de sesión) y su API. Las pruebas inician la aplicación real en otro puerto y la controlan con un cliente HTTP que no sigue redirecciones, como un navegador que usted puede leer:
[Fact]
public async Task APageWithoutASessionStartsSignInAndCallsNoApi()
{
using var response = await h.Get("/dashboard");
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.StartsWith(h.Fake.Issuer + "/oauth2/authorize?", response.Headers.Location!.ToString());
Assert.Empty(h.Fake.To("/api/messages"));
}
dotnet restore Tests --locked-mode
python3 ../scripts/nuget_deps.py # licences and exact pins (--age: 7-day rule, network)
dotnet test Tests --no-restore
Las pruebas comprueban que la configuración se valida al arrancar (y que ningún error contiene un secreto); que el inicio de sesión envía PKCE S256, state, nonce y resource, y que el código se canjea con el verificador correspondiente; que una solicitud de autorización enviada mantiene los parámetros fuera del navegador; que el token de acceso se envía a la API en el encabezado Authorization y nunca a una cookie ni a una página; que un state desconocido o reutilizado, un iss incorrecto o ausente, y un token de ID sin firma, firmado con otra clave, firmado con un HMAC cuya clave es la clave pública, o emitido para otra audiencia, otro nonce u otro emisor, o ya caducado, se rechazan sin crear una sesión; que una página sin sesión inicia el inicio de sesión; y que el cierre de sesión necesita el token antifalsificación, va al endpoint de cierre de sesión con id_token_hint y termina la sesión en el servidor.
El inicio de sesión completo con un navegador real necesita un entorno de KeenAuth. No forma parte de las pruebas sin conexión.
Detrás de un proxy o balanceador de carga
Configure APP_BASE_URL con la URL pública de la aplicación; el URI de redirección se construye a partir de ella, no a partir del encabezado Host. Si un proxy termina el TLS, añada el middleware de encabezados reenviados solo cuando el proxy sea la única forma de llegar a la aplicación, para que las cookies de sesión reciban la marca Secure.
Límites actuales
- El inicio de sesión comienza con un
GETa/login. La comprobación destatey PKCE protegen la devolución de llamada. - El manejador autentica el cliente con
client_secret_post, no conclient_secret_basic. - La aplicación acepta tokens de ID firmados con RS256, ES256 o PS256. KeenAuth también puede firmar con EdDSA; este ejemplo no lo acepta, y EdDSA no se ha probado. Mantenga uno de los tres algoritmos para el entorno de la aplicación web.
- El almacén de tickets y las claves que protegen el token antifalsificación y las cookies de state viven en la memoria de un proceso. Tras un reinicio, las sesiones terminan y los inicios de sesión en curso fallan. Con varios servidores, guarde los tickets en una caché compartida y conserve las claves de ASP.NET Core Data Protection.
- Sin token de actualización y sin inicio de sesión silencioso.
Próximos pasos
- Proteger una API de Express: la API a la que llama esta aplicación.
- Verificar tokens: el verificador que está bajo el middleware de su API.