假设你是一名大学生,你们学校有个网站,用来选课、查看考试成绩等。
又或者你是个公司职员,你们公司有个网站(OA系统),用来打卡签到查工资单年终奖等。
当你要访问学校网站或公司网站的时候,从用户体验的角度来看,你对这个网站的体验大抵是:
这个网站需要登录,或者说大部分内容、功能都需要在登录后查看。你需要在登录页面输入用户名、密码,点击登录
登录进网站后,你只能查看、或者使用这个网站的一部分功能:
作为网站的使用者而言,整个过程的核心在于“登录”:
而如果你站在网站开发者的角度来看这件事的话,你会发现,上面描述的“登录后浏览网站”的这个过程,其实是要分成两步的:
提交用户名+密码
这个事在用户的角度来看,就是访问网站需要提供一个凭据,来证明自己是网站的用户
这个事在网站的角度来看,在用户提交了一个凭据后,网站需要:
登录后浏览网站内容,或者使用网站功能
这两个步骤其实是相对独立的,或者说,在网站开发者的角度来讲,这两个步骤可以拆分成两坨代码去执行:
前者就叫认证(Authentication)
,简写为AuthN
,后者就叫授权(Authorization)
,简写为AuthZ
这里再多说一点文化差异:在中文语境中,“授权”这个词其实和
Authorization
不是完全划等号的。
“授权”给我们的直觉是一个动词,并且总有一股爹味:即听起来,像是有个领导,为你授予了某种特权之后,你才能去做某件事。
大多数人只有在仔细琢磨之后,才能琢磨出来“授权”这个词的名词意味,比如小张向保安怒吼道:“老子进库房领材料是有领导的授权的,你算个什么狗东西敢拦我!”
而英文中的
Authorization
,是一个名词,它有两个意思:
- 是站在保安的角度,“审查访客是否有权限去做这件事”这个过程的名词化描述,把整个审核过程名词化,意思就是
Authorization
。
- 指某种能证明我有权限的文档或者证明材料,比如“领导签过字的授权文件”,“皇上颁给钦差的圣旨”
而我们现在所讨论的
Authorization
,其实说的是它的第一个意思,其实对于这个意思来说,中文中更合适的翻译应当叫“鉴权”,或者干脆大白话一点,叫“权限审查”也可以。
在开发者的角度来看,认证包含两个动作:
授权也包含两个动作:
在用户这边,登录一个网站,只需要填写一次用户名和密码,后续的访问、跳转都不需要,或者至少短期内是不需要再输入用户名和密码了。就好像网站门前有个老大爷,在你输入完一次用户名和密码后,就认识你了一样,后续的出入都不再问“小伙子你哪位?这里不让外卖进”一样。
但在代码的世界里,门口的老大爷是纯粹的脸盲+老年痴呆:大爷记不住任何人的脸。
那大爷为什么不次次都找你要用户名和密码呢?因为凭据的原理,其实分两步:
后续你进出网站,你脑门上都贴了这个纸条,大爷是看见你脑门上有那个纸条,才放你进去的。
这个纸条只是一个比喻,在现实世界里,这个纸条有两种最常见的实现方式
Set-Cookie: xxxx
字样,那么浏览器在收到这个Response后,会在后续所有对这个站点的访问请求中,在HTTP Request头部添加字段Cookie: xxx
。Authorization
字段这个HTTP Request的头部字段名叫
Authorization
,像是“授权”的意思。但实际上,服务端程序在处理这个HTTP Request的时候,是从这个字段里解析“认证”小纸条
要正确理解这个字段的语义,就得再回顾一下英文中这个单词的意思,我们上面讲了,Authorization这个单词在英文中有两个意思
- 指“审查某人是否有权限去做某事”这个过程
- 指某种能够证明我权限的文档或材料,比如“领导签字”
站在HTTP协议制订者,或者HTTP请求发送者的角度上来说,请求头部中的
Authorization
字段的语义是第二个意思。
理解到这个小知识点,你就能理解:为什么明明服务端是解析这个字段去做
Authentication
的,但这个字段的名字却叫Authorization
当我们以“纸条”为核心,去考虑如何以asp .net core middleware的方式实现网站的认证功能的时候,就可以总结出以下伪代码逻辑:
public async Task AuthenticationMiddleware(HttpContext ctx, RequestDelegate next) {
if(ctx.Request 中没有纸条,或者纸条过期,或者纸条不合法) {
return 就地返回,引导用户去登录页面拿纸条
}
ctx.User = 解析纸条信息,拿到纸条中的用户信息;
await next.InvokeAsync(ctx);
}
而我们如果再延申一点,去考虑如何以asp .net core middleware的方式实现网站的权限,即授权功能的时候,可以总结出以下伪代码逻辑:
public async Task AuthorizationMiddleware(HttpContext ctx, RequestDelegate next) {
var policies = 从预先定义权限规则的地方,拿到权限规则;
var requestPath = 拿到本次请求的访问路径;
var userInfo = ctx.User;
var isAllow = 判断本次请求是否合法(policies, requestPath, userInfo);
if (isAllow) {
await next.InvokeAsync(ctx);
} else {
ctx.Response.StatusCode = 401;
}
}
在asp .net core框架中,权限规则是分散成两部分的,具体哪两部分这个后续我们讲到AuthZ的细节的时候再细说,这里只提一下,框架允许程序员把权限信息以Attribute的形式写在各endpoint的脑门上,就像下面的Controller:
[Authorize(policy: "ManagersOnly")]
[ApiController]
[Route("api/[controller]")]
public class xxxController: ControllerBase
{
// ...
}
这就导致在Authorization Middleware执行前,就需要先拿到这个请求对应的endpoint的信息,换句话说,在request pipeline中,EndpointRoutingMiddleware
需要先执行,Authorization Middleware需要后执行。
而Authentication Middleware做的事只是解析纸条,并不需要endpoint的信息,所以在request pipeline中,EndpointRoutingMiddleware
可以放在Authentication Middleware之后。
所以,通常情况下,在配置request pipeline的时候,需要遵循下面两个规则 :
ctx.User
ctx.Endpoint
理解到这点,再回头看下面这张出息asp .net core官方文档中的图,是不是又多了一点体会呢?
我们上面的伪代码中,说过,认证的目的就是从HTTP请求中读纸条,然后解析纸条,从中解析出一个用户信息来。再把这个用户信息放在ctx.User
字段中去。
虽然上面的代码是伪代码,但有一件事是真的:那就是在asp .net core中,沿着request pipeline传递的HttpContext
对象,确实有个字段叫User
,并且这个字段的作用,确实是用来存储认证结果的。
比如我们用dotnet new web --use-program-main -o HelloSimpleAuthN
来创建一个空的web应用,并把它的Program.cs
写成下面这样:
namespace HelloSimpleAuthN;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", async ctx =>
{
await ctx.Response.WriteAsync("Hello");
});
app.Run();
}
}
我们在那个唯一的endpoint执行体内打个断点,通过调试器就可以观察到ctx.User
字段:虽然目前的代码没有认证功能,但这个字段是存在的
它并不像我们想当然的,里面用Username, FirstName, LastName, Email, Gender
之类的常用字段来描述一个“用户”,而是里面有着Claims
,Identities
,Identity
三个字段,让人有些许疑惑:
Claims
?Identity
字段,还有一个复数形式的Identities
字段呢?要解答这些疑惑,就得介绍一下asp .net core中有关“用户”的三层概念:
咱先不论这三个概念的语义,你先记住:
说白了,Principal就是一个类似IList<Dictionary<string, string>>
的东西。
先牢牢记住这三层概念在数据结构层面的样子,然后我们来介绍它们的语义:
Principal这个概念,对应着框架里的System.Security.Claims.ClaimsPrincipal
类型:它描述的是一个自然人,一个网站的用户。
asp .net core框架认为网站的认证授权过程中只能存在一个登录用户:即不存在一种场景,有两个人坐在一台电脑页面,希望同时登录他们两个的账号,继而将两个人有权限访问的内容都合并起来展示在浏览器上。
所以在框架的的默认实现中,HttpContext
只持有一个User
字段,当然了,这个字段的类型就是ClaimsPrincipal
Identity概念对应着框架里的System.Security.Claims.ClaimsIdentity
类型:它描述的是一个人的某种身份。
听起来好像一个自然人,会有多种身份,是吗?没错,asp .net core框架是这样认为的。怎么理解这个逻辑呢?
假设有一个大四的学生叫小明,那么小明作为一个自然人,其实同时有多个身份:
这样的例子在小明身上还能举出很多个,即:
要完整、准确的描述一个自然人的话,我们需要很多的信息,乃至于其实我们无法在程序中准确的描述一个自然人,因为与一个自然人有关的信息,或者说数据,实在太多了:要不要记录他的性别?要不要记录他是否脱发?
大多数时候,我们其实关注的只是一个人的一部分数据:而这一部分数据,其实描述的就是一个身份,一个Identity
而在程序的世界里,这个道理也是成立的:
那么,作为一个网站开发框架,只用一个身份去描述它的用户,合理吗?在95%的情况下是合理的,在另外5%的情况下是不合理的:因为有些网站可能同时会关注用户的好几个身份。
比如小明所在的学校与某几个企业的老板一拍脑门,校领导与企业老板决定让校企合作更粘稠一些:我们把企业OA和学校信息系统集成起来吧!这样学生用身份证号作用户名登录后,就既能查看学校方面的信息,也能查看自己的实习企业信息了,既能在上面选课,也能申请加入企业的一些实习项目。
那么这个应用,就会同时关注用户的多个身份:在访问校内资源的时候,程序要检查小明的“学生”身份相关的信息,来审阅权限,在访问企业资源的时候,程序要检查小时的“雇员”身份相关信息,来审阅权限。
所以,asp .net core框架认为:一个自然人,即ClaimsPrincipal
,可以有一个或多个身份,即ClaimsIdentity
。
所以在ClaimsPrincipal
类的定义中,你会看到有个属性的名字叫Identities
,是复数形式,类型是IEnumerable<ClaimsIdentity>
。
虽然在ClaimsPrincipal
类的定义中,也有一个属性的名字叫Identity
,但请注意:
如果一个用户只有一个身份的话,可以拿Identity
属性作为快速访问这个身份的一个语法糖去用,没什么问题
如果一个用户有多种身份的话,就不要贸然使用Identity
属性了,具体原因见官方文档对这个字段的说明,我大致翻译一下,内容如下:
这个字段的语义是“主要身份”
默认情况下,框架会优先将实际类型为
WindowsIdentity
的对象列为“主要身份”
如果没有
WindowsIdentity
的实例的话,框架就会将Identities
属性中的第一个身份返回
要改写主要身份的筛选逻辑,你需要写个新类去继承
ClaimsPrincipal
类,再覆盖PrimaryIdentitySelector
方法,把“主要身份”的筛选逻辑放在里面
如果你对上面的内容感到莫名其妙,那么就忽略它就可以了,先不要管这个奇怪的
Identity
属性
我们上面说了,一个身份是多份数据的集合。而Claim这个概念,就是“数据”的意思。它对应着框架里的System.Security.Claims.Claim
类。
大多数时候,描述身份的数据,其实就是个键值对而已,比如以下的例子:
键 | 值 |
---|---|
性别 | 男 |
年龄 | 21 |
职位 | 普通员工 |
年级 | 大四 |
专业 | 采矿工程 |
所以Claim
的本质就是个键值对:Claim.Type
和Claim.Value
分别描述了键和值。这两个字段都是string
类型。
不过框架为了更好的适应各种奇形怪状的需求以及提供给程序员尽可能多的扩展性,在键值对的基础上,还给Claim
类扩展了其它字段,这些字段我们目前忽视它们。
理解了Claim, Identity, Principal三层概念,也了解了它们对应的在asp .net core框架中的类型后,就很容易的能在代码中创建出一个用户出来,即ClaimsPrincipal
实例,如下:
using System.Security.Claims;
namespace HelloSimpleAuthN;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.Use(async (ctx, next) =>
{
List<Claim> claimsOfXiaoMingAsAStudent = new List<Claim>
{
new Claim("name", "Xiao Ming"),
new Claim("major", "Mining Engineering"),
new Claim("grade", "junior"),
new Claim("is member of union", "false")
};
List<Claim> claimsOfXiaoMingAsADriver = new List<Claim>
{
new Claim("name", "Xiao Ming"),
new Claim("age", "21"),
new Claim("type", "C2"),
new Claim("issuance date", "2024-03-17"),
new Claim("expiration date", "2030-03-16")
};
List<Claim> claimsOfXiaoMingAsAnEmployee = new List<Claim>
{
new Claim("name", "Xiao Ming"),
new Claim("age", "21"),
new Claim("title", "intern engineer"),
new Claim("department", "Engineering/Tunneling Team 3")
};
List<ClaimsIdentity> identitiesOfXiaoMing = new List<ClaimsIdentity>
{
new ClaimsIdentity(claimsOfXiaoMingAsAStudent),
new ClaimsIdentity(claimsOfXiaoMingAsADriver),
new ClaimsIdentity(claimsOfXiaoMingAsAnEmployee),
};
ClaimsPrincipal xiaoming = new ClaimsPrincipal(identitiesOfXiaoMing);
ctx.User = xiaoming;
await next.Invoke(ctx);
});
app.MapGet("/", async ctx =>
{
await ctx.Response.WriteAsync("Hello");
});
app.Run();
}
}
上面的代码确实在处理每个Http请求的时候向ctx.User
写入了一个用户信息,但问题是:这个用户是在代码中写死的,而不是从“HTTP请求脑门上的纸条”中读取出来的。
我们上面说了,凭据这事,其实有两步:
上面的代码没有第一步,即没有“登录”体验。对于第二步,用户信息也不是从某个纸条中获取的,而是无中生有直接生出来的。
在这一章节,我们从0开始思考“认证”的本质,并一步步的用代码去实现它。
现在我们抛弃框架,假设asp .net core没有提供任何有关认证或授权的基础设施,在这种情况下,我们想实现一个“认证”功能,需要做什么?
我们设计这样一个网站,整个网站只有三个endpoint:
而要实现这样一个简陋到令人发指的网站,代码也简单直观到令人发指,如下:
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/login", async ctx =>
{
ctx.Response.Headers.SetCookie = "userinfo=username:Alice,gender:female,age:18";
await ctx.Response.WriteAsync($"You've just logged in as Alice");
});
app.MapGet("/logout", async ctx =>
{
ctx.Response.Headers.SetCookie = "userinfo=";
await ctx.Response.WriteAsync($"You've just logged out");
});
app.MapGet("/", async ctx =>
{
string? userinfo = null;
foreach(var cookieKvp in ctx.Request.Headers.Cookie)
{
if(cookieKvp.StartsWith("userinfo"))
{
userinfo = cookieKvp.Split("=")[1];
break;
}
}
if(string.IsNullOrEmpty(userinfo))
{
await ctx.Response.WriteAsync($"You are not login yet, please visit /login to login");
}
else
{
await ctx.Response.WriteAsync($"You currently logged in as {userinfo}");
}
});
app.Run();
}
}
运行效果如下:
以上的逻辑非常简单,我们甚至都不需要使用到框架为我们设计的ClaimsPrincipal/ClaimsIdentity/Claim
类以及HttpContext.User
属性,就已经从本质上说明了“认证”的本质。
在不更改逻辑的情况下,我们可以将登录、注销、认证的逻辑从endpoint中抽离出来,封装在一个独立的类中,在不改变上述代码的逻辑的前提下,我们就可以做如下的重构:
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<AuthService>();
var app = builder.Build();
app.MapGet("/login", async ctx =>
{
AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
string userinfo = "username:Alice,gender:female,age:18";
auth.Signin(userinfo);
await ctx.Response.WriteAsync($"You've just logged in as Alice");
});
app.MapGet("/logout", async ctx =>
{
AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
auth.Signout();
await ctx.Response.WriteAsync($"You've just logged out");
});
app.MapGet("/", async ctx =>
{
AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
string? userinfo = auth.Authenticate();
if(string.IsNullOrEmpty(userinfo))
{
await ctx.Response.WriteAsync($"You are not login yet, please visit /login to login");
}
else
{
await ctx.Response.WriteAsync($"You currently logged in as {userinfo}");
}
});
app.Run();
}
}
public class AuthService
{
IHttpContextAccessor ctxAccessor;
public AuthService(IHttpContextAccessor ctxAccessor)
{
this.ctxAccessor = ctxAccessor;
}
public void Signin(string userinfo)
{
ctxAccessor.HttpContext!.Response.Headers.SetCookie = $"userinfo={userinfo}";
}
public void Signout()
{
ctxAccessor.HttpContext!.Response.Headers.SetCookie = $"userinfo=";
}
public string? Authenticate()
{
string? userinfo = null;
foreach (var cookieKvp in ctxAccessor.HttpContext!.Request.Headers.Cookie)
{
if (cookieKvp.StartsWith("userinfo"))
{
userinfo = cookieKvp.Split("=")[1];
break;
}
}
return userinfo;
}
}
将逻辑抽离出来最大的好处,就是endpoint不用再关心登录、注销、认证的具体实现细节了,实现了逻辑上的解耦。
上面有一个知识点,即IHttpContextAccessor
,这里简单的过一下:
HttpContext
实例,这没什么问题。但像上述AuthService
这种类,它与request pipeline和endpoint是解耦的,是无法直接访问到HttpContext
对象的IHttpContextAccessor
,但这个访问器默认情况下是没有注册到DI池中的,需要通过手动调用builder.Services.AddHttpContextAccessor()
来将其进行注册AuthService
在实现时将IHttpContextAccessor
声明成构造函数的参数,又被注册到DI池中,生命周期为Scoped
。这种情况下,当我们在一次Http请求的处理过程中,首次通过ctx.RequestServices.GetRequiredService<AuthService>()
来获取AuthService
的实例时,DI池就会为我们新创建一个AuthService
对象,创建时填充构造函数用的就是之前注册好的IHttpContextAccessor
对象在上例中,其实将AuthService
的生命周期声明为Transient
和Singleton
,都可以实现相同的效果,因为
AuthService
虽然在访问HttpContext
,但每次访问ctx.Accessor.HttpContext
时,拿到的都是“当前的HttpContext
”AuthService
整个类里的三个功能,除了HttpContext
实例之外,不依赖任何状态,自身也没有保存任何状态,所以从纯代码逻辑的角度来说,这个对象应当在DI池中被注册为Singleton
以上代码的最大、最明显的问题是:cookie依赖于浏览器的默认行为,但cookie其实是可以篡改的,如下:
在实际中,认证使用的纸条应当满足如下的条件:
这四条的核心在于:
所以我们可以把上面的代码升级成下面这样:
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
+ builder.Services.AddDataProtection();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<AuthService>();
var app = builder.Build();
app.MapGet("/login", async ctx =>
{
AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
string userinfo = "username:Alice,gender:female,age:18";
auth.Signin(userinfo);
await ctx.Response.WriteAsync($"You've just logged in as Alice");
});
app.MapGet("/logout", async ctx =>
{
AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
auth.Signout();
await ctx.Response.WriteAsync($"You've just logged out");
});
app.MapGet("/", async ctx =>
{
AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
string? userinfo = auth.Authenticate();
if(string.IsNullOrEmpty(userinfo))
{
await ctx.Response.WriteAsync($"You are not login yet, please visit /login to login");
}
else
{
await ctx.Response.WriteAsync($"You currently logged in as {userinfo}");
}
});
app.Run();
}
}
public class AuthService
{
IHttpContextAccessor ctxAccessor;
+ IDataProtectionProvider idp;
+
+ IDataProtector DataProtector => this.idp.CreateProtector("dp for authentication");
+
+ long CurrentTimestamp => new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds();
- public AuthService(IHttpContextAccessor ctxAccessor)
- {
- this.ctxAccessor = ctxAccessor;
- }
+ public AuthService(IHttpContextAccessor ctxAccessor, IDataProtectionProvider idp)
+ {
+ this.ctxAccessor = ctxAccessor;
+ this.idp = idp;
+ }
public void Signin(string userinfo)
{
- ctxAccessor.HttpContext!.Response.Headers.SetCookie = $"userinfo={userinfo}";
+ ctxAccessor.HttpContext!.Response.Headers.SetCookie = new Microsoft.Extensions.Primitives.StringValues(
+ new string[]
+ {
+ $"userinfo={this.DataProtector.Protect(userinfo)}",
+ $"userinfo_expire_time={this.DataProtector.Protect((this.CurrentTimestamp + 10).ToString())}"
+ });
}
public void Signout()
{
- ctxAccessor.HttpContext!.Response.Headers.SetCookie = $"userinfo=";
+ ctxAccessor.HttpContext!.Response.Headers.SetCookie = new Microsoft.Extensions.Primitives.StringValues(
+ new string[]
+ {
+ "userinfo=",
+ "userinfo_expire_time="
+ });
}
public string? Authenticate()
{
- string? userinfo = null;
- foreach (var cookieKvp in ctxAccessor.HttpContext!.Request.Headers.Cookie)
- {
- if (cookieKvp.StartsWith("userinfo"))
- {
- userinfo = cookieKvp.Split("=")[1];
- break;
- }
- }
- return userinfo;
+ string? userinfo = null;
+ bool expired = true;
+
+ foreach (var cookieKvp in ctxAccessor.HttpContext!.Request.Headers.Cookie)
+ {
+ try
+ {
+ string key = cookieKvp.Split("=")[0];
+ string value = cookieKvp.Split("=")[1];
+ if (key == "userinfo")
+ {
+ userinfo = this.DataProtector.Unprotect(value);
+ }
+
+ if (key == "userinfo_expire_time")
+ {
+ string expireTimeStr = this.DataProtector.Unprotect(value);
+ if (long.Parse(expireTimeStr) > this.CurrentTimestamp)
+ {
+ expired = false;
+ }
+ }
+ }
+ catch
+ {
+ userinfo = null;
+ break;
+ }
+ }
+
+ return expired ? null : userinfo;
}
}
运行效果如下,首先是登录的用户十秒后纸条过期,需要重新登录:
其次是cookie的内容是加密的:无法假冒,甚至无法篡改,篡改后的cookie会导致解析的时候抛出异步,从而判定为用户信息不存在:
除了新增一个cookie写明过期时间外,以上代码最大的改动是引入了asp .net core框架内置的数据加密API,即我们在DI池中调用的那个builder.Services.AddDataProtection()
,这行调用的作用是:向DI池中注册了一个IDataProtectionProvider
对象。
通过这个IDataProtectionProvider
对象,可以调用CreateProtector(string)
方法来获取到一个加密解密器IDataProtector
。通过一个IDataProtectionProvider
可以获取到N多个加密解密器,而CreateProtector(string)
中的参数就起到区分这个加密解密器的作用,你可以把参数理解为“加密解密器的名字”。
加密解密默认情况下使用的是对称加密算法,这也很符合直觉,因为加密和解密都在同一个地点执行,即服务端。而这有一个关键问题,即算法的密钥,存放在哪里?默认情况下,密钥存储在%LOCALAPPDATA%\ASP.NET\DataProtection-Keys
目录,但我们可以通过以下的方式在代码中明确指定将密钥存储在文件系统指定路径,或者数据库中(我们目前还未涉及如何连接数据库,先感受一下就行了)
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(@"\\server\share\directory\"));
builder.Services.AddDataProtection()
.PersistKeysToDbContext<SampleDbContext>();
对数据库来说,事情稍微麻烦一点,对应的DbContext
类必须实现IDataProtectionKeyContext
接口,不过这是后话,我们讲到数据库的时候再细聊。
OK把话题拉回到认证方面:我们上面的代码已经用手撸cookie的方式实现了asp .net core框架中AuthenticationMiddleware的核心功能了,下一步我们要做的,就是把用户信息包装在一个ClaimsPrincipal中,赋给ctx.User
,并且把“读纸条解析用户信息”这件事,封装成一个Middleware
代码如下:
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDataProtection();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<AuthService>();
+ builder.Services.AddScoped<BasicCookieAuthMiddleware>();
var app = builder.Build();
+
+ app.UseBasicCookieAuth();
app.MapGet("/login", async ctx =>
{
AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
string userinfo = "username:Alice,gender:female,age:18";
auth.Signin(userinfo);
await ctx.Response.WriteAsync($"You've just logged in as Alice");
});
app.MapGet("/logout", async ctx =>
{
AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
auth.Signout();
await ctx.Response.WriteAsync($"You've just logged out");
});
app.MapGet("/", async ctx =>
{
- AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
- string? userinfo = auth.Authenticate();
-
- if(string.IsNullOrEmpty(userinfo))
- {
- await ctx.Response.WriteAsync($"You are not login yet, please visit /login to login");
- }
- else
- {
- await ctx.Response.WriteAsync($"You currently logged in as {userinfo}");
- }
+ await ctx.Response.WriteAsync($"<h1>claims count == {ctx.User.Claims.Count()}</h1>");
+ foreach(var claim in ctx.User.Claims)
+ {
+ await ctx.Response.WriteAsync($"<h1>{claim.Type} : {claim.Value}</h1>");
+ }
});
app.Run();
}
}
+public class BasicCookieAuthMiddleware : IMiddleware
+{
+ private AuthService auth;
+ public BasicCookieAuthMiddleware(AuthService auth)
+ {
+ this.auth = auth;
+ }
+ public async Task InvokeAsync(HttpContext context, RequestDelegate next)
+ {
+ this.auth.Authenticate();
+ await next.Invoke(context);
+ }
+}
+
+public static class BasicCookieAuthMiddlewareExtensions
+{
+ public static void UseBasicCookieAuth(this IApplicationBuilder app )
+ {
+ app.UseMiddleware<BasicCookieAuthMiddleware>();
+ }
+}
+
public class AuthService
{
IHttpContextAccessor ctxAccessor;
IDataProtectionProvider idp;
IDataProtector DataProtector => this.idp.CreateProtector("dp for authentication");
long CurrentTimestamp => new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds();
public AuthService(IHttpContextAccessor ctxAccessor, IDataProtectionProvider idp)
{
this.ctxAccessor = ctxAccessor;
this.idp = idp;
}
public void Signin(string userinfo)
{
ctxAccessor.HttpContext!.Response.Headers.SetCookie = new Microsoft.Extensions.Primitives.StringValues(
new string[]
{
$"userinfo={this.DataProtector.Protect(userinfo)}",
$"userinfo_expire_time={this.DataProtector.Protect((this.CurrentTimestamp + 10).ToString())}"
});
}
public void Signout()
{
ctxAccessor.HttpContext!.Response.Headers.SetCookie = new Microsoft.Extensions.Primitives.StringValues(
new string[]
{
"userinfo=",
"userinfo_expire_time="
});
}
- public string? Authenticate()
+ public void Authenticate()
{
string? userinfo = null;
bool expired = true;
foreach (var cookieKvp in ctxAccessor.HttpContext!.Request.Headers.Cookie)
{
try
{
string key = cookieKvp.Split("=")[0];
string value = cookieKvp.Split("=")[1];
if (key == "userinfo")
{
userinfo = this.DataProtector.Unprotect(value);
}
if (key == "userinfo_expire_time")
{
string expireTimeStr = this.DataProtector.Unprotect(value);
if (long.Parse(expireTimeStr) > this.CurrentTimestamp)
{
expired = false;
}
}
}
catch
{
userinfo = null;
break;
}
}
- return expired ? null : userinfo;
+
+ if(!expired && userinfo is not null)
+ {
+ IEnumerable<KeyValuePair<string, string>> userClaims = userinfo.Split(",").Select(kvpStr => new KeyValuePair<string, string>(kvpStr.Split(":")[0], kvpStr.Split(":")[1]));
+ IEnumerable<Claim> claims = userClaims.Select(kvp => new Claim(kvp.Key, kvp.Value));
+ ClaimsIdentity identity = new ClaimsIdentity(claims);
+ ClaimsPrincipal principal = new ClaimsPrincipal(identity);
+ ctxAccessor.HttpContext.User = principal;
+ }
}
}
上面我们的代码改动做了这么几件事:
在AuthService
中,把Authenticate
方法的返回值改成了void
:因为现在如果成功的从纸条中解析到用户信息的话,可以直接把用户信息写在HttpContext.User
字段中
我们把认证逻辑包装成了一个Middleware,于是就有了BasicCookieAuthMiddleware
类和BasicCookieAuthMiddlewareExtensions
类
endpoint "/"
的逻辑里,这导致其它endpoint如果也需要解析用户信息,就需要再调用一次AuthService.Authenticate()
方法,现在好了,在所有endpoint执行之前,有BasicCookieAuthMiddleware
去解析用户信息,并写在ctx.User
中登录和注销的逻辑没有更改,依然是在endpoint中直接调用AuthService
的Signin
和Signout
方法来实现登录与注销
endpoint "/"
现在渲染的是ctx.User
中的Claims
现在的运行效果如下所示:
接下来,我们再进一步,把登录与注销两部分逻辑,写成HttpContext
的扩展方法,让endpoint的逻辑更清晰一些
HttpContext
的扩展方法我们进一步对代码做如下改动:
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDataProtection();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<BasicCookieAuthMiddleware>();
var app = builder.Build();
app.UseBasicCookieAuth();
app.MapGet("/login", async ctx =>
{
+ ctx.BasicCookieAuthSignin("username:Alice,gender:female,age:18");
- AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
- string userinfo = "username:Alice,gender:female,age:18";
- auth.Signin(userinfo);
await ctx.Response.WriteAsync($"You've just logged in as Alice");
});
app.MapGet("/logout", async ctx =>
{
+ ctx.BasicCookieAuthSignout();
- AuthService auth = ctx.RequestServices.GetRequiredService<AuthService>();
- auth.Signout();
await ctx.Response.WriteAsync($"You've just logged out");
});
app.MapGet("/", async ctx =>
{
await ctx.Response.WriteAsync($"<h1>claims count == {ctx.User.Claims.Count()}</h1>");
foreach(var claim in ctx.User.Claims)
{
await ctx.Response.WriteAsync($"<h1>{claim.Type} : {claim.Value}</h1>");
}
});
app.Run();
}
}
// ...
// ...
+public static class BasicCookieAuthServiceExtensions
+{
+ public static void BasicCookieAuthSignin(this HttpContext ctx, string userinfo)
+ {
+ ctx.RequestServices.GetRequiredService<AuthService>().Signin(userinfo);
+ }
+
+ public static void BasicCookieAuthSignout(this HttpContext ctx)
+ {
+ ctx.RequestServices.GetRequiredService<AuthService>().Signout();
+ }
+}
// ...
// ...
现在就非常像样了,这基本就是框架中认证的实现思路,当然我们实现的这个玩意只是大的指导思想与框架的实现吻合,细节上差得还是很多。
上面的代码除了实现简陋,且并没有实现“校验用户提交的用户名+密码”功能外,其实还有一个问题,就是如果你在endpoint "/"
的实现体中打个断点的话,你会看到,ctx.User.Identity.IsAuthenticated
属性的值是false
我们上面介绍了有关ClaimsIdentity
的知识,但没有提到这个类下的这个叫IsAuthenticated
的属性,按常理来说,这个字段从英文单词的意思上来看,应当是在指代“当前这个用户的身份是否是认证得来的”,或者直接一点,用ctx.User.Identity.IsAuthenticated
来判断当前HTTP请求是否成功的通过了认证相关的middleware。
那么怎么样才能让这个字段为true
呢?如果你像我一样去翻官方文档,你会发现:
IsAuthenticated
是一个只读字段AuthenticationType
的属性是绑定的,基本等价于!string.IsNullOrEmpty(this.AuthenticationType)
表达式ClaimsIdentity
对象的时候,我们没有为这个属性赋值的机会,没有任何构造函数可以直接指定IsAuthenticated
属性的值如果你接着去翻文档去看AuthenticationType
字段是什么鬼,你会发现:
AuthenticationType
也是一个只读字段authenticationType
的参数,就是在为该属性赋值OK,来总结一下:
ClaimsIdentity
的时候,如果不提供authenticationType
参数,那么会导致构造出的对象的AuthenticationType
字段是空的,会接着导致IsAuthenticated
属性的值是false
现在问题来了:这个“authentication type”的概念,到底是什么东西?要说明白这个问题,还稍微有一点麻烦。先把这个问题埋在心里,接着继续往下看。
现在我们所写的代码,就基本是把框架内附带的Cookie认证相关的基础设施手撸了一遍,当然撸出来的是一个极简版的认证。
我们现在来回顾一下我们做了哪些事情:
把所有与认证相关的逻辑都封装在一个独立的类中,即是上面的AuthService
这个类使用IHttpContextAccessor
来访问HttpContext
对象
这个类使用IDataProtectionProvider
来加密纸条
这个类的功能分两类:
HttpContext.User
对象中我们把AuthService
中的登录与注销功能,封装在了BasicCookieAuthServiceExtensions
类的扩展方法中,这样可以使得我们的/login
与/logout
两个endpoint的逻辑更清晰
我们把AuthService
中的认证逻辑,封装在了BasicCookieAuthMiddleware
中写成了middleware,这样使得所有需要查看用户信息的endpoint不需要再关心认证逻辑
上面的代码除了简陋之外,还有什么缺陷吗?
目前能观察到的一个缺陷,就是:AuthService
应当分层,以支持进一步扩展。
这话是什么意思呢?是指我们目前,把所有的逻辑实现都直接写在了AuthService
中,而无论是BasicCookieAuthServiceExtensions
中的扩展方法,还是BasicCookieAuthMiddleware
,都直接的、明晃晃的依赖着这个实现类。
这太耦合了,我们应该把AuthService
抽象成:接口+实现两层。
AuthService
接口化我们应该定义一个叫IAuthService
的接口,里面大致有以下的方法
public interface IAuthService
{
// 从纸条中获取用户信息,并写入ctx.User中
// 应该被封装在middleware中
void Authenticate(HttpContext ctx);
// 使用户登入
// 对基于cookie机制的实现来说,就是把principal序列化后写入Http Response的头部字段set-cookie字段中
// 应该被封装在 this HttpContext ctx 的扩展方法中
void SignIn(HttpContext ctx, ClaimsPrincipal principal);
// 使用户注销
// 对基于cookie机制的实现来说,就是清空现有的cookie
// 应该被封闭在 this HttpContext ctx 的扩展方法中
void SignOut(HttpContext ctx);
}
而具体的实现逻辑,放在实现类CookieAuthService
中。这样的好处就是我们后续可以写很多不同的实现类,来支持各种各校的“纸条”的实现,不需要局限在cookie机制上。
并且即便是基于cookie机制,我们也可以使用不同的具体实现,使用不同的加密解密器等。
现在我们把示例程序拆分成多个文件结构,以便后续继续升级改造,目前将AuthService
接口化后的项目目录结构如下:
HelloSimpleAuthN
|
\--> HelloSimpleAuthN.csproj
\--> ISimpleAuthNService.cs
\--> Program.cs
\--> SimpleAuthNMiddleware.cs
\--> SimpleCookieAuthNService.cs
核心接口,对认证的抽象,ISimpleAuthNService.cs
的内容如下:
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System.Security.Claims;
namespace HelloSimpleAuthN;
public interface ISimpleAuthNService
{
void Authenticate(HttpContext ctx);
void SignIn(HttpContext ctx, ClaimsPrincipal principal);
void SignOut(HttpContext ctx);
}
public static class SimpleAuthNServiceExtensions
{
public static void SimpleAuthNSignIn(this HttpContext ctx, ClaimsPrincipal principal)
{
ctx.RequestServices.GetRequiredService<ISimpleAuthNService>().SignIn(ctx, principal);
}
public static void SimpleAuthNSignOut(this HttpContext ctx)
{
ctx.RequestServices.GetRequiredService<ISimpleAuthNService>().SignOut(ctx);
}
}
既然我们已经把“认证的功能”抽象成了接口,那么很自然的,我们就可以根据该接口,写出SimpleAuthNSignIn
和SimpleAuthNSignOut
两个扩展方法,在endpoint或middleware中可以直接通过ctx.SimpleAuthNSign[In|Out]
去方便的调用,实现登录与注销功能。endpiont和middleware的代码无需去关心登录与注销的底层实现是怎么回事。
更自然的,我们基于这个接口就可以直接写出负责读纸条的middleware,就是SimpleAuthNMiddleware.cs
,内容如下:
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using System.Threading.Tasks;
namespace HelloSimpleAuthN;
public class SimpleAuthNMiddleware : IMiddleware
{
private ISimpleAuthNService authService;
public SimpleAuthNMiddleware(ISimpleAuthNService authService)
{
this.authService = authService;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
this.authService.Authenticate(context);
await next.Invoke(context);
}
}
public static class SimpleAuthNMiddlewareExtensions
{
public static void UseSimpleAuthN(this IApplicationBuilder app)
{
app.UseMiddleware<SimpleAuthNMiddleware>();
}
}
这个middleware同样不关心认证的具体实现,只关心接口。同样只关心接口,不关心实现的还有我们的主逻辑代码Program.cs
,内容如下:
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
namespace HelloSimpleAuthN;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDataProtection();
builder.Services.AddScoped<ISimpleAuthNService, SimpleCookieAuthNService>();
builder.Services.AddScoped<SimpleAuthNMiddleware>();
var app = builder.Build();
app.UseSimpleAuthN();
app.MapGet("/login", async ctx =>
{
ClaimsPrincipal alice = new ClaimsPrincipal(new ClaimsIdentity(new List<Claim> {
new Claim("Name", "Alice"),
new Claim("Gender", "Female"),
new Claim("Age", "18"),
}));
ctx.SimpleAuthNSignIn(alice);
await ctx.Response.WriteAsync($"You've just logged in as Alice");
});
app.MapGet("/logout", async ctx =>
{
ctx.SimpleAuthNSignOut();
await ctx.Response.WriteAsync($"You've just logged out");
});
app.MapGet("/", async ctx =>
{
await ctx.Response.WriteAsync($"<h1>claims count == {ctx.User.Claims.Count()}</h1>");
foreach(var claim in ctx.User.Claims)
{
await ctx.Response.WriteAsync($"<h1>{claim.Type} : {claim.Value}</h1>");
}
});
app.Run();
}
}
在三个endpoint的执行代码中,没有任何人关心认证/登录/注销的具体实现是怎么回事,所有人都是在直接调用扩展方法,依赖接口。这极大的降低了业务逻辑程序员的心智负担。
整个Program.cs
中,唯一与认证/登录/注销的具体实现相关的代码,只有一行:就是builder.Services.AddScoped<ISimpleAuthNService, SimpleCookieAuthNService>();
这一行:毕竟其它地方再不在乎具体实现,那DI池子里也得有个对象真的在干活呀!干活的这个对象的类,即是SimpleAuthNMiddleware.cs
,内容如下:
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
namespace HelloSimpleAuthN;
public class SimpleCookieAuthNService : ISimpleAuthNService
{
private IDataProtectionProvider idp;
private IDataProtector DataProtector => this.idp.CreateProtector("dp for simple cookie authN");
private long CurrentTimestamp => new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds();
public long ExpireTimeInSeconds { get; set; }
public string CookieName { get; set; }
public SimpleCookieAuthNService(IDataProtectionProvider idp)
{
this.idp = idp;
this.ExpireTimeInSeconds = 10;
this.CookieName = "userinfo";
}
public void Authenticate(HttpContext ctx)
{
bool expired = true;
string? userInfoStr = null;
foreach (var cookieKvp in ctx.Request.Headers.Cookie)
{
try
{
string key = cookieKvp.Split("=")[0];
string value = cookieKvp.Split("=")[1];
if (key == this.CookieName)
{
userInfoStr = this.DataProtector.Unprotect(value);
}
if (key == $"{this.CookieName}_expire_time")
{
string expireTimeStr = this.DataProtector.Unprotect(value);
if (long.Parse(expireTimeStr) > this.CurrentTimestamp)
{
expired = false;
}
}
}
catch
{
userInfoStr = null;
break;
}
}
if (!expired && userInfoStr is not null)
{
IEnumerable<KeyValuePair<string, string>> userClaims = userInfoStr.Split(",").Select(kvpStr => new KeyValuePair<string, string>(kvpStr.Split(":")[0], kvpStr.Split(":")[1]));
IEnumerable<Claim> claims = userClaims.Select(kvp => new Claim(kvp.Key, kvp.Value));
ClaimsIdentity identity = new ClaimsIdentity(claims);
ClaimsPrincipal principal = new ClaimsPrincipal(identity);
ctx.User = principal;
}
}
public void SignIn(HttpContext ctx, ClaimsPrincipal principal)
{
string userInfoStr = string.Join(",", principal.Claims.Select(claim => $"{claim.Type}:{claim.Value}"));
ctx.Response.Headers.SetCookie = new Microsoft.Extensions.Primitives.StringValues(
new string[]
{
$"{this.CookieName}={this.DataProtector.Protect(userInfoStr)}",
$"{this.CookieName}_expire_time={this.DataProtector.Protect($"{this.CurrentTimestamp + this.ExpireTimeInSeconds}")}"
});
}
public void SignOut(HttpContext ctx)
{
ctx.Response.Headers.SetCookie = new Microsoft.Extensions.Primitives.StringValues(
new string[]
{
$"{this.CookieName}=",
$"{this.CookieName}_expire_time="
});
}
}
这个类中写着所有具体的认证/登录/注销的实际逻辑,与我们前几个版本的逻辑基本一致,程序运行起来后行为也没有差异,这一版我们只是将两个东西写成了公开属性,以供使用者自定义而已:
ExpireTimeInSeconds
,单位是秒,默认值和之前一样,是10秒CookieName
,默认值和之前一样,是"userinfo"
接口化之后一切都很美好
Program.cs
中的三个endpoint的执行逻辑,完全不需要去看认证的具体实现ISimpleAuthNService
接口就行了除了一个小瑕疵:程序中只能存在一个ISimpleAuthNService
的实例。换言之:在这种设计下,一个网站无法同时支持多种登录/注销/认证实现同时存在。比如你的网站无法既支持通过微信扫码登录,同时还支持用户名+密码登录。
在我们上面的例子中,我们假设这样一种场景,虽然目前我们只实现了一个SimpleCookieAuthNService
,但我们可以通过属性ExpireTimeInSeconds
和CookieName
的区别,来假设两种不同的登录/注销/认证流程。
这里的关键在于:我们不能用“类名”来映射“登录/注销/认证的具体实现”了。因为“登录/注销/认证的具体实现”对应的其实是对象,而不是类。
比如上面的例子中,如果我们将Program.cs
的代码改动如下,虽然前后使用的是同一个ISimpleAuthNService
的实现类,但是前后其实应用的是两套“登录/注销/认证的具体实现”。
// ...
builder.Services.AddDataProtection();
- builder.Services.AddScoped<ISimpleAuthNService, SimpleCookieAuthNService>();
+ builder.Services.AddScoped<ISimpleAuthNService>(sp =>
+ {
+ SimpleCookieAuthNService authService = new SimpleCookieAuthNService(sp.GetRequiredService<IDataProtectionProvider>());
+ authService.CookieName = "auth info issued by org a";
+ authService.ExpireTimeInSeconds = 100;
+ return authService;
+ });
builder.Services.AddScoped<SimpleAuthNMiddleware>();
// ...
它们或许在代码上是高度相似的,但这只是因为我们只将“过期时间+cookie名字”设置成了可调属性而已。如果我们把SimpleCookieAuthNService
写得更灵活一点,完全可以实现一种:虽然我和你使用的是同一个类,但实例化出来后的登录/注销/认证流程有很大差别,这种效果。
如果我们要支持多种“登录/注销/认证的具体实现”同时存在在程序中,我们就必须为每一种“登录/注销/认证的具体实现”起个名字,这个“名字”的概念,有两种叫法:
没错,这正是ClaimsIdentity.AuthenticationType
属性,正是我们上面悬而未解的一个问题。
再说回authentication scheme
这个概念:
具体什么意思,要看上下文环境。
我们再谈一谈,ClaimsIdentity.AuthenticatinoType
和IsAuthenticated
属性吧:
在构造ClaimsIdentity
的时候,如果传入了authenticationType
参数,则会导致构造出的对象,IsAuthenticated
的值为true
这说明了两件事
我们首先假设我们要支持的两套scheme,其具体实现的逻辑和SimpleCookieAuthNService
里写的基本一致,只不过
userinfo_issued_by_A
,过期时间100秒userinfo_issued_by_B
,过期时间30秒如果有多种scheme同时存在,那么这意味着对于“登录/注销/认证功能”的调用方而言,每次调用的时候都必须指定scheme了。
也就是说,Program.cs
中,在调用ctx.SimpleAuthNSignIn(..)
, ctx.SimpleAuthNSignOut()
的时候,需要额外指定一个scheme参数了。当然,在构造alice
的时候,也需要在构造ClaimsIdentity
的时候把这个scheme的值写进构造函数中。
然后,我们需要把ISimpleAuthNService <- SimpleCookieAuthNService
这个两层抽象,改造成三层,比如:
ISimpleAuthNService
的功能是向endpoint和middleware提供一个简单的入口,将“登录/注销/认证”的细节全部隐藏掉
SimpleAuthNService
的体内不再包含任何scheme的实现细节,而是以字典的方式存储着scheme名字 <-> scheme具体实现
的映射表
CookieAuthNHandler
体内包含着使用cookie实现登录/注销/认证的全部细节
而这一层也可以进一步接口化,分为IAuthNHandler
和CookieAuthNHandler
两层
由于支持了多种scheme,所以在认证逻辑中,具体实现应当是调用ctx.User.AddIdentity(xxx)
来向HttpContext中添加一个身份,而不是使用ctx.User = xxx
来赋值一个用户
最后,我们要改造我们的middleware,使它在认证过程中,把所有的scheme都过一遍。
改造完成后,项目结构如下所示:
HelloSimpleAuthN
|
\--> HelloSimpleAuthN.csproj
\--> CookieAuthNHandler.cs
\--> IAuthNHandler.cs
\--> ISimpleAuthNService.cs
\--> Program.cs
\--> SimpleAuthNMiddleware.cs
\--> SimpleAuthNService.cs
虽然有点乱啊,但咱仔细捋一下,就不乱了
ISimpleAuthNService
相较之前,没什么大的变动,就是支持了scheme
参数而已
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System.Security.Claims;
namespace HelloSimpleAuthN;
public interface ISimpleAuthNService
{
void Authenticate(HttpContext ctx, string scheme);
void SignIn(HttpContext ctx, string scheme, ClaimsPrincipal principal);
void SignOut(HttpContext ctx, string scheme);
}
public static class SimpleAuthNServiceExtensions
{
public static void SimpleAuthNSignIn(this HttpContext ctx, string scheme, ClaimsPrincipal principal)
{
ctx.RequestServices.GetRequiredService<ISimpleAuthNService>().SignIn(ctx, scheme, principal);
}
public static void SimpleAuthNSignOut(this HttpContext ctx, string scheme)
{
ctx.RequestServices.GetRequiredService<ISimpleAuthNService>().SignOut(ctx, scheme);
}
}
SimpleAuthNService
则是统领了所有的scheme,并把它们保存在一个Dictionary<string, IAuthNHandler>
中
using Microsoft.AspNetCore.Http;
using System.Collections.Generic;
using System.Security.Claims;
namespace HelloSimpleAuthN
{
public class SimpleAuthNService : ISimpleAuthNService
{
private Dictionary<string, IAuthNHandler> handlers;
public SimpleAuthNService()
{
this.handlers = new Dictionary<string, IAuthNHandler>();
}
public void AddScheme(string scheme, IAuthNHandler handler)
{
this.handlers.Add(scheme, handler);
}
public void Authenticate(HttpContext ctx, string scheme)
{
this.handlers[scheme].Authenticate(ctx);
}
public void SignIn(HttpContext ctx, string scheme, ClaimsPrincipal principal)
{
this.handlers[scheme].SignIn(ctx, principal);
}
public void SignOut(HttpContext ctx, string scheme)
{
this.handlers[scheme].SignOut(ctx);
}
}
}
每个Scheme中的逻辑被抽象成了IAuthNHandler
接口
using Microsoft.AspNetCore.Http;
using System.Security.Claims;
namespace HelloSimpleAuthN;
public interface IAuthNHandler
{
void SignIn(HttpContext ctx, ClaimsPrincipal principal);
void SignOut(HttpContext ctx);
void Authenticate(HttpContext ctx);
}
真正的逻辑实现被写在了CookieAuthNHandler
类中,但请注意我们之前说过,scheme对应的并不是handler类,而是handler实例
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
namespace HelloSimpleAuthN;
public class CookieAuthNHandler : IAuthNHandler
{
private string schemeName;
private IDataProtectionProvider idp;
private IDataProtector DataProtector => this.idp.CreateProtector("dp for simple cookie authN");
private long CurrentTimestamp => new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds();
public long ExpireTimeInSeconds { get; set; }
public string CookieName { get; set; }
public CookieAuthNHandler(string schemeName, IDataProtectionProvider idp, long expireTimeInSeconds, string cookieName)
{
this.schemeName = schemeName;
this.idp = idp;
this.ExpireTimeInSeconds = expireTimeInSeconds;
this.CookieName = cookieName;
}
public void Authenticate(HttpContext ctx)
{
bool expired = true;
string? userInfoStr = null;
foreach (var cookieKvp in ctx.Request.Headers.Cookie)
{
try
{
string key = cookieKvp.Split("=")[0];
string value = cookieKvp.Split("=")[1];
if (key == this.CookieName)
{
userInfoStr = this.DataProtector.Unprotect(value);
}
if (key == $"{this.CookieName}_expire_time")
{
string expireTimeStr = this.DataProtector.Unprotect(value);
if (long.Parse(expireTimeStr) > this.CurrentTimestamp)
{
expired = false;
}
}
}
catch
{
userInfoStr = null;
break;
}
}
if (!expired && userInfoStr is not null)
{
IEnumerable<KeyValuePair<string, string>> userClaims = userInfoStr.Split(",").Select(kvpStr => new KeyValuePair<string, string>(kvpStr.Split(":")[0], kvpStr.Split(":")[1]));
IEnumerable<Claim> claims = userClaims.Select(kvp => new Claim(kvp.Key, kvp.Value));
ClaimsIdentity identity = new ClaimsIdentity(claims, this.schemeName);
ctx.User.AddIdentity(identity);
}
}
public void SignIn(HttpContext ctx, ClaimsPrincipal principal)
{
string userInfoStr = string.Join(",", principal.Claims.Select(claim => $"{claim.Type}:{claim.Value}"));
ctx.Response.Headers.SetCookie = new Microsoft.Extensions.Primitives.StringValues(
new string[]
{
$"{this.CookieName}={this.DataProtector.Protect(userInfoStr)}",
$"{this.CookieName}_expire_time={this.DataProtector.Protect($"{this.CurrentTimestamp + this.ExpireTimeInSeconds}")}"
});
}
public void SignOut(HttpContext ctx)
{
ctx.Response.Headers.SetCookie = new Microsoft.Extensions.Primitives.StringValues(
new string[]
{
$"{this.CookieName}=",
$"{this.CookieName}_expire_time="
});
}
}
这个middleware相较于之前的改动,是要把所有scheme都轮一遍,把所有能解析的纸条都解析出来
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace HelloSimpleAuthN;
public class SimpleAuthNMiddleware : IMiddleware
{
private IList<string> authNSchemes;
private ISimpleAuthNService authService;
public SimpleAuthNMiddleware(ISimpleAuthNService authService, IList<string> authNSchemes)
{
this.authService = authService;
this.authNSchemes = authNSchemes;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
foreach(string scheme in this.authNSchemes)
{
this.authService.Authenticate(context, scheme);
}
await next.Invoke(context);
}
}
public static class SimpleAuthNMiddlewareExtensions
{
public static void UseSimpleAuthN(this IApplicationBuilder app)
{
app.UseMiddleware<SimpleAuthNMiddleware>();
}
}
Program.cs
的改动较大,代码如下:
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
namespace HelloSimpleAuthN;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDataProtection();
builder.Services.AddScoped<ISimpleAuthNService>(sp =>
{
SimpleAuthNService auth = new SimpleAuthNService();
auth.AddScheme("scheme_A", new CookieAuthNHandler("scheme_A", sp.GetDataProtectionProvider(), 100, "userinfo_issued_by_A"));
auth.AddScheme("scheme_B", new CookieAuthNHandler("scheme_A", sp.GetDataProtectionProvider(), 30, "userinfo_issued_by_B"));
return auth;
});
builder.Services.AddScoped<SimpleAuthNMiddleware>(sp =>
{
return new SimpleAuthNMiddleware(sp.GetRequiredService<ISimpleAuthNService>(), new string[] { "scheme_A", "scheme_B" });
});
var app = builder.Build();
app.UseSimpleAuthN();
app.MapGet("/loginA", async ctx =>
{
ClaimsPrincipal alice = new ClaimsPrincipal(new ClaimsIdentity(new List<Claim> {
new Claim("Name", "Alice"),
new Claim("Gender", "Female"),
new Claim("Age", "18"),
}));
ctx.SimpleAuthNSignIn("scheme_A", alice);
await ctx.Response.WriteAsync($"You've just logged in as Alice");
});
app.MapGet("/loginB", async ctx =>
{
ClaimsPrincipal alice = new ClaimsPrincipal(new ClaimsIdentity(new List<Claim> {
new Claim("Name", "Bob"),
new Claim("Gender", "Mmale"),
new Claim("Age", "28"),
}));
ctx.SimpleAuthNSignIn("scheme_B", alice);
await ctx.Response.WriteAsync($"You've just logged in as Bob");
});
app.MapGet("/logoutA", async ctx =>
{
ctx.SimpleAuthNSignOut("scheme_A");
await ctx.Response.WriteAsync($"You've just logged out Alice");
});
app.MapGet("/logoutB", async ctx =>
{
ctx.SimpleAuthNSignOut("scheme_B");
await ctx.Response.WriteAsync($"You've just logged out Bob");
});
app.MapGet("/", async ctx =>
{
await ctx.Response.WriteAsync($"<h1>Identities count == {ctx.User.Identities.Count()}</h1>");
int count = 0;
foreach(var identity in ctx.User.Identities)
{
await ctx.Response.WriteAsync($"<h2>Identities[{count}].IsAuthenticated = {identity.IsAuthenticated}</h2>");
foreach(var claim in identity.Claims)
{
await ctx.Response.WriteAsync($"<h3>{claim.Type} : {claim.Value}</h3>");
}
++count;
}
});
app.Run();
}
}
运行效果如下:
现在我们用非常简陋的代码实现了对多scheme的支持,从4.1章节到4.8章节,我们一步步的用迭代简陋代码的过程为大家介绍了很多概念,既说明了Authentication的本质,也大致告诉你,如果要手撸Authentication相关代码的话,大致要做哪些事情。
接下来我不会再对这个示例代码进行下一步迭代了,因为我们要了解的一些概念、思路已经了解的差不多了,是时候转头去看看框架为我们提供的Authentication基础设施了,但在那之前,请牢记以下重点
用比喻的方式来说,这两个动作分别是“解析纸条”与“操纵纸条”
Claim/Identity/Principal
是什么,也要认识到认证是“身份”层面的概念Authentication是一个身份上的概念,所以ClaimsIdentity
的构造函数重载会有authenticationType
参数,会有IsAuthenticated
属性。
网站应用的登录“用户”,永远只是“一个用户”。网站可以支持“拥有多个身份的一个用户”登录,但从逻辑和设计上就不支持“多个用户挤在一个session中登录网站”
认证的具体实现一定是写在某个类中的,我们一般把这种类叫AuthenticationHandler
类。
但scheme指的是:“Handler类的实例” + 一个名字。就好比上面我们的示例代码:scheme_A
和scheme_B
完全共享代码,但它们是两个完全不同的,可以独立存在的两个scheme。
现在,让我们把示例代码扔掉,转头去看,同样的程序运行效果,如果我们使用asp .net core框架的基础设施,写出来长什么样子
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
namespace HelloSimpleAuthN;
public class AuthenticateAllSchemesMiddleware : IMiddleware
{
private IAuthenticationService authService;
private IAuthenticationSchemeProvider authSchemeProvider;
public AuthenticateAllSchemesMiddleware(
IAuthenticationService authService,
IAuthenticationSchemeProvider authSchemeProvider)
{
this.authService = authService;
this.authSchemeProvider = authSchemeProvider;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
foreach(AuthenticationScheme scheme in await this.authSchemeProvider.GetAllSchemesAsync())
{
AuthenticateResult authRes = await this.authService.AuthenticateAsync(context, scheme.Name);
if(authRes.Succeeded)
{
ClaimsIdentity authenticatedIdentity = (ClaimsIdentity)authRes.Principal.Identity;
context.User.AddIdentity(authenticatedIdentity);
}
}
await next.Invoke(context);
}
}
public class Program
{
public static readonly string SchemeA = "scheme_A";
public static readonly string SchemeB = "scheme_B";
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication()
.AddCookie(SchemeA)
.AddCookie(SchemeB);
builder.Services.AddScoped<AuthenticateAllSchemesMiddleware>();
var app = builder.Build();
app.UseMiddleware<AuthenticateAllSchemesMiddleware>();
app.MapGet("/loginA", async ctx =>
{
List<Claim> claims = new List<Claim>
{
new Claim("Name", "Alice"),
new Claim("Gender", "Female"),
new Claim("Age", "18")
};
ClaimsIdentity identity = new ClaimsIdentity(claims, SchemeA);
ClaimsPrincipal principal = new ClaimsPrincipal(identity);
await ctx.SignInAsync(SchemeA, principal);
await ctx.Response.WriteAsync($"You've just logged in as Alice");
});
app.MapGet("/loginB", async ctx =>
{
List<Claim> claims = new List<Claim>
{
new Claim("Name", "Bob"),
new Claim("Gender", "Male"),
new Claim("Age", "28")
};
ClaimsIdentity identity = new ClaimsIdentity(claims, SchemeB);
ClaimsPrincipal principal = new ClaimsPrincipal(identity);
await ctx.SignInAsync(SchemeB, principal);
await ctx.Response.WriteAsync($"You've just logged in as Bob");
});
app.MapGet("/logoutA", async ctx =>
{
await ctx.SignOutAsync(SchemeA);
await ctx.Response.WriteAsync($"You've just logged out Alice");
});
app.MapGet("/logoutB", async ctx =>
{
await ctx.SignOutAsync(SchemeB);
await ctx.Response.WriteAsync($"You've just logged out Bob");
});
app.MapGet("/", async ctx =>
{
await ctx.Response.WriteAsync($"<h1>Identities count == {ctx.User.Identities.Count()}</h1>");
int count = 0;
foreach(var identity in ctx.User.Identities)
{
await ctx.Response.WriteAsync($"<h2>Identities[{count}].IsAuthenticated = {identity.IsAuthenticated}</h2>");
foreach(var claim in identity.Claims)
{
await ctx.Response.WriteAsync($"<h3>{claim.Type} : {claim.Value}</h3>");
}
++count;
}
});
app.Run();
}
}
接下来我们以对比的方式,来看框架的实现,和我们自己那个简陋的例子之间,有什么异同
我们那个简陋的示例中,调用的ctx.SimpleAuthNSignIn
和ctx.SimpleAuthNSignOut
是我们自己写的扩展方法,源代码如下:
public static class SimpleAuthNServiceExtensions
{
public static void SimpleAuthNSignIn(this HttpContext ctx, string scheme, ClaimsPrincipal principal)
{
ctx.RequestServices.GetRequiredService<ISimpleAuthNService>().SignIn(ctx, scheme, principal);
}
public static void SimpleAuthNSignOut(this HttpContext ctx, string scheme)
{
ctx.RequestServices.GetRequiredService<ISimpleAuthNService>().SignOut(ctx, scheme);
}
}
现在的这份代码中,调用的是框架为我们提供的扩展方法,我们以SignInAsync
为例,它有四个重载,框架源代码如下:
namespace Microsoft.AspNetCore.Authentication;
public static class AuthenticationHttpContextExtensions
{
// ...
public static Task SignInAsync(this HttpContext context, string? scheme, ClaimsPrincipal principal) =>
context.SignInAsync(scheme, principal, properties: null);
public static Task SignInAsync(this HttpContext context, ClaimsPrincipal principal) =>
context.SignInAsync(scheme: null, principal: principal, properties: null);
public static Task SignInAsync(this HttpContext context, ClaimsPrincipal principal, AuthenticationProperties? properties) =>
context.SignInAsync(scheme: null, principal: principal, properties: properties);
public static Task SignInAsync(this HttpContext context, string? scheme, ClaimsPrincipal principal, AuthenticationProperties? properties) =>
GetAuthenticationService(context).SignInAsync(context, scheme, principal, properties);
private static IAuthenticationService GetAuthenticationService(HttpContext context) =>
context.RequestServices.GetService<IAuthenticationService>() ??
throw new InvalidOperationException(Resources.FormatException_UnableToFindServices(
nameof(IAuthenticationService),
nameof(IServiceCollection),
"AddAuthentication"));
// ...
}
思路一致。
而如果你再打开框架中IAuthenticationService
接口的定义,你会发现,这玩意其实和我们自己写的ISimpleAuthNService
差不多:
namespace Microsoft.AspNetCore.Authentication
{
public interface IAuthenticationService
{
Task<AuthenticateResult> AuthenticateAsync(HttpContext context, string? scheme);
Task ChallengeAsync(HttpContext context, string? scheme, AuthenticationProperties? properties);
Task ForbidAsync(HttpContext context, string? scheme, AuthenticationProperties? properties);
Task SignInAsync(HttpContext context, string? scheme, ClaimsPrincipal principal, AuthenticationProperties? properties);
Task SignOutAsync(HttpContext context, string? scheme, AuthenticationProperties? properties);
}
}
而翻开IAuthenticationService
的框架默认实现,即AuthenticationService
来看的话,你会发现,虽然框架的AuthenticationService
的实现更复杂了,但实现思路和我们的SimpleAuthNService
也是差不多的,我们以AuthenticationService.SignInAsync
方法的实现为例,以下是框架源代码:
// ...
public virtual async Task SignInAsync(HttpContext context, string? scheme, ClaimsPrincipal principal, AuthenticationProperties? properties)
{
if (principal == null)
{
throw new ArgumentNullException(nameof(principal));
}
if (Options.RequireAuthenticatedSignIn)
{
if (principal.Identity == null)
{
throw new InvalidOperationException("SignInAsync when principal.Identity == null is not allowed when AuthenticationOptions.RequireAuthenticatedSignIn is true.");
}
if (!principal.Identity.IsAuthenticated)
{
throw new InvalidOperationException("SignInAsync when principal.Identity.IsAuthenticated is false is not allowed when AuthenticationOptions.RequireAuthenticatedSignIn is true.");
}
}
if (scheme == null)
{
var defaultScheme = await Schemes.GetDefaultSignInSchemeAsync();
scheme = defaultScheme?.Name;
if (scheme == null)
{
throw new InvalidOperationException($"No authenticationScheme was specified, and there was no DefaultSignInScheme found. The default schemes can be set using either AddAuthentication(string defaultScheme) or AddAuthentication(Action<AuthenticationOptions> configureOptions).");
}
}
var handler = await Handlers.GetHandlerAsync(context, scheme);
if (handler == null)
{
throw await CreateMissingSignInHandlerException(scheme);
}
var signInHandler = handler as IAuthenticationSignInHandler;
if (signInHandler == null)
{
throw await CreateMismatchedSignInHandlerException(scheme, handler);
}
await signInHandler.SignInAsync(principal, properties);
}
// ...
而我们自己写的SimpleAuthNService.SignIn
方法的实现如下:
// ...
public void SignIn(HttpContext ctx, string scheme, ClaimsPrincipal principal)
{
this.handlers[scheme].SignIn(ctx, principal);
}
// ...
虽然这两坨代码在数量上差别巨大,但实际逻辑都可以用以下伪代码描述:
public void Signin(ctx, scheme, principal)
{
var handler = 寻找到对应的handler实例(scheme);
handler.Signin(principal);
}
再往里追究hanlder的话,就会发现框架的实现和我们开始出现了分歧:我们是简单的使用一个Dictionary
就存储了所有的scheme和对应的handler。
但框架这里写得复杂得多,框架为了记录、追踪、索引所有的scheme,分别向DI池中注册了两个对象:
IAuthenticationHandlerProvider
的AuthenticationHandlerProvider
IAuthenticationSchemeProvider
的AuthenticationSchemeProvider
其中..SchemeProvider
中记录的是“scheme名字”和“Handler类型”之间的映射关系。不过我们之前也强调过很多次了,scheme映射的其实并不是handler类型,而是handler实例。
要获得真正的scheme实例,就需要借助...HandlerProvider
来实现,框架源代码如下:
public class AuthenticationHandlerProvider : IAuthenticationHandlerProvider
{
public AuthenticationHandlerProvider(IAuthenticationSchemeProvider schemes)
{
Schemes = schemes;
}
public IAuthenticationSchemeProvider Schemes { get; }
private readonly Dictionary<string, IAuthenticationHandler> _handlerMap = new Dictionary<string, IAuthenticationHandler>(StringComparer.Ordinal);
public async Task<IAuthenticationHandler?> GetHandlerAsync(HttpContext context, string authenticationScheme)
{
if (_handlerMap.TryGetValue(authenticationScheme, out var value))
{
return value;
}
var scheme = await Schemes.GetSchemeAsync(authenticationScheme);
if (scheme == null)
{
return null;
}
var handler = (context.RequestServices.GetService(scheme.HandlerType) ??
ActivatorUtilities.CreateInstance(context.RequestServices, scheme.HandlerType))
as IAuthenticationHandler;
if (handler != null)
{
await handler.InitializeAsync(scheme, context);
_handlerMap[authenticationScheme] = handler;
}
return handler;
}
}
在GetHandlerAsync
方法中,在通过...SchemeProvider
拿到一个scheme的类型信息后,会对其进行实例化。这样看来,框架中的认证体系其实有五层:
IAuthenticationService
,负责向程序员提供一个一键式的认证接口
扩展方法ctx.SignInAsync(...)
就实现在这个级别
AuthenticationService
:负责统领所有的scheme
它是通过IAuthenticationSchemeProvider
和IAuthenticationHandlerProvider
来访问到所有scheme的
它对SignInAsync
等方法的实现,可以总结为两步:
SignInAsync
方法IAuthenticationHandlerProvider
和它的默认实现AuthenticationHandlerProvider
:将scheme名字与handler实例映射起来
这个接口只有一个方法:GetHandlerAsync
:你给我名字,我给你handler
在实现中,...HandlerProvider
其实并不直接持有Handler对象,而是通过IAuthenticationSchemeProvider
持有其类型信息
当用户调用GetHandlerAsync
时,再按需创建并初始化Handler对象
IAuthenticationSchemeProvider
和它的默认实现AuthenticationSchemeProvider
:将scheme名字和hanlder类型映射起来
将scheme集合拆分成...HandlerProvider
和...SchemeProvider
在我看来,多少有点过度设计的味道
用来描述handler类的一系列接口与抽象类:我们下面单开一小节介绍它们
首先是三个最基础的接口
IAuthenticationHandler
: 有 初始化(InitializeAsync
),认证(AuthenticateAsync
),质疑(ChallengeAsync
),禁止(ForbidAsync
)四个基本方法IAuthenticationSignOutHandler
: 在上个接口的基础上,添加了注销(SignOutAsync
)IAuthenticationSignInHandler
: 在上个接口的基础上,添加了登录(SignInAsync
)然后是框架打样实现的三个抽象类:
AuthentiationHandler<TOptions>
SignOutAuthenticationHandler<TOptions>
SignInAuthenticationHandler<TOptions>
如果你去翻框架源代码的话,你会发现接口的定义非常简洁,框架打样实现的三个抽象类里,AuthenticationHandler<T>
作为最顶层的抽象类,框架已经在这个抽象类内部为你写了不少默认实现,定义了不少用得上的字段和属性。还在接口定义的语义之外,很明显的在支持着“事件回调”和“认证转发”等骚操作骚特性。
而认证最为基本的五个方法:登录、注销、质疑、禁止和认证中。有这么几个特点:
虽然在接口中,这五个方法的名字叫xxxAsync
,其中xxx
是动词SignIn/SignOut/Challenge/Forbidden/Authenticate
。但在抽象类中,这五个方法都有了基本实现
除非你有非常强的理由,否则不要动这个默认实现
抽象类对接口的默认实现,调用了HandlexxxAsync
方法,其中xxx
依然是动词
这个HandlexxxAsync
是抽象类中定义的虚方法,这个是没有实现的纯虚方法,是留给程序员自己填充的
也就是说,如果我们要实现一个自定义的handler,丛一个特殊的HTTP头部字段中去以自定义的方式解析纸条,那么我们就应当创建一个类,让它继承SignInAuthenticationHandler<TOptions>
类,然后以自定义的方式去实现五个HandlexxxAsync
方法
而不是去覆写接口里的xxxAsync
方法
我们再来说这五个动词的语义:
这两个语义都比较好理解,并且框架把他们写在认证相关的基础设施类中也比较好理解。
质疑,Challenge:请你重新去拿个纸条
Challenge一般的处理结果是给浏览器发送一个401回应或者302回应。
401回应在HTTP协议里的语义是:认证没有成功,服务端没有认出请求者的身份,所以不能返回内容。
在WebAPI项目中,对于纸条缺失、解析失败等情形,应当返回401给客户端
302回应在HTTP协议层面就是个重定向
在服务端渲染网站项目中,对于纸条缺失、解析失败等情形,可以给客户端返回个302,把客户端浏览器引导至登录页面
禁止,Forbidden:我看过你的纸条了,你没有权限查看这个内容
Forbidden其实是一个Authorization领域的概念,即认证通过了,但用户没有权限访问这个内容。
虽然这是一个AuthZ领域的概念,但在AuthN这一层的middleware中,我们会对这个请求进行处理:
在web api 项目中,我们一般给客户端扔个403通知它一下
在服务端渲染网站项目中,我们会给客户端一个302重定向,把它引导到一个提示页面,告诉它:“你没权限”
这里之所以要介绍一下这几个动词,和这几个Handler接口和抽象类,是为了给你一个印象,让你知道,如果你自己要实现一个自定义的Handler的时候,大致要做什么,要研究哪个方面、方向的源代码。
OK,不要在这里钻太多的牛角尖,理解到这里就可以了。
具体登录、注销、认证逻辑的实现,我们之前那个简陋的例子,是自己手动实现的,写在CookieAuthNHandler.cs
中。
而框架中有关基于cookie机制的登录、注销、认证逻辑的实现,写在CookieAuthenticationHandler
类中,虽然框架的实现丰富了很多细节,但实现思路基本是一致的:
HandleSignInAsync
和HandleSignOutAsync
都是在通过Http控制浏览器的cookieHandleAuthenticateAsync
是从cookie中解析身份信息不同的是,在CookieAuthNHandler.cs
中,我们解析完身份信息后,是就地创建了一个ClaimsIdentity
实例,然后将其添加到ctx.User.AddIdentity(...)
方法里
而在CookieAuthenticationHandler
中,HandleAuthenticateAsync
把解析后的身份信息包装成了ClaimsPrincipal
,并进一步包装成了AuthenticationResult
。而具体解析出的这些结果和上下文应该怎么去使用,Handler其实是不关心的,相关的逻辑应当写在Middleware中
在我们实现的简陋版本示例中,我们向DI其实只注册了两个对象,代码长下面这样:
// ...
builder.Services.AddDataProtection();
builder.Services.AddScoped<ISimpleAuthNService>(sp =>
{
SimpleAuthNService auth = new SimpleAuthNService();
auth.AddScheme("scheme_A", new CookieAuthNHandler("scheme_A", sp.GetDataProtectionProvider(), 100, "userinfo_issued_by_A"));
auth.AddScheme("scheme_B", new CookieAuthNHandler("scheme_A", sp.GetDataProtectionProvider(), 30, "userinfo_issued_by_B"));
return auth;
});
builder.Services.AddScoped<SimpleAuthNMiddleware>(sp =>
{
return new SimpleAuthNMiddleware(sp.GetRequiredService<ISimpleAuthNService>(), new string[] { "scheme_A", "scheme_B" });
});
// ...
虽然代码上只注册了两个对象,但实际上我们干了三件事:
ISimpleAuthNService
和SimpleAuthNService
CookieAuthNHandler
实例我们先抛开middleware不管,这里最核心的概念,是要理解到:认证过程大的分层有两层,一层管抽象,一层管实现。
在asp .net core框架中,这个分层的思路也是成立的,比如在新版的示例代码中,有关DI的部分如下:
// ...
builder.Services.AddAuthentication()
.AddCookie(SchemeA)
.AddCookie(SchemeB);
builder.Services.AddScoped<AuthenticateAllSchemesMiddleware>();
// ...
抛开middleware对象的注册不谈,其实框架中的写法也是分了两层:
builder.Services.AddAuthentication
其实就是在向DI层注册抽象层对象,如果你翻开它的源代码去看:
// ...
services.AddAuthenticationCore();
services.AddDataProtection();
services.AddWebEncoders();
services.TryAddSingleton<ISystemClock, SystemClock>();
// ...
这个扩展方法里包了四个调用,后三个比较好理解,从名字上来看,大致就是向DI里添加一些辅助工具,包括我们之前提到的DataProtection相关工具。核心调用是第一个,services.AddAuthenticationCore();
,它的实现是:
services.TryAddScoped<IAuthenticationService, AuthenticationService>();
services.TryAddSingleton<IClaimsTransformation, NoopClaimsTransformation>();
services.TryAddScoped<IAuthenticationHandlerProvider, AuthenticationHandlerProvider>();
services.TryAddSingleton<IAuthenticationSchemeProvider, AuthenticationSchemeProvider>();
向DI池中添加了AuthenticationService
,AuthenticationHandlerProvider
和AuthenticationSchemeProvider
,唯独没有添加具体的Handler,即scheme是什么。
在AddAuthentication
的返回值上链式调用AddCookie
,其实是在向xxxHandlerProvider
和xxxSchemeProvider
中添加具体实现。AddCookie
的源代码如下:
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);
}
也算是非常直观了
另外还有件事需要注意到:框架抽象层是定义在以下三个包中的:
Microsoft.AspNetCore.Authentication
Microsoft.AspNetCore.Authentication.Core
Microsoft.AspNetCore.Authentication.Abstractions
而基于cookie的具体实现是定义在Microsoft.AspNetCore.Authentication.Cookies
中的。
除了用cookie实现纸条功能外,常用的还有使用JWT Token的实现,这种实现就在包Microsoft.AspNetCore.Authentication.JwtBearer
中。
两版例子唯一的共同点是:两个版本都自己实现了用来认证的middleware。
那么是框架没有提供认证的middleware吗?并不是,框架已经写好了一个叫AuthenticationMiddleware
的类(没有实现IMiddleware
接口的写法),并且在代码中直接如下调用,就可以使用这个middleware:
// ...
var app = builder.Build();
app.UseAuthentication();
// ...
UseAuthentication()
扩展方法内部调用的就是app.UseMiddleware<AuthenticationMiddleware>()
。
那为什么我们不用它呢?
你翻开AuthenticationMiddleware
的源代码去看一眼就明白了:
public class AuthenticationMiddleware
{
// ...
public async Task Invoke(HttpContext context)
{
context.Features.Set<IAuthenticationFeature>(new AuthenticationFeature
{
OriginalPath = context.Request.Path,
OriginalPathBase = context.Request.PathBase
});
// Give any IAuthenticationRequestHandler schemes a chance to handle the request
var handlers = context.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>();
foreach (var scheme in await Schemes.GetRequestHandlerSchemesAsync())
{
var handler = await handlers.GetHandlerAsync(context, scheme.Name) as IAuthenticationRequestHandler;
if (handler != null && await handler.HandleRequestAsync())
{
return;
}
}
var defaultAuthenticate = await Schemes.GetDefaultAuthenticateSchemeAsync();
if (defaultAuthenticate != null)
{
var result = await context.AuthenticateAsync(defaultAuthenticate.Name);
if (result?.Principal != null)
{
context.User = result.Principal;
}
if (result?.Succeeded ?? false)
{
var authFeatures = new AuthenticationFeatures(result);
context.Features.Set<IHttpAuthenticationFeature>(authFeatures);
context.Features.Set<IAuthenticateResultFeature>(authFeatures);
}
}
await _next(context);
}
// ...
}
框架默认提供的这个middleware有两个特点:
ClaimsPrincipal
直接赋值给ctx.User
,而不像我们上面所有的例子那样:只把认证出来的身份通过ctx.User.AddIdentity
添加到ctx.User
体内现在的问题是,如果我们的应用中存在多个scheme的话,我如何指定一个scheme,为"default scheme"呢?
答案很简单:在向DI注册抽象层的过程中,把"default scheme"的名字送给AddAuthentication()
作为参数即可,如下:
builder.Service.AddAuthentication(SchemeA)
.AddCookie(SchemeA)
.AddCookie(SchemeB);
如此操作后,就可以使用框架自带的AuthenticationMiddleware
了。
比如,我们如下改动我们的代码:
// ...
-public class AuthenticateAllSchemesMiddleware : IMiddleware
-{
- private IAuthenticationService authService;
- private IAuthenticationSchemeProvider authSchemeProvider;
-
- public AuthenticateAllSchemesMiddleware(
- IAuthenticationService authService,
- IAuthenticationSchemeProvider authSchemeProvider)
- {
- this.authService = authService;
- this.authSchemeProvider = authSchemeProvider;
- }
-
- public async Task InvokeAsync(HttpContext context, RequestDelegate next)
- {
- foreach(AuthenticationScheme scheme in await this.authSchemeProvider.GetAllSchemesAsync())
- {
- AuthenticateResult authRes = await this.authService.AuthenticateAsync(context, scheme.Name);
- if(authRes.Succeeded)
- {
- ClaimsIdentity authenticatedIdentity = (ClaimsIdentity)authRes.Principal.Identity;
- context.User.AddIdentity(authenticatedIdentity);
- }
- }
- await next.Invoke(context);
- }
-}
-
// ...
public class Program
{
// ...
public static void Main(string[] args)
{
// ...
- builder.Services.AddAuthentication()
+ builder.Services.AddAuthentication(SchemeA)
.AddCookie(SchemeA)
.AddCookie(SchemeB);
- builder.Services.AddScoped<AuthenticateAllSchemesMiddleware>();
var app = builder.Build();
- app.UseMiddleware<AuthenticateAllSchemesMiddleware>();
+ app.UseAuthentication();
// ...
}
}
那么运行效果会有以下改动:
SchemeA
对请求进行认证,永远不会以SchemeB
对请求进行认证SchemeA
认证成功后,ctx.User
只会有一个身份,而不像之前那样,有两个身份这篇文章中,我们通过循序渐进的示例的方式,向大家展示了asp .net core框架中认证领域的相关概念及设计思路。虽然示例过多导致有点拖沓,但循序渐进的示例能帮助你更好的理解相关概念与设计思路,我强烈建议你手动实现所有的示例以加深记忆。
如果你能回答出以下几个问题,那么就基本证明你已经掌握了这篇文章的知识: