【ASP.NET Core】Cookie 验证中回调用 URL 自动跳转的问题
一个多月没更新了。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 HashHelper hasher; 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(string? url) { ViewData["url"] = url; return View("~/Views/Login.cshtml"); } // 这个是 POST 的,接收输入的用户和密码 [HttpPost] public async Task<IActionResult> Login(string username, string passwd, string? url, [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 HttpContext.SignInAsync(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="login?url=@ViewData["url"]" 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=<从ViewData取出的值>,这等于将框架传给我们的 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) { …… // return NoContent(); // 手动跳转 return Redirect(url ?? "/main/default"); }
如果你要求必须保证框架能自动跳转,能做到吗?能,只要把检验用户名/密码和登录的处理逻辑放到 HTTP 管道上就行了,只有让用户输入这一步才走 MVC 控制器里返回视图。
主页/验证失败 -> /login?url=... -> 登录界面 -> /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 = "/login"; // 登入 opt.LogoutPath = "/logout"; // 登出 opt.ReturnUrlParameter = "url"; // 回调 URL 字段名 });
咱们在 HTTP 管道上直接实现登入/登出。
app.MapGet("/login", (string? url) => { // 这里是由框架自动转过来的 Console.WriteLine("-- 进入登录入口"); // 我们啥也不处理,目的只是呈现登录界面 // 记得把 url 传过去,这个不能丢了,不然等会玩不下去了 return Results.Redirect("/main/login?url=" + url); }); app.MapPost("/login", async ( [FromForm] string username, [FromForm] string passwd, [FromForm] string? url) => { HashHelper hasher = app.Services.GetRequiredService<HashHelper>(); string hashedPwd = hasher.MakeHash(passwd); string lowcasename = username.ToLower(); UserInfo? user; using (var scop = app.Services.CreateScope()) { 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="/login?url=@ViewData["url"]" method="post"> …… </form>
一切看着很顺利,但一运行就……

这是没有配置好 anti-forgery 导致的,很好解决。三步走:
1、在配置服务容器阶段,调用 AddAntiforgery 方法。
builder.Services.AddAntiforgery();
2、在 HTTP 管道中,在验证和授权后面调用 UseAntiforgery 方法。为什么要在授权之后呢,因为不想在那时候就生成 anti-token。
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
3、在视图中,form 里面要加这一行。
<form action="/login?url=@ViewData["url"]" method="post"> @Html.AntiForgeryToken() …… </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)) { redirectUri = Request.Query[Options.ReturnUrlParameter]; if (string.IsNullOrEmpty(redirectUri) || !IsHostRelative(redirectUri)) { redirectUri = null; } } if (redirectUri != null) { await Events.RedirectToReturnUrl( 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.Response.Headers.Location = context.RedirectUri; } else { context.Response.Redirect(context.RedirectUri); } return Task.CompletedTask; };
这下看懂了吧。
好了,今天老周就水到这里了。要开饭了。

浙公网安备 33010602011771号