马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
×
一个多月没更新了。7月末的时候有个 Web 看板的玩意儿,也不知道他们找了什么人计划了个界面(估计是个妹子,UI 花里花哨的),然后他们说要做成这样。我说你们就这么一点点破数据,才14个字段的及时记录,用得着这么复杂的界面去显示吗。做出来也没屌用,没啥数据可呈现的。开了几次会跟他们吵了几场。最后老周奋战 12 天给他们全搞定,包括后台的设备管理、用户、角色、权限分配管理、上传日志 维护。前端页面的左边就做成像火车站的大屏幕那样,把最新的数据自动滚动显示;右边就像游戏里面的“血条”,用一排进度条动态显示每个车间,车间中每排呆板的操作员检测零件的数量。工作服从高的人显示为金色,然后依次红、蓝、粉,工作服从最差的显示为灰色。他们说这叫“员工学习曲线”,反正都不知道哪个大头鬼发明的名词。新员工第一个月要完成 70% 的使命,第二个月要完成 80% 的使命。如果前三个月完成的百分比不达标,卷铺盖走人!虽然他们的管理严格,不过报酬不错的,双休,不加班,每周四还组织搞运动,周五发零食。这年头,人家打工仔都比步伐员快活。
===================================================================
宇宙人都知道,HTTP 通信是无状态的,所以总得有个东西来存放状态。尤其在登录方面,要有个东西来保存登录信息,才不至于每打开一个页面都要登录一次。传统规矩,服务器用 Session,浏览器用 Cookie。绝大多数情况下是可用的,但部门非正常人类,浏览器禁用了 Cookie。这个老周遇过,JS 动态发出哀求,然后携带登录信息(固然不含用户名和暗码的)。雷同 Token,那是很多年前的活了,那年代还不流行什么 Token 的,其实是在服务器上动态天生的一串字符,在数据库用有个表将这串字符与用户对应起来,并存储过期时间。如果过期了或者对不上,就验证失败。Token 也没啥高级的,别人拿到你的 Token 照旧能冒充你。
在 ASP.NET Core 中,Cookie 验证可以配置登录路径、注销路径,以及回调URL的字段名。相信有搞过 ASP.NET Core 的伙伴都知道,老周不多介绍了。
但是,如果各位仔细的话,会发现执行登录后不会自动跳转的。下面老周用一个不健全但结构的例子演示一下。
咱们明确:
1、老周这里用 SQLite 数据库演示;
2、需要用 EF Core;
3、Cookie 验证会配置登录、注销等路径;
4、使用 MVC。
好,动手!老传统,咱们从抽象到详细。数据库是最抽象的,就从实体模型开始。- public class UserInfo
- {
- public int Uid { get; set; }
- public string UName { get; set; } = "admin";
- public string Passwd { get; set; } = "";
- }
复制代码 这个够了,有ID,有用户名和暗码字段。现在咱们做好 DbContext 类的派生。- public class MyAppContext : DbContext
- {
- private<strong> HashHelper hasher</strong>;
- public MyAppContext(DbContextOptions<MyAppContext> options, HashHelper hh)
- : base(options)
- {
- hasher = hh;
- }
- // 数据集合
- public DbSet<UserInfo> Users { get; set; }
- protected override void OnModelCreating(ModelBuilder modelBuilder)
- {
- modelBuilder.Entity<UserInfo>(ent =>
- {
- // 主键
- ent.HasKey(u => u.Uid);
- // 表名
- ent.ToTable("tb_Users");
- // 初始数据
- ent.HasData(new UserInfo
- {
- Uid = 1,
- UName = "admin",
- Passwd = hasher.MakeHash("admin")
- });
- });
- }
- }
复制代码 眼尖的老伙伴发现了一个叫 HashHelper 的东西从构造函数注入了,它是啥服务?那是老周自主研发的加密器,用 sha256 哈希。老周做项目都习惯用 SHA256 以上的处理暗码的,老早就不用 MD5 了。实际项目中,咱们一般不把原暗码存入数据库,都是哈希一下再存。安全在 99.99% 的情况是足够的。其实很多项目就算直接存明文都没关系,你以为这个破东西有多出名啊,黑客们根本不感兴趣。许多小工厂的项目,老周乃至都不加密的。
下面是 HashHelper 的代码,很简单,就不写注释了。- public class HashHelper
- {
- public string MakeHash(string input)
- {
- return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(input)));
- }
- }
复制代码 在服务容器注册一下本身的 DbContext。- builder.Services.AddDbContext<MyAppContext>(opt =>
- {
- opt.UseSqlite("data source=test.db");
- });
复制代码 为了证明老周前面提到的题目,咱们搞个 MVC 控制器。- [Route("[controller]/[action]")]
- public class MainController : Controller
- {
- // 这是主页,要授权后才能访问
- [Authorize]
- public IActionResult Default()
- {
- return View("~/Views/Default.cshtml");
- }
- // GET 方式,主要返回UI,让你输入用户名和密码
- public IActionResult Login(<strong>string?</strong><strong> url</strong>)
- {
- <strong>ViewData[</strong><strong>"url"] = url;
- </strong> return View("~/Views/Login.cshtml");
- }
- // 这个是 POST 的,接收输入的用户和密码
- [HttpPost]
- public async Task<IActionResult> Login(string username, string passwd, <strong>string?</strong><strong> url</strong>, [FromServices]HashHelper hasher, [FromServices]MyAppContext dbContext)
- {
- // 全转为小写再比较,咱们这里不区分大小写
- string hashedPwd = hasher.MakeHash(passwd);
- string lowcasename = username.ToLower();
- var user = await dbContext.Users.FirstOrDefaultAsync(u=>u.UName.ToLower() == lowcasename && u.Passwd == hashedPwd);
- // null 就是没查到用户
- if(user == null)
- {
- // 回去继续登录
- return Redirect("/main/login?url=" + url);
- }
- // 准备登录用的各种证据
- Claim c = new(ClaimTypes.NameIdentifier, user.UName);
- // 创建标识
- ClaimsIdentity idt = new([c], CookieAuthenticationDefaults.AuthenticationScheme);
- // 创建用户实体,这个会跟随HTTP管道周期内的 HttpContext
- ClaimsPrincipal princ = new(idt);
- // 执行登录,其实执行这个后会跳转的,但实际上没有跳转
- await<strong> HttpContext.SignInAsync</strong>(princ);
- // 无内容(后面运行之后你就懂的)
- return NoContent();
- }
- }
复制代码 留意那个叫 url 的参数。这个就是传回调地址用的,Cookie 验证默认的字段名是 ReturnUrl,当老周不喜欢这么长的名字,所以会配置为 url,这个后面配置验证时再说。
验证用户名和暗码是否精确的工作是由咱们本身完成的,如果精确,允许登录,就交给 HttpContext.SignInAsync 方法去处理,它会在服务器上存 Session,并天生发回给浏览器的 Cookie。
下面代码配置 Cookie 验证。- builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(opt =>
- {
- opt.Cookie.Path = "/"; // Cookie 范围路径
- opt.Cookie.Name = "guang_tou_qiang"; // Cookie的名称
- opt.LoginPath = "/main/login"; // 登入
- opt.LogoutPath = "/main/logout"; // 登出
- opt.ReturnUrlParameter = "url"; // 回调 URL 字段名
- });
复制代码 Cookie.Name 是天生的 Cookie 的名称,默认会带“ASP.NET Core”字样,这样不友好,所以一般要给它分配个别的名字,比如老周叫它“光头强”。
LogOut 其实没有实现的,这不是重点。注销登录也很简单,调用一下 HttpContext.SignOutAsync 方法完事了,它会删除 Cookie 和 Session 中保存的内容。记住回调用 URL 的字段名已改为 url。
比如,你要访问主页 http://hehe.net/,此时,验证和授权中间件分析后发现你小子没登录,然后自动跳转到 LoginPath 配置的路径。并且加上回调URL,http://hehe.net/main/login?url=/,意思就是你登录成功跳转回 url 指定的路径。
再比如,你要访问 http://hehe.net/tools,效果验证不通过,跳转到 http://hehe.net/main/login?url=/tools,等你成功登录后,再跳转回 /tools 页面。
这里各位要留意:大多数时候,咱们要呈现一个界面,让用户输入用户名和暗码(其他不用输入的不讨论),然后发回服务器。也就是说,HTTP-GET 方式调用 main/login 时由框架自动传递 url 的值,可是这里你 return View 返回登录UI,这使得一轮HTTP会话结束了;等输入好用户名和暗码,POST 回 main/login 时,url 的值已经丢了。所以,这里咱们在 GET 方式调用 main/login 时把 url 存到 ViewData 字典中,return View() 会自动把它传给视图,然后在视图文件里,咱们再从 ViewData 取出 url 的值。- <form action="<strong>login?url=@ViewData["</strong><strong>url"]</strong>" method="post"><table>
- <tr>
- <td>用户名:</td>
- <td><input type="text" name="username" required></td>
- </tr>
- <tr>
- <td>密码:</td>
- <td>
- <input type="password" name="passwd" required>
- </td>
- </tr>
- </table>
- <button type="submit">登录</button>
- </form>
复制代码 在 form 中咱们可以让 action 的值为 /main/login?url=,这等于将框架传给我们的 url 的值又传回给服务器。这样一来,执行 POST 的 Login 方法时,就不会丢失 url 的值了。
之所以要让 GET 和 POST 的方法都叫 Login,是因为 Cookie 验证在传递回调用 URL 时要查抄当前路径是不是 LoginPath,如果是才会进行跳转。所以这里我们要让 GET 和 POST 的路径都是 /main/login?url=....。
咱们运行一下。初次进入主页面失败,这时框架能自动跳转到登录页。
数据库默认创建的用户名和暗码都是 admin。点击登录后,你会发现没有转到主页。是没有成功吗?不,登录是成功的,但没有自动跳转。通过开发人员工具抓取的小笼包可以看到:服务器返回的是 No Content。
查看一下,有 Cookie 返回的。
还记得吗?咱们的 MVC 控制器里,就是 return NoContent() 的。
这么一搞,原因找到了。由于咱们是在 MVC Action 方法成员中调用 SignIn 方法的,虽然框架有自动跳转,但 MVC 在返回时,会把框架设置的标头覆盖了。
说人话就是:要想保证框架本身的跳转功能有效,登录处理就要在 HTTP 管道上完成,即在中间件层完成。
那,怎么解决呢?最最最简单的方法就是无视框架的跳转,在 MVC 控制器代码中咱们本技艺动跳转。- [HttpPost]
- public async Task<IActionResult> Login(string username, string passwd, string? url, [FromServices]HashHelper hasher, [FromServices]MyAppContext dbContext)
- {
- <form action="<strong>/login?url=@ViewData["</strong><strong>url"]</strong>" method="post">
- ……
- </form>
- // return NoContent();
- // 手动跳转
- return <strong>Redirect(url ?? "/main/default"</strong><strong>)</strong>;
- }
复制代码
如果你要求必须保证框架能自动跳转,能做到吗?能,只要把检验用户名/暗码和登录的处理逻辑放到 HTTP 管道上就行了,只有让用户输入这一步才走 MVC 控制器里返回视图。- 主页/验证失败 -> /login?url=... -> <strong>登录界面</strong> -> /login?url=... -> ...
复制代码 也就是在呈现登录界面这一步绕一个圈,跳 MVC 里面去,最终又跳回 HTTP 管道。
现在,咱们把 Cookie 验证的配置改一下(主要改登入/登出路径)。- builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(opt =>
- {
- opt.Cookie.Path = "/"; // Cookie 范围路径
- opt.Cookie.Name = "guang_tou_qiang"; // Cookie的名称
- opt.LoginPath = "<strong>/login</strong>"; // 登入
- opt.LogoutPath = "<strong>/logout</strong>"; // 登出
- opt.ReturnUrlParameter = "url"; // 回调 URL 字段名
- });
复制代码 咱们在 HTTP 管道上直接实现登入/登出。- app.MapGet("/login", (<strong>string? url</strong>) =>
- {
- // 这里是由框架自动转过来的
- Console.WriteLine("-- 进入登录入口");
- // 我们啥也不处理,目的只是呈现登录界面
- // 记得把 url 传过去,这个不能丢了,不然等会玩不下去了
- return Results.Redirect("/main/login?url=" + url);
- });
- app.MapPost("/login", async (
- [FromForm] string username,
- [FromForm] string passwd,
- [FromQuery] <strong>string? url</strong>) =>
- {
- HashHelper hasher = app.Services.GetRequiredService<HashHelper>();
- string hashedPwd = hasher.MakeHash(passwd);
- string lowcasename = username.ToLower();
- UserInfo? user;
- using (var scop =<strong> app.Services.CreateScope()</strong>)
- {
- MyAppContext context = scop.ServiceProvider.GetRequiredService<MyAppContext>();
- user = await context.Users.FirstOrDefaultAsync(u => u.UName.ToLower() == lowcasename && u.Passwd == hashedPwd);
- }
- // null 就是没查到用户
- if (user == null)
- {
- // 回去继续登录
- return Results.Redirect("/login?url=" + url);
- }
- // 准备登录
- Claim c = new(ClaimTypes.NameIdentifier, user.UName);
- ClaimsIdentity idt = new([c], CookieAuthenticationDefaults.AuthenticationScheme);
- ClaimsPrincipal princ = new(idt);
- // 执行登录
- return Results.SignIn(princ);
- });
- app.MapGet("/logout", async context =>
- {
- // 注销很简单
- await context.SignOutAsync();
- });
复制代码 /login 是有两个的,一个GET,一个POST。GET 是由框架跳转的,这时我们绕到登录界面 /main/login,并把 url 参数传过去。之后就会显示登录界面,输入用户名和暗码后,登录,又 POST 回 /login,并把 url 又传回服务器。
所以,真正校验暗码和完成登录的是在 POST 版的 /login。由于 Minim-API 创建的中间件是单实例服务,不能直接获取 DbContext。它是范围(作用域)服务。要通过 Services.CreateScope 方法创建一个范围级别的服务容器才能获取。固然,你在 AddDbContext 时确实可以通过选项将 DbContext 配置为单实例服务,但严重不推荐这样做,DbContext 最好用完就释放。
之后的登录逻辑一样,要准备 Principal。不过,这次不需要调用 HttpContext.SignInAsync 方法,而是直接用 TypedResults (或Results) 对象的静态方法—— SignIn。
注销时调用 HhttpContext.SignOutAsync,或者 Results / TypedResults 的 SignOut 方法。
在登录界面的视图文件中,要把 form 的 action 改一下,记得用查询字符串传回 url。- <form action="<strong>/login?url=@ViewData["</strong><strong>url"]</strong>" method="post">
- ……
- </form>
复制代码 一切看着很顺利,但一运行就……
这是没有配置好 anti-forgery 导致的,很好解决。三步走:
1、在配置服务容器阶段,调用 AddAntiforgery 方法。- builder.Services.AddAntiforgery();
复制代码 2、在 HTTP 管道中,在验证和授权后面调用 UseAntiforgery 方法。为什么要在授权之后呢,因为不想在那时候就天生 anti-token。- app.UseRouting();
- app.UseAuthentication();
- app.UseAuthorization();
- <strong>app.UseAntiforgery();</strong>
复制代码 3、在视图中,form 里面要加这一行。- [b]@Html.AntiForgeryToken()[/b]<form action="<strong>/login?url=@ViewData["</strong><strong>url"]</strong>" method="post">
- ……
- </form>
复制代码 就是天生 token 用的,最终 HTML 会酿成这样。
同时还带个 Cookie。
在登录页面输入用户名和暗码,就行了。
这样你会发现,现在框架能自动跳转了。
其实,呈现登录页面这一步不是说一定要绕进 MVC 里,而是因为老周前面演示时用的 MVC,其实你用啥方法就行的。比如用字符串直接拼个 HTML 文档返回也可以的。或者在 wwwroot 目录中放到静态 HTML 页也可以。总之方法多多,任君选择。只要到达让用户输入信息的目的就行。
最后,咱们去瞧一下源代码,看看框架怎么跳转的。Cookie 验证的处理类是 CookieAuthenticationHandler。当登录操作触发时,由 IAuthenticationService 服务调用 HandleSignInAsync 方法。在处理完登录事宜后会有这么一段。- var shouldHonorReturnUrlParameter = Options.LoginPath.HasValue && OriginalPath == Options.LoginPath;
- await ApplyHeaders(shouldRedirect: true, shouldHonorReturnUrlParameter, signedInContext.Properties);
复制代码 ApplyHeaders 通过设置标头的方式完成跳转。- private async Task ApplyHeaders(bool shouldRedirect, bool shouldHonorReturnUrlParameter, AuthenticationProperties properties)
- {
- Response.Headers.CacheControl = HeaderValueNoCacheNoStore;
- Response.Headers.Pragma = HeaderValueNoCache;
- Response.Headers.Expires = HeaderValueEpocDate;
- if (shouldRedirect && Response.StatusCode == 200)
- {
- // set redirect uri in order:
- // 1. properties.RedirectUri
- // 2. query parameter ReturnUrlParameter (if the request path matches the path set in the options)
- //
- // Absolute uri is not allowed if it is from query string as query string is not
- // a trusted source.
- var redirectUri = properties.RedirectUri;
- if (shouldHonorReturnUrlParameter && string.IsNullOrEmpty(redirectUri))
- {
- <strong>redirectUri </strong><strong>=</strong><strong> Request.Query[Options.ReturnUrlParameter]</strong>;
- if (string.IsNullOrEmpty(redirectUri) || !IsHostRelative(redirectUri))
- {
- redirectUri = null;
- }
- }
- if (redirectUri != null)
- {
- await<strong> Events.RedirectToReturnUrl</strong>(
- new RedirectContext<CookieAuthenticationOptions>(Context, Scheme, Options, properties, redirectUri));
- }
- }
- }
复制代码 通过 Request.Query 获取了回调 URL,从这里看到,跳转的条件有二:
1、有设置的 ReturnUrl 字段;
2、当前哀求的路径要和 LoginPath 配置的相同。这就是我们上面为什么 GET 和 POST 都要 /login 的原因,这是为了保证路径稳定。
再到 CookieAuthenticationEvents 类中,看看真正的跳转代码。- public Func<RedirectContext<CookieAuthenticationOptions>, Task> OnRedirectToReturnUrl { get; set; } = context =>
- {
- if (IsAjaxRequest(context.Request))
- {
- context.<strong>Response.Headers.Location </strong><strong>=</strong><strong> context.RedirectUri</strong>;
- }
- else
- {
- context.<strong>Response.Redirect(context.RedirectUri)</strong>;
- }
- return Task.CompletedTask;
- };
复制代码 这下看懂了吧。
好了,今天老周就水到这里了。要开饭了。
|