4

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

 2 years ago
source link: https://www.cnblogs.com/tcjiaan/p/15846148.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

如果大伙伴们以前写过 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

怎么样?像吧,也不知道那些洋鬼子们怎么想的,把它俩弄得那么像。

老周试着用一个故事来区别这两个过程——假如你去你朋友家里玩。首先,你朋友家里得有人,而且你按门铃后他会开门让你进去(验证);之后,你进去了,但是朋友家里有很多个房间,一般大客厅你肯定可以站在那里的,但是,朋友的卧室就不见得会允许你进去(授权),除非你们特别熟。

验证是你能不能进别人家的门,授权是进了门后你被允许做什么

------------------------- 小龙虾星人分界线 ------------------------

下面分别说说这两个过程的一些要素。

现在的网站咱们都知道,身份验证方式很多。你可以用户名/密码登录,你可以用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 在许多需要验证的请求中都要调用,如果你在这里去检查用户名和密码,岂不是每次都要跳转到登录页让用户去输入?

一旦验证完成,就到了授权过程。

验证过程通过验证方案名称来标识,同样,授权过程也可包含多个策略。

比如,可以基于用户的角色进行授权,管理员的权限多一些,非管理员的少一些;

可以基于用户的年龄进行授权,哪些游戏 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 视图。

367389-20220126173924698-1218281316.png

 点击“管理页入口”链接,进入 Admin/MainLoad,此时候因为没有登录,就会跳转到 /Home/Login 。输入一个正确的用户名和密码,登录。

367389-20220126174122991-59015342.png

 成功后就跳回到管理后台。

367389-20220126174900210-1542629688.png

 5 秒钟后就会过期,要访问就得重新登录。当然这个主要为了测试方便。实际运用可以设置 15 -20 分钟。

保存 Session 标识的 Cookie 由运行库自动完成,通过浏览器的开发人员工具能够看到生成的 Cookie。

367389-20220126175847852-1493191019.png

 默认的 Cookie 使用了名称 AspNetCore.Session,如果你觉得这个名字不够高大上,可以自己改。在 AddSession 时设置。

builder.Services.AddSession(o =>
{
    // 把时间缩短一些,好测试
    o.IdleTimeout = TimeSpan.FromSeconds(5);
    o.Cookie.Name = "dyn_ssi";
});

然后,生成的用来保存Session标识的 Cookie 就会变成:

367389-20220126180404457-1502374032.png

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK