惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

J
Java Code Geeks
美团技术团队
Recent Announcements
Recent Announcements
B
Blog
GbyAI
GbyAI
雷峰网
雷峰网
博客园_首页
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
T
Tailwind CSS Blog
M
MIT News - Artificial intelligence
V
V2EX
人人都是产品经理
人人都是产品经理
爱范儿
爱范儿
L
LangChain Blog
Microsoft Security Blog
Microsoft Security Blog
宝玉的分享
宝玉的分享
A
About on SuperTechFans
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
U
Unit 42
Hugging Face - Blog
Hugging Face - Blog
F
Fortinet All Blogs
N
Netflix TechBlog - Medium
Last Week in AI
Last Week in AI
aimingoo的专栏
aimingoo的专栏

博客园 - 东邪独孤

【ASP.NET Core】在 Windows Forms 中运行 Web 服务器 【EF Core】级联删除行为 【EF Core】继承策略——TPC 【EF Core】继承策略——TPT 【EF Core】继承策略——TPH 【EF Core】使用自定义的值比较器 【EF Core】值转换器 【EF Core】直接更新数据 【EF Core】实体追踪——Entry中记录的数据 【EF Core】实体状态与变更追踪 【EF Core】将一个实体映射到多个表的正确方法 【EF Core】“Code First”方案下以编程方式生成迁移 【EF Core】“DB First”方案下用编程方式生成数据库模型代码 【EF Core】三种方法记录生成的 SQL 语句 【EF Core】未定义实体类的数据库模型 【EF Core】“多对多”关系与跳跃导航 【EF Core】FromExpression 方法有什么用? 【EF Core】通过 DbContext 选项扩展框架 【EF Core】框架底层的数据库连接管理 【EF Core】再谈普通实体关系与 Owned 关系的区别 【EF Core】实体类的依赖注入 【EF Core】优化后的模型 【EF Core】使用外部 Model 【EF Core】聊聊“复合”属性 【EF Core】为 DatabaseFacade 扩展“创建”与“删除”数据表功能 【EF Core】带主键实体与无主键实体 【EF Core】框架是如何识别实体类的属性和主键的
【ASP.NET Core】Cookie 验证中回调用 URL 自动跳转的问题
东邪独孤 · 2026-08-22 · via 博客园 - 东邪独孤

一个多月没更新了。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=....。

咱们运行一下。首次进入主页面失败,这时框架能自动跳转到登录页。

image

数据库默认创建的用户名和密码都是 admin。点击登录后,你会发现没有转到主页。是没有成功吗?不,登录是成功的,但没有自动跳转。通过开发人员工具抓取的小笼包可以看到:服务器返回的是 No Content。

image

查看一下,有 Cookie 返回的。

image

还记得吗?咱们的 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>

一切看着很顺利,但一运行就……

image

这是没有配置好 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 会变成这样。

image

同时还带个 Cookie。

image

在登录页面输入用户名和密码,就行了。

这样你会发现,现在框架能自动跳转了。

其实,呈现登录页面这一步不是说一定要绕进 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;
 };

这下看懂了吧。

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