【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
{
    privateHashHelper 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);
        // 执行登录,其实执行这个后会跳转的,但实际上没有跳转
        awaitHttpContext.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)
        {
            awaitEvents.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;
 };

这下看懂了吧。

好了,今天老周就水到这里了。要开饭了。