理解ASP.NET Core - 基于Cookie的身份认证(Authentication)


注:本文隶属于《理解ASP.NET Core》系列文章,请查看置顶博客或

概述

通常,身份认证(Authentication)和授权(Authorization)都会放在一起来讲。但是,由于这俩英文相似,且“认证授权”四个字经常连着用,导致一些刚接触这块知识的读者产生混淆,分不清认证和授权的区别,甚至认为这俩是同一个。所以,我想先给大家简单区分一下身份认证和授权。

身份认证

确认执行操作的人是谁。

当用户请求后台服务时,系统首先需要知道用户是谁,是张三、李四还是匿名?确认身份的这个过程就是“身份认证”。在我们的实际生活中,通过出示自己的身份证,别人就可以快速地确认你的身份。

授权

确认操作人是否有执行该项操作的权限。

确认身份后,已经获悉了用户信息,随后来到授权阶段。在本阶段,要做的是确认用户有没有执行该项操作的权限,如确认张三有没有商品查看权限、有没有编辑权限等。

Cookie对于许多人来说,是一个再熟悉不过的东西,熟悉到现在的Web应用,基本离不开它,如果你对Cookie还不太了解,也别慌,我在文末给大家整理了一些高质量的文章,推荐对Cookie有一个整体的了解之后,再来继续阅读下方的内容!

基于Cookie进行身份认证,通常的方案是用户成功登录后,服务端将用户的必要信息记录在Cookie中,并发送给浏览器,后续当用户发送请求时,浏览器将Cookie传回服务端,服务端就可以通过Cookie中的信息确认用户信息了。

在开始之前,为了方便大家理解并能够实际操作,我已经准备好了一个示例程序,请访问XXTk.Auth.Samples.Cookies.Web获取源码。文章中的代码,基本上在示例程序中均有实现,强烈建议组合食用!

身份认证(Authentication)

添加身份认证中间件

在 ASP.NET Core 中,为了进行身份认证,需要在HTTP请求管道中通过UseAuthentication添加身份认证中间件——AuthenticationMiddleware

csharp
public class AuthenticationMiddleware
{
    private readonly RequestDelegate _next;

    public AuthenticationMiddleware(RequestDelegate next, IAuthenticationSchemeProvider schemes)
    {
        _next = next;
        Schemes = schemes;
    }

    public IAuthenticationSchemeProvider Schemes { get; set; }

    public async Task Invoke(HttpContext context)
    {
        // 记录原始路径和原始基路径
        context.Features.Set(new AuthenticationFeature
        {
            OriginalPath = context.Request.Path,
            OriginalPathBase = context.Request.PathBase
        });

        // 如果有显式指定的身份认证方案,优先处理(这里不用看,直接看下面)
        var handlers = context.RequestServices.GetRequiredService();
        foreach (var scheme in await Schemes.GetRequestHandlerSchemesAsync())
        {
            var handler = await handlers.GetHandlerAsync(context, scheme.Name) as IAuthenticationRequestHandler;
            if (handler != null && await handler.HandleRequestAsync())
            {
                return;
            }
        }

        // 使用默认的身份认证方案进行认证,并赋值 HttpContext.User
        var defaultAuthenticate = await Schemes.GetDefaultAuthenticateSchemeAsync();
        if (defaultAuthenticate != null)
        {
            var result = await context.AuthenticateAsync(defaultAuthenticate.Name);
            if (result?.Principal != null)
            {
                context.User = result.Principal;
            }
        }

        await _next(context);
    }
}
折叠 

配置Cookie认证方案

现在,认证中间件已经加好了,现在需要在ConfigureServices方法中添加身份认证所需要用到的服务并进行认证方案配置。

我们可以通过AddAuthentication扩展方法来添加身份认证所需要的服务,并可选的指定默认认证方案的名称,以下方为例:

csharp
public static class CookieAuthenticationDefaults
{
    // 认证方案名
    public const string AuthenticationScheme = "Cookies";

    // Cookie名字的前缀
    public static readonly string CookiePrefix = ".AspNetCore.";
    
    // 登录路径
    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");

    // return url 的参数名
    public static readonly string ReturnUrlParameter = "ReturnUrl";
}

现在,我们已经指定了默认认证方案,接下来就是来配置这个方案的细节,通过后跟AddCookie来实现:

csharp
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddOptions(CookieAuthenticationDefaults.AuthenticationScheme)
            .Configure((options, dp) =>
            {
                options.LoginPath = new PathString("/Account/Login");
                options.LogoutPath = new PathString("/Account/Logout");
                options.AccessDeniedPath = new PathString("/Account/AccessDenied");
                options.ReturnUrlParameter = "returnUrl";

                options.ExpireTimeSpan = TimeSpan.FromDays(14);
                //options.Cookie.Expiration = TimeSpan.FromMinutes(30);
                //options.Cookie.MaxAge = TimeSpan.FromDays(14);
                options.SlidingExpiration = true;
                
                options.Cookie.Name = "auth";
                //options.Cookie.Domain = ".xxx.cn";
                options.Cookie.Path = "/";
                options.Cookie.SameSite = SameSiteMode.Lax;
                options.Cookie.HttpOnly = true;
                options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
                options.Cookie.IsEssential = true;
                options.CookieManager = new ChunkingCookieManager();
                
                options.DataProtectionProvider ??= dp;
                var dataProtector = options.DataProtectionProvider.CreateProtector("Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationMiddleware", CookieAuthenticationDefaults.AuthenticationScheme, "v2");
                options.TicketDataFormat = new TicketDataFormat(dataProtector);

                options.Events.OnSigningIn = context =>
                {
                    Console.WriteLine($"{context.Principal.Identity.Name} 正在登录...");
                    return Task.CompletedTask;
                };

                options.Events.OnSignedIn = context =>
                {
                    Console.WriteLine($"{context.Principal.Identity.Name} 已登录");
                    return Task.CompletedTask;
                };
                
                options.Events.OnSigningOut = context =>
                {
                    Console.WriteLine($"{context.HttpContext.User.Identity.Name} 注销");
                    return Task.CompletedTask;
                };

                options.Events.OnValidatePrincipal += context =>
                {
                    Console.WriteLine($"{context.Principal.Identity.Name} 验证 Principal");
                    return Task.CompletedTask;
                };
            });
        services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
            .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
    }
}
折叠 

以上配置,大多使用了程序的默认值,接下来一一进行详细讲解:

  • LoginPath:登录页路径,指向一个Action
    • 默认/Account/Login
    • 当服务端不允许匿名访问而需要确认用户信息时,跳转到该页面进行登录。
    • 另外,登录方法通常会有一个参数,叫作return url,用来当用户登录成功时,自动跳转回之前访问的页面。这个参数也会自动传递给该Action,下方会详细说明。
  • LogoutPath:注销路径,指向一个Action。默认/Account/Logout
  • AccessDeniedPath:访问拒绝页路径,指向一个Action。默认/Account/AccessDenied。当出现Http状态码 403 时,会跳转到该页面。
  • ReturnUrlParameter:上面提到的return url的参数名,参数值会通过 query 的方式传递到该参数中。默认ReturnUrl
  • ExpireTimeSpan:认证票据(authentication ticket)的有效期。
    • 默认 14 天
    • 认证票据在代码中表现为类型为AuthenticationTicket的对象,它就好像一个手提包,里面放满了可以证明你身份的物品,如身份证、驾驶证等。
    • 认证票据存储在Cookie中,它的有效期与所在Cookie的有效期是独立的,如果Cookie没有过期,但是认证票据过期了,也无法通过认证。在下方讲解登录部分时,有针对认证票据有效期的详细说明。
  • Cookie.Expiration:Cookie的过期时间,即在浏览器中的保存时间,用于持久化Cookie。
    • 对应Cookie中的Expires属性,是一个明确地时间点。
    • 目前已被禁用,我们无法给它赋值。
  • Cookie.MaxAge:Cookie的过期时间,即在浏览器中的保存时间,用于持久化Cookie。
    • 对应Cookie中的Max-Age属性,是一个时间范围。
    • 如果Cookie的Max-AgeExpires同时设置,则以Max-Age为准
    • 如果没有设置Cookie的Expires,同时Cookie.MaxAge的值保持为null,那么该Cookie的有效期就是当前会话(Session),当浏览器关闭后,Cookie便会被清除(实际上,现在的部分浏览器有会话恢复功能,浏览器关闭后重新打开,Cookie也会跟着恢复,仿佛浏览器从未关闭一样)。
  • SlidingExpiration:指示Cookie的过期方式是否为滑动过期。默认true。若为滑动过期,服务端收到请求后,如果发现Cookie的生存期已经超过了一半,那么服务端会重新颁发一个全新的Cookie,Cookie的过期时间和认证票据的过期时间都会被重置。
  • Cookie.Name:该Cookie的名字,默认是.AspNetCore.Cookies
  • Cookie.Domain:该Cookie所属的域,对应Cookie的Domain属性。一般以“.”开头,允许subdomain都可以访问。默认为请求Url的域。
  • Cookie.Path:该Cookie所属的路径,对应Cookie的Path属性。默认/
  • Cookie.SameSite:设置通过浏览器跨站发送请求时决定是否携带Cookie的模式,共有三种,分别是NoneLaxStrict。 csharp
    public enum CookieSecurePolicy
    {
        SameAsRequest,
        Always,
        None
    }
    
    • CookieSecurePolicy.Always:设置Secure=true,当发送登录请求和后续请求均为Https时,浏览器才将Cookie发送给服务端。
    • CookieSecurePolicy.None:不设置Secure,即发送Http请求和Https请求时,浏览器都会将Cookie发送给服务端。
    • CookieSecurePolicy.SameAsRequest:默认值。视情况而定,如果登录接口是Https请求,则设置Secure=true,否则,不设置。
  • Cookie.IsEssential:指示该Cookie对于应用的正常运行是必要的,不需要经过用户同意使用
  • CookieManager:Cookie管理器,用于添加响应Cookie、查询请求Cookie或删除Cookie。默认是ChunkingCookieManager
  • DataProtectionProvider:认证票据加密解密提供器,可以按需提供相应的加密解密工具。默认是KeyRingBasedDataProtector。有关数据保护相关的知识,请参考官方文档-ASP.NET Core数据保护。
  • TicketDataFormat:认证票据的数据格式,内部通过DataProtectionProvider提供的加密解密工具进行认证票据的加密和解密。默认是TicketDataFormat

以下是部分事件回调:

  • Events.OnSigningIn:登录前回调
  • Events.OnSignedIn:登录后回调
  • Events.OnSigningOut:注销时回调
  • Events.OnValidatePrincipal:验证 Principal 时回调

如果你觉得这样注册回调不优雅,那你可以继承自CookieAuthenticationEvents来实现自己的类,内部重写对应的方法即可,如:

csharp
public class AccountController : Controller
{
    [HttpGet]
    public IActionResult Login([FromQuery] string returnUrl = null)
    {
        ViewBag.ReturnUrl = returnUrl;

        return View();
    }

    [HttpPost]
    public async Task Login([FromForm] LoginViewModel input)
    {
        ViewBag.ReturnUrl = input.ReturnUrl;

        // 用户名密码相同视为登录成功
        if (input.UserName != input.Password)
        {
            ModelState.AddModelError("UserNameOrPasswordError", "无效的用户名或密码");
        }

        if (!ModelState.IsValid)
        {
            return View();
        }

        var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme);
        identity.AddClaims(new[]
        {
            new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString("N")),
            new Claim(ClaimTypes.Name, input.UserName)
        });

        var principal = new ClaimsPrincipal(identity);

        // 登录
        var properties = new AuthenticationProperties
        {
            IsPersistent = input.RememberMe,
            ExpiresUtc = DateTimeOffset.UtcNow.AddSeconds(60),
            AllowRefresh = true
        };
        await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, properties);

        if (Url.IsLocalUrl(input.ReturnUrl))
        {
            return Redirect(input.ReturnUrl);
        }

        return Redirect("/");
    }
}
折叠 

首先说一下ClaimIdentityPrincipal

  • Claim:表示一条信息的声明。以我们的身份证为例,里面包含姓名、性别等信息,如“姓名:张三”、“性别:男”,这些都是Claim。
  • Identity:表示一个身份。对于一个ClaimsIdentity来说,它是由一个或多个Claim组成的。我们的身份证就是一个Identity。
  • Principal:表示用户本人。对于一个ClaimsPrincipal来说,它是由一个或多个ClaimsIdentity组成的。想一下,我们每个人的身份不仅仅只有一种,除了身份证外,还有驾驶证、会员卡等。

回到Login方法,首先声明了一个ClaimsIdentity实例,并将CookieAuthenticationDefaults.AuthenticationScheme作为认证类型来传入。需要注意的是,这个认证类型一定不要是null或空字符串,否则,默认配置下,你会得到如下错误:

text
Cookie.Expiration is ignored, use ExpireTimeSpan instead.

可是ExpireTimeSpan属性,注释明确地说它指的不是Cookie的Expires属性,而是票据的有效期,这又是咋回事呢?其实,你可以想象一下以下场景:该Cookie的ExpiresMax-Age都没有被设置(程序允许它们为空),那么该Cookie的有效期就是当前会话,但是,你通过设置AuthenticationProperties.IsPersistent = true来表明该Cookie是持久化的,这就产生了歧义,实际上Cookie并没有持久化,但是代码却认为它持久化了。所以,为了解决这个歧义,Cookie.Expiration就被禁用了,而新增了一个ExpireTimeSpan属性,它除了可以作为票据的有效期外,还能在Cookie的ExpiresMax-Age都没有被设置但AuthenticationProperties.IsPersistent = true的情况下,将值设置为Cookie的Expires属性,使得Cookie也被持久化。

我们看一下登录效果:

  • 未选择“记住我”时:

  • 选择“记住我”时:

其他的特性自己摸索一下吧!

下面是SignInAsync 的核心内部细节模拟,更多细节请查看AuthenticationServiceCookieAuthenticationHandler

csharp
[HttpPost]
public async Task Logout()
{
    await HttpContext.SignOutAsync();

    return Redirect("/");
}

可以看到名为“auth”的Cookie已被清空:

至此,一个简单的基于Cookie的身份认证功能就实现了。

授权(Authorization)

添加授权中间件

要使用授权,需要先通过UseAuthorization添加授权中间件——AuthorizationMiddleware

csharp
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthorization(options =>
        {
            options.DefaultPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
            options.InvokeHandlersAfterFailure = true;
        });
    }
}
  • DefaultPolicy:默认的授权策略,默认为new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build(),即通过身份认证的用户才能获得授权。
  • InvokeHandlersAfterFailure:当存在多个授权处理器时,若其中一个失败后,后续的处理器是否还继续执行。默认为true,即会继续执行。

Url添加授权

现在,我们要求用户登录后才可以访问/Home/Privacy,为其添加特性[Authorize],不需要传入策略policy,就用默认策略即可:

csharp
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // Cookie全局策略
        services.AddCookiePolicy(options =>
        {
            options.OnAppendCookie = context =>
            {
                Console.WriteLine("------------------ On Append Cookie --------------------");
                Console.WriteLine($"Name: {context.CookieName}\tValue: {context.CookieValue}");
            };

            options.OnDeleteCookie = context =>
            {
                Console.WriteLine("------------------ On Delete Cookie --------------------");
                Console.WriteLine($"Name: {context.CookieName}");
            };
        });

        services.AddControllersWithViews();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler("/Home/Error");
        }
        app.UseStaticFiles();

        app.UseRouting();

        // Cookie 策略中间件
        app.UseCookiePolicy();

        // 身份认证中间件
        app.UseAuthentication();
        // 授权中间件
        app.UseAuthorization();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllerRoute(
                name: "default",
                pattern: "{controller=Home}/{action=Index}/{id?}");
        });
    }
}
折叠 

优化改进

优化Claim以减小身份认证Cookie体积

在用户登录时,验证通过后,会添加Claims,其中“类型”使用的是微软提供的ClaimTypes

csharp
public static class ClaimTypes
{
    public const string Name = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name";

    public const string NameIdentifier = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier";
}

我们可以使用JwtClaimTypes进行优化:

cli
Install-Package IdentityModel
  1. 进行替换,注意要在创建ClaimsIdentity实例时指定NameRole的类型,这样HttpContext.User.Identity.NameHttpContext.User.IsInRole(string role)才能正常使用:
csharp
public class MemoryCacheTicketStore : ITicketStore
{
    private const string KeyPrefix = "AuthSessionStore-";
    private readonly IMemoryCache _cache;
    private readonly TimeSpan _defaultExpireTimeSpan;

    public MemoryCacheTicketStore(TimeSpan defaultExpireTimeSpan, MemoryCacheOptions options = null)
    {
        options ??= new MemoryCacheOptions();
        _cache = new MemoryCache(options);
        _defaultExpireTimeSpan = defaultExpireTimeSpan;
    }

    public async Task<string> StoreAsync(AuthenticationTicket ticket)
    {
        var guid = Guid.NewGuid();
        var key = KeyPrefix + guid.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);
        }
        else
        {
            options.SetSlidingExpiration(_defaultExpireTimeSpan);
        }

        _cache.Set(key, ticket, options);

        return Task.CompletedTask;
    }

    public Task RetrieveAsync(string key)
    {
        _cache.TryGetValue(key, out AuthenticationTicket ticket);
        return Task.FromResult(ticket);
    }

    public Task RemoveAsync(string key)
    {
        _cache.Remove(key);
        return Task.CompletedTask;
    }
}
折叠 

然后,只需要给CookieAuthenticationOptions.SessionStore赋值就好了:

text
CfDJ8OGRqoEUgBZEu4m5Q8NfuATXjRKivKy7CR-oPpx2SaNJ8n1GWyBbPhNTEQzzIbZ62DqJPuxKtBJ752GqNxod9U5paaI_aQdH9EOH8nvgrinjvdHTneeKlhBvamEQrq7nA1e3wJOuQwFXRJASUphkS3kQzvc4-Upz27AAfoD510MC7YiwlhyxWl7agb8F0eeiilxAHDn4gskVqshu2hc5ENQAJNjXpa0yVaseryvsPrbukv5jqGC12WuUVe1cYhBIdWHHT61ZJcNtvNOAdtVlVA7i7RCJUBxNCUAhB-mw_s7R4GsNbU8aW7Ye9H-tx5067w

好文推荐

  • Cookie
    • Set-Cookie
      • Samesite
        • Cookie Samesite简析
        • Cookie 的 SameSite 属性 - 阮一峰
        • CSRF 漏洞的末日?关于 Cookie SameSite 那些你不得不知道的事
        • Understanding "same-site" and "same-origin"
      • Secure
        • Cookie的Secure属性

源码请戳XXTk.Auth.Samples.Cookies.Web

 

__EOF__

 
  • 本文作者: xiaoxiaotank
  • 本文链接: 
  • 关于博主: 使用微信扫描一下左侧的二维码关注我的订阅号,或加入我的QQ交流群:705680556
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
  • 声援博主: 如果您觉得文章对您有帮助,可以点击文章右下角【推荐】一下。