冠军

导航

理解 ASP.NET Core:Cookie 认证

理解 ASP.NET Core:Cookie 认证

ASP.NET Core 内置提供了基于 Cookie 的认证支持。在使用 Cookie 验证的时候,相关的三要素;

  • 认证模式名称;CookieAuthenticationDefaults.AuthenticationScheme

    namespace Microsoft.AspNetCore.Authentication.Cookies
    {
        public static class CookieAuthenticationDefaults
        {
            public const string AuthenticationScheme = "Cookies";
    

    见:在 GitHub 中查看 CookieAuthenticationDefaults 源码

  • 认证处理器:CookieAuthenticationHandler

    public class CookieAuthenticationHandler : Microsoft.AspNetCore.Authentication.SignInAuthenticationHandler<Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions>
    

    见:在 GitHub 中查看 CookieAuthenticationHandler 源码

    可以看到它派生于 SignInAuthenticationHandler,所以其支持了登录和登出操作。

  • 认证配置:CookieAuthenticationOptions

    public class CookieAuthenticationOptions : Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions
    

    见:在 GitHub 中查看 CookieAuthenticationOptions 源码

基于 Cookie 的认证自然要通过 Cookie 来保持和处理认证信息,与 Cookie 相关的设置是通过 Cookie 来完成的,它是一个 CookieBuilder 类型,使用它来构建 Cookie。CookieBuilder 提供了关于 Cookie 本身的多个属性,用于配制 Cookie。

public class CookieBuilder
{
    private string? _name;
    public virtual string? Name
    {
        get => _name;
        set => _name = !string.IsNullOrEmpty(value)
            ? value
            : throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(value));
    }

    public virtual string? Path { get; set; }
    public virtual string? Domain { get; set; }
    public virtual bool HttpOnly { get; set; }
    public virtual SameSiteMode SameSite { get; set; } = SameSiteMode.Unspecified;
    public virtual CookieSecurePolicy SecurePolicy { get; set; }
    public virtual TimeSpan? Expiration { get; set; }
    public virtual TimeSpan? MaxAge { get; set; }
    public virtual bool IsEssential { get; set; }

在 GitHub 中查看 CookieBuilder 源码

主要属性:

  • Name,Cookie 的名称

  • Path,Cookie 的作用路径

  • Domain,Cookie 的作用域

  • HttpOnly

  • SameSite

  • SecurePolicy

  • IsEssential,是否需要用户许可

  • Expiration,不能设置,会得到一个异常

    Cookie Expiration is ignored, use ExpireTimeSpan instead.

抛出异常的源代码如下,可以看到当设置 Expiration 之后,会导致异常抛出。

public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder, string authenticationScheme, string? displayName, Action<CookieAuthenticationOptions> configureOptions)
{          
  builder.Services
    .TryAddEnumerable(
    	ServiceDescriptor.Singleton<IPostConfigureOptions<CookieAuthenticationOptions>, 
    	PostConfigureCookieAuthenticationOptions>());
  builder.Services.AddOptions<CookieAuthenticationOptions>(authenticationScheme)
    .Validate(o => o.Cookie.Expiration == null, "Cookie.Expiration is ignored, use ExpireTimeSpan instead.");
  return builder
    .AddScheme<CookieAuthenticationOptions, CookieAuthenticationHandler>(
    	authenticationScheme, 
    	displayName, 
    	configureOptions);
}

在 GitHub 中查看 CookieExtensions 源代码

默认的 Cookie 名称为

public static readonly string CookiePrefix = ".AspNetCore.";

在 GitHub 中查看 CookieAuthenticationDefaults 源码

例如:

services.AddAuthentication(
  Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme)
  .AddCookie(options =>
  {
  	// Cookie settings
  	options.Cookie.HttpOnly = true;

但是,Expiration 并不能与 ExpireTimeSpan 一起设置来实现持久化的 Cookie。同时设置会导致如下异常:

Cookie.Expiration is ignored, use ExpireTimeSpan instead.

如果你希望得到持久化的 Cookie,必须使用 AuthenticationProperties 进行设置。

var authProperties = new AuthenticationProperties
{
	IsPersistent = true,
  ExpiresUtc = DateTime.UtcNow.AddMinutes(5),
  AllowRefresh = true
};

await HttpContext.SignInAsync(
  CookieAuthenticationDefaults.AuthenticationScheme, 
  new ClaimsPrincipal(claimsIdentity), 
  authProperties);

参见 Clarify behavior of CookieAuthenticationOptions.Cookie.Expiration 的讨论

public bool SlidingExpiration { get; set; }
public TimeSpan ExpireTimeSpan { get; set; }
ExpireTimeSpan

验证票据的过期时间,默认 14 天。注意,这里并不是设置 Cookie 的过期时间。

Controls how much time the cookie will remain valid from the point it is created. The expiration information is in the protected cookie ticket. Because of that an expired cookie will be ignored even if it is passed to the server after the browser should have purged it.

Cookie 的过期时间需要通过 AuthenticationProperties 进行设置。

SlidingExpiration

是否支持滑动过期。默认是 True。

还可以通过 CookieManager 对 Cookie 进行进一步管理。

public ICookieManager CookieManager { get; set; } = default!;

CookieManager 属性提供了如何从请求处理的上下文中提取认证 Cookie 中保存的值,如何追加,以及删除上下文中认证 Cookie 的管理。

public interface ICookieManager
{
    string? GetRequestCookie(HttpContext context, string key);
    void AppendResponseCookie(HttpContext context, string key, string? value, CookieOptions options);
    void DeleteCookie(HttpContext context, string key, CookieOptions options);
}

在 GitHub 中查看 ICookieManager 源码

它的默认实现是 ChunkingCookieManager 类,

public class ChunkingCookieManager : ICookieManager
{
 // ...... 
}

在 GitHub 中查看 ChunkingCookieManager 源码

在 CookieAuthenticationOptions 中可以看到,

/// <summary>
/// The component used to get cookies from the request or set them on the response.
///
/// ChunkingCookieManager will be used by default.
/// </summary>
public ICookieManager CookieManager { get; set; } = default!;

在 GitHub 中查看源码

对于 Cookie 来说,默认的过期时间为 Session,即关闭浏览器后就清除。通常在用户登录时会提供一个记住我的选项,用来保证在关闭浏览时不清除 Cookie,这就需要在生成的 Cookie 中声明一个 Expiration 的绝对过期时间。在前面已经看到,这并不能通过设置 CookieBuilder 的 Expiration 属性来实现。

在 SignInAsync 方法中,接收一个 AuthenticationProperties 类型的参数,可以用来指定 Cookie 是否持久化以及过期时间。

await HttpContext.SignInAsync("MyCookieAuthenticationScheme", principal, new AuthenticationProperties
{
    // 持久保存
    IsPersistent = true

    // 指定过期时间
    ExpiresUtc = DateTime.UtcNow.AddMinutes(20)
});

看一下 CookieAuthenticationHandler 中 SignInAsync 方法关于该配置的实现:

if (!signInContext.Properties.ExpiresUtc.HasValue)
{
    signInContext.Properties.ExpiresUtc = issuedUtc.Add(Options.ExpireTimeSpan);
}
if (signInContext.Properties.IsPersistent)
{
    var expiresUtc = signInContext.Properties.ExpiresUtc ?? issuedUtc.Add(Options.ExpireTimeSpan);
    signInContext.CookieOptions.Expires = expiresUtc.ToUniversalTime();
}

在 GitHub 中查看 CookieAuthenticationHandler 源码

只有在 IsPersistent 为 True 时,才会在写入Cookie指定 Expires。需要注意的是浏览器中的 Cookie 过期时间仅仅是用来指定浏览器是否删除 Cookie,而在 Cookie 存储的值中,也会包含该 Cookie 认证的发布时间和过期时间等,并在 HandleAuthenticateAsync 方法中对会其进行验证,并不是说只要你有Cookie就能验证通过。

在 GitHub 中查看 AuthenticationProperties 源码

  • LoginPath 登录路径
  • LogoutPath 登出路径
  • AccessDeniedPath 禁止访问路径
  • ReturnUrlParameter 用于跳转的 Url 参数
public PathString LoginPath { get; set; }
public PathString LogoutPath { get; set; }
public PathString AccessDeniedPath { get; set; }
public string ReturnUrlParameter { get; set; }

它们的默认值定义在 CookieAuthenticationDefaults 中

public static readonly PathString LoginPath = new PathString("/Account/Login");
public static readonly PathString LogoutPath = new PathString("/Account/Logout");
public static readonly PathString AccessDeniedPath = new PathString("/Account/AccessDenied");
public static readonly string ReturnUrlParameter = "ReturnUrl";

在 GitHub 中查看 CookieAuthenticationDefaults 源码

在 CookieAuthenticationOptions 上对于 Cookie 验证过程提供了基于事件的两种扩展方式,实际上,它们来自基类 AuthenticationSchemeOptions。

public Type EventsType { get; set; }
public object Events { get; set; }

见:https://github.com/dotnet/aspnetcore/blob/release/5.0/src/Security/Authentication/Core/src/AuthenticationSchemeOptions.cs

默认提供了一个 Events 对象可以直接使用,如果提供了 EventsType,那么将直接使用它来处理事件。

定义了 4 个关于登录和登出的回调点:

  • OnSigningIn
  • OnSignedIn
  • OnSigningOut
  • OnValidatePricipal

另外 4 个关于重定向的回调点

  • OnRedirectToLogin
  • OnRedirectToAccessDenied
  • OnRedirectToLogout
  • OnRedirectToReturnUrl

这些回调点定义在一个事件对象上,类型为:CookieAuthenticationEvents,在这个基类上,默认已经提供了默认的回调处理。如果提供自己的 EventsType,通常会从它派生出来。

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Net.Http.Headers;

namespace Microsoft.AspNetCore.Authentication.Cookies
{
    public class CookieAuthenticationEvents
    {
        public Func<CookieValidatePrincipalContext, Task> OnValidatePrincipal { get; set; } 
      			= context => Task.CompletedTask;
        public Func<CookieSigningInContext, Task> OnSigningIn { get; set; } = context => Task.CompletedTask;

        public Func<CookieSignedInContext, Task> OnSignedIn { get; set; } = context => Task.CompletedTask;

        public Func<CookieSigningOutContext, Task> OnSigningOut { get; set; } = context => Task.CompletedTask;

        public Func<RedirectContext<CookieAuthenticationOptions>, Task> OnRedirectToLogin { get; set; } 
      		= context =>
        {
            if (IsAjaxRequest(context.Request))
            {
                context.Response.Headers[HeaderNames.Location] = context.RedirectUri;
                context.Response.StatusCode = 401;
            }
            else
            {
                context.Response.Redirect(context.RedirectUri);
            }
            return Task.CompletedTask;
        };

        public Func<RedirectContext<CookieAuthenticationOptions>, Task> OnRedirectToAccessDenied { get; set; }
      		= context =>
        {
            if (IsAjaxRequest(context.Request))
            {
                context.Response.Headers[HeaderNames.Location] = context.RedirectUri;
                context.Response.StatusCode = 403;
            }
            else
            {
                context.Response.Redirect(context.RedirectUri);
            }
            return Task.CompletedTask;
        };

        public Func<RedirectContext<CookieAuthenticationOptions>, Task> OnRedirectToLogout { get; set; } = context =>
        {
            if (IsAjaxRequest(context.Request))
            {
                context.Response.Headers[HeaderNames.Location] = context.RedirectUri;
            }
            else
            {
                context.Response.Redirect(context.RedirectUri);
            }
            return Task.CompletedTask;
        };

        public Func<RedirectContext<CookieAuthenticationOptions>, Task> OnRedirectToReturnUrl { get; set; } = context =>
        {
            if (IsAjaxRequest(context.Request))
            {
                context.Response.Headers[HeaderNames.Location] = context.RedirectUri;
            }
            else
            {
                context.Response.Redirect(context.RedirectUri);
            }
            return Task.CompletedTask;
        };

        private static bool IsAjaxRequest(HttpRequest request)
        {
            return string.Equals(request.Query[HeaderNames.XRequestedWith], "XMLHttpRequest", StringComparison.Ordinal) ||
                string.Equals(request.Headers[HeaderNames.XRequestedWith], "XMLHttpRequest", StringComparison.Ordinal);
        }

        public virtual Task ValidatePrincipal(CookieValidatePrincipalContext context) => OnValidatePrincipal(context);

        public virtual Task SigningIn(CookieSigningInContext context) => OnSigningIn(context);

        public virtual Task SignedIn(CookieSignedInContext context) => OnSignedIn(context);

        public virtual Task SigningOut(CookieSigningOutContext context) => OnSigningOut(context);

        public virtual Task RedirectToLogout(RedirectContext<CookieAuthenticationOptions> context) => OnRedirectToLogout(context);

        public virtual Task RedirectToLogin(RedirectContext<CookieAuthenticationOptions> context) => OnRedirectToLogin(context);

        public virtual Task RedirectToReturnUrl(RedirectContext<CookieAuthenticationOptions> context) => OnRedirectToReturnUrl(context);

        public virtual Task RedirectToAccessDenied(RedirectContext<CookieAuthenticationOptions> context) => OnRedirectToAccessDenied(context);
    }
}

见:https://github.com/dotnet/aspnetcore/blob/release/5.0/src/Security/Authentication/Cookies/src/CookieAuthenticationEvents.cs

ASP.NET Core 实现跨站登录重定向的新姿势

作为 .NET 程序员,痛苦之一是自从 ASP.NET 诞生之日起直到最新的 ASP.NET Core 都无法直接实现跨站登录重定向(比如访问 https://q.cnblogs.com ,跳转到 https://passport.cnblogs.com 进行登录),只能跳转到当前站点。

具体拿 ASP.NET Core 来说就是 CookieAuthenticationOptions.LoginPath 只能指定路径,不能指定包含主机名的完整 url ,ASP.NET Core 会在重定向时自动加上当前请求的主机名。

services.AddAuthentication()
.AddCookie(options =>
{
    options.LoginPath = "/account/signin";
});

ReturnUrl 查询参数也只会包含路径,不包含完整的 url 。

昨天在阅读了 ASP.NET Core Authenticaion 的源码后,我们找到了一种新的解药 —— 修改 CookieAuthenticationEvents.OnRedirectToLogin 委托实现跨站登录重定向。

以下是新解药制作方法。

在 Startup.ConfigureServices 中给 AddCookie 添加如下的配置代码以使用修改后的 url 进行重定向:

services.AddAuthentication()
.AddCookie(options =>
{
    var originRedirectToLogin = options.Events.OnRedirectToLogin;
    options.Events.OnRedirectToLogin = context =>
    {
        return originRedirectToLogin(RebuildRedirectUri(context));
    };
});

RebuildRedirectUri 的实现代码如下:

private static RedirectContext<CookieAuthenticationOptions> RebuildRedirectUri(
    RedirectContext<CookieAuthenticationOptions> context)
{
    if (context.RedirectUri.StartsWith(ACCOUNT_SITE))
        return context;

    var originUri = new Uri(context.RedirectUri);
    var uriBuilder = new UriBuilder(ACCOUNT_SITE);
    uriBuilder.Path = originUri.AbsolutePath;
    var queryStrings = QueryHelpers.ParseQuery(originUri.Query);
    var returnUrlName = context.Options.ReturnUrlParameter;
    var returnUrl = originUri.GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped) + queryStrings[returnUrlName];
    uriBuilder.Query = QueryString.Create(returnUrlName, returnUrl).ToString();
    context.RedirectUri = uriBuilder.ToString();
    return context;
}

以上一堆代码用于实现 url 的转换,详见博问 https://q.cnblogs.com/q/108087/

这个长久以来的痛苦总算基于 ASP.NET Core 强大的扩展与配置能力相对优雅地消除了。

当保存在用户浏览器中的 Cookie 伴随请求再次回到服务器的时候,Cookie 认证处理器并不会自动到后台检查当前的用户是否有效,而是直接使用从 Cookie 中解析出来的用户信息。例如在 Tom 登录之后,在后台的数据库中已经将该用户禁用,但是由于 Tom 已经登录过了,在客户端的浏览器中就已经保存了一个有效的 Cookie,那么 Tom 还可以继续访问系统。

为了避免这种情况,Cookie 认证处理器提供了一个扩展点 OnValidatePrincipal 来允许检查当前的凭据是否可用。在用户通过认证之后,将会调用注册的回调方法进行检查。

可以如下注册 OnValidatePrincipal 的回调方法。

public void ConfigureServices(IServiceCollection services)
{
    services.AddAuthentication(options =>
    {
        options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    })
        .AddCookie(options =>
        {
            options.Events.OnValidatePrincipal = PrincipalValidator.ValidateAsync;
        });
}

自定义的检查器。

public static class PrincipalValidator
{
    public static async Task ValidateAsync(CookieValidatePrincipalContext context)
    {
        if (context == null) throw new System.ArgumentNullException(nameof(context));

        var userId = context.Principal.Claims.FirstOrDefault(claim => claim.Type == ClaimTypes.NameIdentifier)?.Value;
        if (userId == null)
        {
            context.RejectPrincipal();
            return;
        }

        // Get an instance using DI
        var dbContext = context.HttpContext.RequestServices.GetRequiredService<IdentityDbContext>();
        var user = await dbContext.Users.FindByIdAsync(userId);
        if (user == null)
        {
            context.RejectPrincipal();
            return;
        }
    }
}

https://www.meziantou.net/validating-user-with-cookie-authentication-in-asp-net-core-2.htm

参见 React to back-end changes ,该示例中自定义了派生自 CookieAuthenticationEvents 的事件处理对象,并重写了 ValidatePrincipal() 方法。

认证票据的存储

除了可以将认证信息完全保存到 Cookie 内,还可以将认证信息保存到一个认证信息的存储中,它的类型是 ITicketStore。

当在认证票据中保存大量信息的话,会导致 Cookie 中的内容过多。可以考虑将认证信息保存在一个服务器上的存储中,每个认证信息提供一个标示,在 cookie 中只保存该标示,这样既可以压缩 Cookie 中内容的长度,也可以提供安全性。

/// <summary>
/// An optional container in which to store the identity across requests. When used, only a session identifier is sent
/// to the client. This can be used to mitigate potential problems with very large identities.
/// </summary>
public ITicketStore? SessionStore { get; set; }

该接口的定义如下:

public interface ITicketStore
{
    /// <summary>
    /// Store the identity ticket and return the associated key.
    /// </summary>
    /// <param name="ticket">The identity information to store.</param>
    /// <returns>The key that can be used to retrieve the identity later.</returns>
    Task<string> StoreAsync(AuthenticationTicket ticket);

    /// <summary>
    /// Tells the store that the given identity should be updated.
    /// </summary>
    /// <param name="key"></param>
    /// <param name="ticket"></param>
    /// <returns></returns>
    Task RenewAsync(string key, AuthenticationTicket ticket);

    /// <summary>
    /// Retrieves an identity from the store for the given key.
    /// </summary>
    /// <param name="key">The key associated with the identity.</param>
    /// <returns>The identity associated with the given key, or if not found.</returns>
    Task<AuthenticationTicket> RetrieveAsync(string key);

    /// <summary>
    /// Remove the identity associated with the given key.
    /// </summary>
    /// <param name="key">The key associated with the identity.</param>
    /// <returns></returns>
    Task RemoveAsync(string key);
}

在 GitHub 中查看 ITicketStore 的定义

使用分布式缓存管理认证信息

参考 Session 的原理,把Claims信息则保存在服务端,并为其设置一个ID,Cookie中则只保存该ID,这样就可以在服务端通过该ID来检索出完整的Claims信息。不过注意,这并不是在使用 ASP.NET Core 中的 Session,只是参考其存储方式。

那么怎么做呢?在前面注册Cookie认证时,使用的AddCookie方法中,其CookieAuthenticationOptions参数还可以设置一个ITicketStore类型的 SessionStore 属性,我们可以通过实现该接口来自定义 Cookie 的存取方式,下面使用本地缓存来进行示范,在多服务器情况下,需要使用分布式缓存处理。

首先添加Microsoft.Extensions.Caching.Memory的Package引用:

dotnet add package Microsoft.Extensions.Caching.Memory

然后,定义MemoryCacheTicketStore类

public class MemoryCacheTicketStore : ITicketStore
{
    private const string KeyPrefix = "CSS-";
    private IMemoryCache _cache;

    public MemoryCacheTicketStore()
    {
        _cache = new MemoryCache(new MemoryCacheOptions());
    }
    
    public async Task<string> StoreAsync(AuthenticationTicket ticket)
    {
        var key = KeyPrefix + Guid.NewGuid().ToString("N");
        await RenewAsync(key, ticket);
        return key;
    }
    
    public Task RenewAsync(string key, AuthenticationTicket ticket)
    {
        var options = new MemoryCacheEntryOptions();
        var expiresUtc = ticket.Properties.ExpiresUtc;
        if (expiresUtc.HasValue)
        {
            options.SetAbsoluteExpiration(expiresUtc.Value);
        }
        options.SetSlidingExpiration(TimeSpan.FromHours(1));
        _cache.Set(key, ticket, options);
        return Task.FromResult(0);
    }
    
    public Task<AuthenticationTicket> RetrieveAsync(string key)
    {
        _cache.TryGetValue(key, out AuthenticationTicket ticket);
        return Task.FromResult(ticket);
    }
    
    public Task RemoveAsync(string key)
    {
        _cache.Remove(key);
        return Task.FromResult(0);
    }
}

将MemoryCacheTicketStore配置到CookieAuthenticationOptions中:

.AddCookie(options =>
{
    options.SessionStore = new MemoryCacheTicketStore();
});

posted on 2022-03-05 19:29  冠军  阅读(843)  评论(0编辑  收藏  举报