zl程序教程

您现在的位置是:首页 >  .Net

当前栏目

【ASP.NET Core】使用最熟悉的Session验证方案

2023-03-20 14:46:32 时间

如果大伙伴们以前写过 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 就会变成: