如果大伙伴们以前写过 ASP 或 PHP 之类的,相信各位对基于 Session 的身份验证很熟悉(其实在浏览器端是结合 Cookie 来处理的)。这种验证方式是比较早期的,操作起来也不复杂。
a、用户打开(或自动跳转到)登入页,输入你的大名和密码,登录。
b、提交到服务器,比较一下用户名和密码是否正确。
c、若验证成功,往 Session 里写入一个标识。实际上往Session里面写啥都行,能作为用户登录标识就行。毕竟嘛,对于每个连接来说,Session是唯一的,所以,在页面“头部”验证时,许多时候压根不用关心Session里存了啥,只要有登录标识就OK。
当然,你会说,我 K,ao,这样验证是不是问题多多?确实,跨域验证就出问题,而且单点登录也不好控制。所以现在才会衍生出许多验证方式。甚至弄得很复杂,于是咱们就知道只要涉及到验证和授权的内容就看得人头晕。很真实,是TM挺复杂的。
不过,你同时也会发现,现在很多 Web 应用还是会使用 Session 来验证的。为啥呢?因为我的项目很小,小到可能就只有五六个人登录,我用得着搞那么复杂吗?
老周不才,没做过什么大项目,小项目倒是坑了不少人。用小项目来忽悠客户一向是老周的核心竞争力,一直被模仿却从未被超越过。你不妨想想,你开了个小店,平时只卖几张不知道正不正版的有颜色的DVD,店里的员工可能就几个,做个管理系统就那么几个操作员。你说这身份验证你会选那些复杂到跳楼的方案吗。
------------------------------- 银河分界线 ------------------------------------
以前,我们在ASP中使用 Session 还是很简单的。ASP 文件中有一种类似C头文件的东西(inc文件),可以在其他ASP文件中包含。那么,这个 inc 文件里写几行代码——检查一下 Session 里是否包含登录标识。若没有,跳转到登录页。然后,需要作验证的页面就 include 这个 inc 文件。这样就以很简单但很混乱的方式实现了验证功能。
在 ASP.NET Core 里其实你也可以这样用,在服务容器中启用 Session 功能,然后写个中间件,插入到 HTTP 管道的头部,检查 Session 中的登录标识,如果没有那就 Redirect 到登录 URL。
这样做确实可行的,但又出新问题了——所有进来的请求都会进行验证了,这会导致客户端访问啥都要验证了。当然,你会想到,Map When 就行了呗,让中间件有了条件限制。
------------------------------ M77星云分界线 ----------------------------------
以上做法并不符合 ASP.NET Core 设计模型。ASP.NET Core 中为验证和授权提供了独立的功能实现的。好了,前文扯了几吨的废话,正片现在开始。
验证与授权是两个不同的过程,但它们又经常一起使用。所以很多大伙伴经常分不清,关键是这两货的单词也长得很像,不信你看:
1、验证——authentication
2、授权——authorization
怎么样?像吧,也不知道那些洋鬼子们怎么想的,把它俩弄得那么像。
老周试着用一个故事来区别这两个过程——假如你去你朋友家里玩。首先,你朋友家里得有人,而且你按门铃后他会开门让你进去(验证);之后,你进去了,但是朋友家里有很多个房间,一般大客厅你肯定可以站在那里的,但是,朋友的卧室就不见得会允许你进去(授权),除非你们特别熟。
验证是你能不能进别人家的门,授权是进了门后你被允许做什么。
------------------------- 小龙虾星人分界线 ------------------------
下面分别说说这两个过程的一些要素。
A、验证
现在的网站咱们都知道,身份验证方式很多。你可以用户名/密码登录,你可以用QQ、微博、微信等帐号登录,你可以用短信验证码登录。像QQ、微信这些是第三方授权的,为了省去每去访问都要授权的麻烦,提供验证的服务器会发给你一个 Token,下次访问你用这个 Token 就行了。当然,这个 Token 也是有时间限制的,过期了就不能用。
这种方法不会暴露用户信息,但也不是真的很安全的,别人可以不知道你是谁,他只要盗走你的 Token 也能用来登录。好比一些平台会开放给开发者 API,比如微博开放平台,会分配给你一个 App Key 和一个密钥,然后你调用 API 时要传递这些东西。如果我知道你的 App Key 和密钥,那我照样可以以你的身份去调用 API。
正因为验证的方式那么多,所以,应用程序必须要有个东东来标识它们,这就跟我们在学校有学号一样道理。于是就出了个名词叫 Authentication Scheme。验证架构,但翻译为验证方案更好听。说白了,就是你给你这种验证方式取个名字罢了。比如,邮件验证码登录的叫“Email-Auth”。像咱们常听说的什么 OAuth 2.0,也是一种验证方案。
光有了验证方案名称可不行,你得让程序知道咋去验证,这就需要为每个方案配套一个 Handler 了,这个 Handler 是一个类,但它要求你实现 IAuthenticationHandler 接口。这样便有了统一的调用标准,当你选择某方案完成验证时,就会调用与这个方案对应的 Handler 来处理。例如:
方案 | Handler | 说明 |
Email-Auth | EmailAuthenHandler | 邮件验证 |
Pwd-Auth | UserPasswordHandler | 用户名/密码验证 |
大概微软也知道在 .NET 库中集成太多验证方案太笨重,所以现在新版本的 ASP.NET Core 的默认库中只保留一些基本的验证方案——如 Cookie,这个方案是内置的,我们不需要自己写代码(在 Microsoft.AspNetCore.Authentication.Cookies 命名空间中)。
在 Microsoft.AspNetCore.Authentication 命名空间下有个抽象类 AuthenticationHandler<TOptions>,它实现了一点基本功能,我们如果想自己写验证方案,可以从这个类派生。但,老周这次要用的方案只是对 Session 的简单检查,所以,就不需要从这个抽象类派生,而是直接实现 IAuthenticationHandler 接口。
在实现验证逻辑前,咱们写个类,作为一些可设置参数的选项。
public class TestAuthenticationOptions
{
/// <summary>
/// 登录入口路径
/// </summary>
public string LoginPath { get; set; } = "/Home/Login"; /// <summary>
/// 存入Session的键名
/// </summary>
public string SessionKeyName { get; set; } = "uid"; /// <summary>
/// 返回URL参数名
/// </summary>
public string ReturnUrlKey { set; get; } = "return";
}
这里老周只按照项目需求设定了三个选项,想添加选项的话得看你的实际需求了。
LoginPath:登录入口,这个属性指定一个URL(一般是相对URL),表示用户输入名称和密码登录的页面(可以是MVC,可以是 RazorPages,这个无所谓,由URL路由和你的代码决定)。
SessionKeyName:这个属性设置 Session 里面存放登录标识时的 Key 名。其实 Session 和字典对象类似,里面每个项都有唯一的 Key。
ReturnUrlKey:指定一个字段名,这个字段名一般附加在URL的参数中,表示要跳转回去的路径。比如,设置为“return”,那么,假如我们要访问 https://localhost/admin/op,但这个路径(或页面)必须要验证,否则不能访问(其实包含授权过程),于是会自动跳转到 https://localhost/Home/login,让用户登录。但用户登录成功后要返回 /admin/op,所以,在 Login 后加个参数:
https://localhost/Home/Login?return=/admin/op
当登录并验证成功后,根据这个 return 查询字段跳转回去。如果你把 ReturnUrlKey 属性设置为“back”,那么登录的URL就是:
https://localhost/Home/Login?back=/admin/op
在实现 IAuthenticationHandler 接口时,可以同时实现 IAuthenticationSignInHandler 接口。而 IAuthenticationSignInHandler 接口是包含 IAuthenticationHandler 和 IAuthenticationSignOutHandler 接口的。这就等于,你只实现 IAuthenticationSignInHandler 接口就行,它包含三个接口的方法成员。
InitializeAsync 方法:初始化时用,一般可以从这里获取当前请求关联的 HttpContext ,以及正在被使用的验证方案信息。
AuthenticateAsync 方法:验证过程,此处老周的做法仅仅看看 Session 中有没有需要的Key就行了。
ChallengeAsync 方法:一旦验证失败,就会调用这个方法,向客户端索要验证信息。这里需要的验证信息是输入用户名和密码。所以,老周在些方法中 Redirect 到登录页面。
ForbidAsync 方法:禁止访问时用,可以直接调用 HttpContext 的 ForbidAsync 方法。
SignInAsync 方法:登入时调用,这里老周只是把用户名放入 Session 就完事了。
SignOutAsync 方法:注销时调用,这里只是把 Session 中的用户名删除即可。
这些方法都可以由 ASP.NET Core 内部自动调用,也可以通过 HttpContext 的扩展方法手动触发,如SignInAsync、AuthenticateAsync、ChallengeAsync等。
public class TestAuthenticationHandler : IAuthenticationSignInHandler
{
/// <summary>
/// 验证方案的名称,可以自行按需取名
/// </summary>
public const string TEST_SCHEM_NAME = "some_authen"; /// <summary>
/// 依赖注入获取的选项
/// </summary>
public TestAuthenticationOptions Options { get; private set; } public TestAuthenticationHandler(IOptions<TestAuthenticationOptions> opt)
{
Options = opt.Value;
} public HttpContext HttpContext { get; private set; }
public AuthenticationScheme Scheme { get; private set; } public Task<AuthenticateResult> AuthenticateAsync()
{
// 先要看看验证方案是否与当前方案匹配
if(Scheme.Name != TEST_SCHEM_NAME)
{
return Task.FromResult(AuthenticateResult.Fail("验证方案不匹配"));
}
// 再看Session
if(!HttpContext.Session.Keys.Contains(Options.SessionKeyName))
{
return Task.FromResult(AuthenticateResult.Fail("会话无效"));
}
// 验证通过
string un = HttpContext.Session.GetString(Options.SessionKeyName)??string.Empty;
ClaimsIdentity id = new(TEST_SCHEM_NAME);
id.AddClaim(new(ClaimTypes.Name, un));
ClaimsPrincipal prcp = new(id);
AuthenticationTicket ticket = new(prcp, TEST_SCHEM_NAME);
return Task.FromResult(AuthenticateResult.Success(ticket));
} public Task ChallengeAsync(AuthenticationProperties? properties)
{
// 跳转到登录入口
HttpContext.Response.Redirect($"{Options.LoginPath}?{Options.ReturnUrlKey}={HttpContext.Request.Path}");
return Task.CompletedTask;
} public async Task ForbidAsync(AuthenticationProperties? properties)
{
await HttpContext.ForbidAsync(Scheme.Name);
} public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context)
{
// 获取一些必备对象的引用
HttpContext = context;
Scheme = scheme;
return Task.CompletedTask;
} public Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties)
{
// 获取用户名
string uname = user.Identity?.Name ?? string.Empty;
if(!string.IsNullOrEmpty(uname))
{
HttpContext.Session.SetString(Options.SessionKeyName, uname);
}
return Task.CompletedTask;
} public Task SignOutAsync(AuthenticationProperties? properties)
{
if(HttpContext.Session.Keys.Contains(Options.SessionKeyName))
{
HttpContext.Session.Remove(Options.SessionKeyName);
}
return Task.CompletedTask;
}
}
在 AuthenticateAsync 方法中,先要检查一下,当前所使用用的验证方案是否与 TEST_SCHEM_NAME 所表示的方案名称相同。这是为了防止把 TestAuthenticationHandler 与错误的验证方案进行注册绑定。例如我这个是实现用Session来验证的,要是把它与“Email-Auth”方案绑定,就会出现逻辑错误,毕竟此类不是用电子邮件来验证的。
不管是实现验证方法AuthenticateAsync 还是登录方法SignInAsync,都不要去检查用户名和密码,而应该把用户名和密码验证放到登录的页面或 Controller 中处理。因为这个自定义的 TestAuthenticationHandler 在许多需要验证的请求中都要调用,如果你在这里去检查用户名和密码,岂不是每次都要跳转到登录页让用户去输入?
B、授权
一旦验证完成,就到了授权过程。
验证过程通过验证方案名称来标识,同样,授权过程也可包含多个策略。
比如,可以基于用户的角色进行授权,管理员的权限多一些,非管理员的少一些;
可以基于用户的年龄进行授权,哪些游戏 15 岁以下的不能玩;
或者,基于用户的信用分来授权,信用差的不能贷款;信用好的允许你贷款
……
授权过程处理是通过收集一系列的声明(Claim)来评估一下用户具有哪些权限。比如
你是管理员吗?
你几岁了?
你过去三年的信用值是多少?
你是不是VIP用户?
你的购物积分多少?
你过去一年在我店买过几次东西?
……
这些声明来源很多,可以在过去用户购买东西时存入数据库并汇总出来,也可能用户在登录验证时从数据库中查询到。处理代码要根据这些声明来综合评定一下,你是否达到授权的【要求】。
这些【要求】就可以用 IAuthorizationRequirement 接口来表示。好玩的是,这个接口没有规定任何方法成员,你只需要有个类来实现这个接口就行。比如用户积分,写个类叫 UserPoints,实现这个接口,再加个属性叫 PointValue,表示积分数。
然后,你把这个 UserPoints 类添加到某授权策略的 Requirements 集合中,在处理授权评估时,再通过代码检查一下里面的各种实现了 IAuthorizationRequirement 接口的对象,看看符不符合条件。
而自定义的授权策略处理是实现 IAuthorizationHandler 接口。你看看,是不是原理差不多,刚才验证的时候会实现自定义的 Handler,现在授权时又可以实现 Handler。
在 Session 验证这个方案中,我们不需要写自定义的授权 Handler,只需要调用现有API开启授权功能,并注册一个有效的策略名称即可。而 IAuthorizationRequirement 我们也不用实现,直接用扩展方法 RequireAuthenticatedUser 就行。意思是说只要有已登录的用户名就行,毕竟咱们前面在验证时,已经提供了一个有效的用户登录名,还记得 AuthenticateAsync 方法中的这几行吗?
// 验证通过
string un = HttpContext.Session.GetString(Options.SessionKeyName)??string.Empty;
ClaimsIdentity id = new(TEST_SCHEM_NAME);
id.AddClaim(new(ClaimTypes.Name, un));
ClaimsPrincipal prcp = new(id);
AuthenticationTicket ticket = new(prcp, TEST_SCHEM_NAME);
return Task.FromResult(AuthenticateResult.Success(ticket));
其实我们已经添加了一个声明——Name,以用户名为标识,在授权策略中,程序要查找的就是这个声明。只要找到,就能授权;否则拒绝访问。
----------------------------------- 第三宇宙分界线 -----------------------------------
在 Program.cs 文件中,我们要注册这些服务类。
var builder = WebApplication.CreateBuilder(args);
// 启用Session功能
builder.Services.AddSession(o =>
{
// 把时间缩短一些,好测试
o.IdleTimeout = TimeSpan.FromSeconds(5);
});
// 这个用来检查用户名和密码是否正确
builder.Services.AddSingleton<UserChecker>();
// 使用MVC功能
builder.Services.AddControllersWithViews();
// 注册刚刚定义的选项类,可以依赖注入
// 不要忘了,不然出大事
builder.Services.AddOptions<TestAuthenticationOptions>();
// 添加验证功能
builder.Services.AddAuthentication(opt =>
{
// 添加我们自定义的验证方案名
opt.AddScheme<TestAuthenticationHandler>(TestAuthenticationHandler.TEST_SCHEM_NAME, null);
});
// 添加授权功能
builder.Services.AddAuthorization(opt =>
{
// 注册授权策略,名为“demo2”
opt.AddPolicy("demo2", c =>
{
// 与我们前面定义的验证方案绑定
// 授权过程跟随该验证后发生
c.AddAuthenticationSchemes(TestAuthenticationHandler.TEST_SCHEM_NAME);
// 要求存在已登录用户的标识
c.RequireAuthenticatedUser();
});
});
var app = builder.Build();
把Session中的过期进间设为5秒,是为了好测试。
上面代码还注册了一个单实例模式的 UserChecker,这只是个测试,老周不使用数据库了,就用一个写“死”了的类来检查用户名和密码是否正确。
public class UserChecker
{
private class UserInfo
{
public string Name { get; init; }
public string Password { get; init; }
} // 简单粗暴的用户信息,只为测试而生
static readonly IEnumerable<UserInfo> _Users = new UserInfo[]
{
new(){Name = "lucy", Password="123456"},
new(){Name= "tom", Password="abcd"},
new() {Name="jim", Password="xyz321"}
}; /// <summary>
/// 验证用户名和密码是否有效
/// </summary>
/// <param name="name">用户名</param>
/// <param name="pwd">用户密码</param>
/// <returns></returns>
public bool CheckLogin(string name, string pwd) => _Users.Any(u => u.Name == name.ToLower() && u.Password == pwd);
}
在 App 对象 build 了之后,记得插入这些中间件到HTTP管道。
app.UseSession();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllerRoute("main", "{controller=Home}/{action=Index}");
注意顺序,授权在验证之后,验证和授权要在 Map MVC的处理之前。
测试项目中我用到了两个 Controller。第一个是 Home,可以随便访问,故不需要考虑验证和授权的问题;第二个是 Admin,只有已正确登录的用户才可以访问。
Admin 控制器很简单,只返回对应的视图。
[Authorize("demo2")]
public class AdminController : Controller
{
public IActionResult MainLoad()
{
return View();
}
}
注意在此控制器上应用了 Authorize 特性,并且指定了使用的授权策略是“demo2”。表明这个控制器里面的所有 Action 都不能匿名访问,要访问得先登录。
MainLoad 视图如下:
<h2>
这是管理后台
</h2>
--------------------------- L78分界线 ----------------------------
Home 控制器允许匿名访问,其中包含了用户登录入口 Login。
public class HomeController : Controller
{
TestAuthenticationOptions _options; public HomeController(IOptions<TestAuthenticationOptions> o)
{
_options = o.Value;
} public IActionResult Index() => View(); public IActionResult Login()
{
// 获取返回的URL
if (!HttpContext.Request.Query.TryGetValue(_options.ReturnUrlKey, out var url))
{
url = string.Empty;
}
// 用模型来传递URL
return View((object)url.ToString());
} public async Task<IActionResult> PostLogin(
string name, //用户名
string pwd, //密码
string _url, //要跳回的URL
[FromServices]UserChecker usrchecker //用来验证用户名和密码
)
{
if(string.IsNullOrEmpty(name)
|| string.IsNullOrEmpty(pwd))
{
return View("Login", _url);
}
// 如果密码不正确
if (!usrchecker.CheckLogin(name, pwd))
return View("Login", _url);
// 准备登入用的材料
// 1、声明
Claim cname = new(ClaimTypes.Name, name);
// 2、标识
ClaimsIdentity id = new(TestAuthenticationHandler.TEST_SCHEM_NAME);
id.AddClaim(cname);
// 3、主体
ClaimsPrincipal principal = new(id);
// 登入
await HttpContext.SignInAsync(TestAuthenticationHandler.TEST_SCHEM_NAME, principal); if(!string.IsNullOrEmpty(_url))
{
// 重定向回到之前的URL
return Redirect(_url);
} return View("Login", _url);
}
}
Home 控制器中只用到两个视图,一个是Index,默认主页;另一个是 Login,用于显示登录UI。
Login 视图如下:
@inject Microsoft.Extensions.Options.IOptions<DemoApp5.TestAuthenticationOptions> _opt
@model string <form method="post" asp-controller="Home" asp-action="PostLogin">
<p>
用户名:
<input name="name" type="text"/>
</p>
<p>
密 码:
<input name="pwd" type="password"/>
</p>
<button type="submit">确 定</button>
<input type="hidden" name="_url" value="@Model" />
</form>
这个视图中绑定的 Model 类型为string,实际上就是 Challenge 方法重定向到此URL时传递的回调URL参数(/Home/Login?return=/Admin/XXX)。在Login方法中,通过View方法把这个URL传给视图中的 Model 属性。
之所以要使用模型绑定,是因为HTTP两次请求间是无状态的:
第一次,GET 方式访问 /Home/Login,并用 return 参数传递了回调URL;
第二次,输入完用户名和密码,POST 方式提交时调用的是 PostLogin 方法,这时候,Login?return=xxxxx 传递的URL已经丢失了,无法再获取。只能绑定到 Model 上,再从 Model 中取值绑定到 hidden 元素上。
<input type="hidden" name="_url" value="@Model" />
POST的时候就会连同这个 hidden 一起发回给服务器,这样在 PostLogin 方法中还能够获取到这个回调URL。
----------------------------------------------------------------------------------------------------
运行示例后,先是打开默认的 Index 视图。
点击“管理页入口”链接,进入 Admin/MainLoad,此时候因为没有登录,就会跳转到 /Home/Login 。输入一个正确的用户名和密码,登录。
成功后就跳回到管理后台。
5 秒钟后就会过期,要访问就得重新登录。当然这个主要为了测试方便。实际运用可以设置 15 -20 分钟。
保存 Session 标识的 Cookie 由运行库自动完成,通过浏览器的开发人员工具能够看到生成的 Cookie。
默认的 Cookie 使用了名称 AspNetCore.Session,如果你觉得这个名字不够高大上,可以自己改。在 AddSession 时设置。
builder.Services.AddSession(o =>
{
// 把时间缩短一些,好测试
o.IdleTimeout = TimeSpan.FromSeconds(5);
o.Cookie.Name = "dyn_ssi";
});
然后,生成的用来保存Session标识的 Cookie 就会变成: