【ASP.NET Core】Cookie 验证中回调用 URL 自动跳转的题目

[复制链接]
发表于 2026-8-23 10:14:54 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
一个多月没更新了。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。
好,动手!老传统,咱们从抽象到详细。数据库是最抽象的,就从实体模型开始。
  1. public class UserInfo
  2. {
  3.     public int Uid { get; set; }
  4.     public string UName { get; set; } = "admin";
  5.     public string Passwd { get; set; } = "";
  6. }
复制代码
这个够了,有ID,有用户名和暗码字段。现在咱们做好 DbContext 类的派生。
  1. public class MyAppContext : DbContext
  2. {
  3.     private<strong> HashHelper hasher</strong>;
  4.     public MyAppContext(DbContextOptions<MyAppContext> options, HashHelper hh)
  5.         : base(options)
  6.     {
  7.         hasher = hh;
  8.     }
  9.     // 数据集合
  10.     public DbSet<UserInfo> Users { get; set; }
  11.     protected override void OnModelCreating(ModelBuilder modelBuilder)
  12.     {
  13.         modelBuilder.Entity<UserInfo>(ent =>
  14.         {
  15.             // 主键
  16.             ent.HasKey(u => u.Uid);
  17.             // 表名
  18.             ent.ToTable("tb_Users");
  19.             // 初始数据
  20.             ent.HasData(new UserInfo
  21.             {
  22.                 Uid = 1,
  23.                 UName = "admin",
  24.                 Passwd = hasher.MakeHash("admin")
  25.             });
  26.         });
  27.     }
  28. }
复制代码
眼尖的老伙伴发现了一个叫 HashHelper 的东西从构造函数注入了,它是啥服务?那是老周自主研发的加密器,用 sha256 哈希。老周做项目都习惯用 SHA256 以上的处理暗码的,老早就不用 MD5 了。实际项目中,咱们一般不把原暗码存入数据库,都是哈希一下再存。安全在 99.99% 的情况是足够的。其实很多项目就算直接存明文都没关系,你以为这个破东西有多出名啊,黑客们根本不感兴趣。许多小工厂的项目,老周乃至都不加密的。
下面是 HashHelper 的代码,很简单,就不写注释了。
  1. public class HashHelper
  2. {
  3.     public string MakeHash(string input)
  4.     {
  5.         return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(input)));
  6.     }
  7. }
复制代码
在服务容器注册一下本身的 DbContext。
  1. builder.Services.AddDbContext<MyAppContext>(opt =>
  2. {
  3.    opt.UseSqlite("data source=test.db");
  4. });
复制代码
为了证明老周前面提到的题目,咱们搞个 MVC 控制器。
  1. [Route("[controller]/[action]")]
  2. public class MainController : Controller
  3. {
  4.     // 这是主页,要授权后才能访问
  5.     [Authorize]
  6.     public IActionResult Default()
  7.     {
  8.         return View("~/Views/Default.cshtml");
  9.     }
  10.     // GET 方式,主要返回UI,让你输入用户名和密码
  11.     public IActionResult Login(<strong>string?</strong><strong> url</strong>)
  12.     {
  13.         <strong>ViewData[</strong><strong>"url"] = url;
  14. </strong>        return View("~/Views/Login.cshtml");
  15.     }
  16.     // 这个是 POST 的,接收输入的用户和密码
  17.     [HttpPost]
  18.     public async Task<IActionResult> Login(string username, string passwd, <strong>string?</strong><strong> url</strong>, [FromServices]HashHelper hasher, [FromServices]MyAppContext dbContext)
  19.     {
  20.         // 全转为小写再比较,咱们这里不区分大小写
  21.         string hashedPwd = hasher.MakeHash(passwd);
  22.         string lowcasename = username.ToLower();
  23.         var user = await dbContext.Users.FirstOrDefaultAsync(u=>u.UName.ToLower() == lowcasename && u.Passwd == hashedPwd);
  24.         // null 就是没查到用户
  25.         if(user == null)
  26.         {
  27.             // 回去继续登录
  28.             return Redirect("/main/login?url=" + url);
  29.         }
  30.         // 准备登录用的各种证据
  31.         Claim c = new(ClaimTypes.NameIdentifier, user.UName);
  32.         // 创建标识
  33.         ClaimsIdentity idt = new([c], CookieAuthenticationDefaults.AuthenticationScheme);
  34.         // 创建用户实体,这个会跟随HTTP管道周期内的 HttpContext
  35.         ClaimsPrincipal princ = new(idt);
  36.         // 执行登录,其实执行这个后会跳转的,但实际上没有跳转
  37.         await<strong> HttpContext.SignInAsync</strong>(princ);
  38.         // 无内容(后面运行之后你就懂的)
  39.         return NoContent();
  40.     }
  41. }
复制代码
留意那个叫 url 的参数。这个就是传回调地址用的,Cookie 验证默认的字段名是 ReturnUrl,当老周不喜欢这么长的名字,所以会配置为 url,这个后面配置验证时再说。
验证用户名和暗码是否精确的工作是由咱们本身完成的,如果精确,允许登录,就交给 HttpContext.SignInAsync 方法去处理,它会在服务器上存 Session,并天生发回给浏览器的 Cookie。
下面代码配置 Cookie 验证。
  1. builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(opt =>
  2. {
  3.    opt.Cookie.Path = "/";     // Cookie 范围路径
  4.    opt.Cookie.Name = "guang_tou_qiang"; // Cookie的名称
  5.    opt.LoginPath = "/main/login";   // 登入
  6.    opt.LogoutPath = "/main/logout"; // 登出
  7.    opt.ReturnUrlParameter = "url";  // 回调 URL 字段名
  8. });
复制代码
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 的值。
  1. <form action="<strong>login?url=@ViewData["</strong><strong>url"]</strong>" method="post"><table>
  2.         <tr>
  3.             <td>用户名:</td>
  4.             <td><input type="text" name="username" required></td>
  5.         </tr>
  6.         <tr>
  7.             <td>密码:</td>
  8.             <td>
  9.                 <input type="password" name="passwd" required>
  10.             </td>
  11.         </tr>
  12.     </table>
  13.     <button type="submit">登录</button>
  14. </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 控制器代码中咱们本技艺动跳转。
  1.     [HttpPost]
  2.     public async Task<IActionResult> Login(string username, string passwd, string? url, [FromServices]HashHelper hasher, [FromServices]MyAppContext dbContext)
  3.     {
  4.      <form action="<strong>/login?url=@ViewData["</strong><strong>url"]</strong>" method="post">
  5.     ……
  6. </form>
  7.         // return NoContent();
  8.         // 手动跳转
  9.         return <strong>Redirect(url ?? "/main/default"</strong><strong>)</strong>;
  10.     }
复制代码
 
如果你要求必须保证框架能自动跳转,能做到吗?能,只要把检验用户名/暗码和登录的处理逻辑放到 HTTP 管道上就行了,只有让用户输入这一步才走 MVC 控制器里返回视图。
  1. 主页/验证失败 -> /login?url=... -> <strong>登录界面</strong> -> /login?url=... -> ...
复制代码
也就是在呈现登录界面这一步绕一个圈,跳 MVC 里面去,最终又跳回 HTTP 管道。
现在,咱们把 Cookie 验证的配置改一下(主要改登入/登出路径)。
  1. builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(opt =>
  2. {
  3.    opt.Cookie.Path = "/";     // Cookie 范围路径
  4.    opt.Cookie.Name = "guang_tou_qiang"; // Cookie的名称
  5.    opt.LoginPath = "<strong>/login</strong>";   // 登入
  6.    opt.LogoutPath = "<strong>/logout</strong>"; // 登出
  7.    opt.ReturnUrlParameter = "url";  // 回调 URL 字段名
  8. });
复制代码
咱们在 HTTP 管道上直接实现登入/登出。
  1. app.MapGet("/login", (<strong>string? url</strong>) =>
  2. {
  3.    // 这里是由框架自动转过来的
  4.    Console.WriteLine("-- 进入登录入口");
  5.    // 我们啥也不处理,目的只是呈现登录界面
  6.    // 记得把 url 传过去,这个不能丢了,不然等会玩不下去了
  7.    return Results.Redirect("/main/login?url=" + url);
  8. });
  9. app.MapPost("/login", async (
  10.    [FromForm] string username,
  11.    [FromForm] string passwd,
  12.    [FromQuery] <strong>string? url</strong>) =>
  13. {
  14.    HashHelper hasher = app.Services.GetRequiredService<HashHelper>();
  15.    string hashedPwd = hasher.MakeHash(passwd);
  16.    string lowcasename = username.ToLower();
  17.    UserInfo? user;
  18.    using (var scop =<strong> app.Services.CreateScope()</strong>)
  19.    {
  20.       MyAppContext context = scop.ServiceProvider.GetRequiredService<MyAppContext>();
  21.       user = await context.Users.FirstOrDefaultAsync(u => u.UName.ToLower() == lowcasename && u.Passwd == hashedPwd);
  22.    }
  23.    // null 就是没查到用户
  24.    if (user == null)
  25.    {
  26.       // 回去继续登录
  27.       return Results.Redirect("/login?url=" + url);
  28.    }
  29.    // 准备登录
  30.    Claim c = new(ClaimTypes.NameIdentifier, user.UName);
  31.    ClaimsIdentity idt = new([c], CookieAuthenticationDefaults.AuthenticationScheme);
  32.    ClaimsPrincipal princ = new(idt);
  33.    // 执行登录
  34.    return Results.SignIn(princ);
  35. });
  36. app.MapGet("/logout", async context =>
  37. {
  38.    // 注销很简单
  39.    await context.SignOutAsync();
  40. });
复制代码
/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。
  1. <form action="<strong>/login?url=@ViewData["</strong><strong>url"]</strong>" method="post">
  2.     ……
  3. </form>
复制代码
一切看着很顺利,但一运行就……

这是没有配置好 anti-forgery 导致的,很好解决。三步走:
1、在配置服务容器阶段,调用  AddAntiforgery 方法。
  1. builder.Services.AddAntiforgery();
复制代码
2、在 HTTP 管道中,在验证和授权后面调用 UseAntiforgery 方法。为什么要在授权之后呢,因为不想在那时候就天生 anti-token。
  1. app.UseRouting();
  2. app.UseAuthentication();
  3. app.UseAuthorization();
  4. <strong>app.UseAntiforgery();</strong>
复制代码
3、在视图中,form 里面要加这一行。
  1.     [b]@Html.AntiForgeryToken()[/b]<form action="<strong>/login?url=@ViewData["</strong><strong>url"]</strong>" method="post">
  2.     ……
  3. </form>
复制代码
就是天生 token 用的,最终 HTML 会酿成这样。

同时还带个 Cookie。

在登录页面输入用户名和暗码,就行了。
这样你会发现,现在框架能自动跳转了。
其实,呈现登录页面这一步不是说一定要绕进 MVC 里,而是因为老周前面演示时用的 MVC,其实你用啥方法就行的。比如用字符串直接拼个 HTML 文档返回也可以的。或者在 wwwroot 目录中放到静态 HTML 页也可以。总之方法多多,任君选择。只要到达让用户输入信息的目的就行。
最后,咱们去瞧一下源代码,看看框架怎么跳转的。Cookie 验证的处理类是 CookieAuthenticationHandler。当登录操作触发时,由 IAuthenticationService 服务调用 HandleSignInAsync 方法。在处理完登录事宜后会有这么一段。
  1. var shouldHonorReturnUrlParameter = Options.LoginPath.HasValue && OriginalPath == Options.LoginPath;
  2. await ApplyHeaders(shouldRedirect: true, shouldHonorReturnUrlParameter, signedInContext.Properties);
复制代码
ApplyHeaders 通过设置标头的方式完成跳转。
  1. private async Task ApplyHeaders(bool shouldRedirect, bool shouldHonorReturnUrlParameter, AuthenticationProperties properties)
  2. {
  3.     Response.Headers.CacheControl = HeaderValueNoCacheNoStore;
  4.     Response.Headers.Pragma = HeaderValueNoCache;
  5.     Response.Headers.Expires = HeaderValueEpocDate;
  6.     if (shouldRedirect && Response.StatusCode == 200)
  7.     {
  8.         // set redirect uri in order:
  9.         // 1. properties.RedirectUri
  10.         // 2. query parameter ReturnUrlParameter (if the request path matches the path set in the options)
  11.         //
  12.         // Absolute uri is not allowed if it is from query string as query string is not
  13.         // a trusted source.
  14.         var redirectUri = properties.RedirectUri;
  15.         if (shouldHonorReturnUrlParameter && string.IsNullOrEmpty(redirectUri))
  16.         {
  17.             <strong>redirectUri </strong><strong>=</strong><strong> Request.Query[Options.ReturnUrlParameter]</strong>;
  18.             if (string.IsNullOrEmpty(redirectUri) || !IsHostRelative(redirectUri))
  19.             {
  20.                 redirectUri = null;
  21.             }
  22.         }
  23.         if (redirectUri != null)
  24.         {
  25.             await<strong> Events.RedirectToReturnUrl</strong>(
  26.                 new RedirectContext<CookieAuthenticationOptions>(Context, Scheme, Options, properties, redirectUri));
  27.         }
  28.     }
  29. }
复制代码
通过 Request.Query 获取了回调 URL,从这里看到,跳转的条件有二:
1、有设置的 ReturnUrl 字段;
2、当前哀求的路径要和 LoginPath 配置的相同。这就是我们上面为什么 GET 和 POST 都要 /login 的原因,这是为了保证路径稳定。
再到 CookieAuthenticationEvents 类中,看看真正的跳转代码。
  1. public Func<RedirectContext<CookieAuthenticationOptions>, Task> OnRedirectToReturnUrl { get; set; } = context =>
  2. {
  3.      if (IsAjaxRequest(context.Request))
  4.      {
  5.          context.<strong>Response.Headers.Location </strong><strong>=</strong><strong> context.RedirectUri</strong>;
  6.      }
  7.      else
  8.      {
  9.          context.<strong>Response.Redirect(context.RedirectUri)</strong>;
  10.      }
  11.      return Task.CompletedTask;
  12. };
复制代码
这下看懂了吧。
 
好了,今天老周就水到这里了。要开饭了。
 
回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表