新文章 网摘 文章 随笔 日记

第 1 部分:在 ASP.NET Core 中处理授权的更好方法(干货)

最后更新时间: 2021年11月14日 |创建: 2018/12/14

我的一个客户要求我帮助构建一个相当大的Web应用程序,他们的身份验证(即检查谁在登录)和授权(即登录用户可以访问哪些页面/功能)非常复杂。根据我的经验,我知道使用ASP.NET的基于角色的方法不会削减它,我发现新的 ASP.NET 基于Core策略的方法非常聪明,但它需要我编写大量(无聊的)策略。

最后,我为我的客户创建了一个解决方案,本文介绍了授权部分 - 我称之为角色到权限(当您阅读本文时,该名称将更有意义)。我还构建了一个 ASP.NET Core 应用程序的示例,其中包含支持本文的所有新代码。此示例应用程序与我的客户端系统完全不同,因为我利用 ASP.NET Core 内置标识系统(客户端的系统需要 OAuth2)。示例应用程序包含大约 60 行,我从原始实现中复制(经客户许可)以创建您可以使用的开源版本(MIT 许可证)。

本文是关于 ASP.NET 核心授权的系列文章之一

注意:您可以克隆GitHub存储库并在本地运行 - 它使用内存中的数据库,因此它可以在任何地方运行。该应用程序是使用 Core 2.1 ASP.NET 编写的。

更新:新库使应用“更好的方法”变得更加容易

这种方法非常流行,以至于我创建了一个名为AuthPermissions.AspNetCore的库,它实现了本文中介绍的角色功能以及第4部分文章中描述的多租户系统,请查看名为“最后,一个改进 ASP.NET Core中角色授权的库”和详细文档(包括视频链接)以获取更多信息。

注意:新的身份验证权限.AspNetCore 库遵循本文和本系列其他文章中的方法,但确实添加了许多改进和功能,例如支持 JWT 令牌、支持 Azure AD 等。

这是一篇很长的文章,所以这里链接到主要部分:

断续器;–总结

  • ASP.NET 基于角色的授权系统适用于具有简单授权规则的系统,但它有一些限制,例如,如果更改授权规则,则必须重新发布代码。
  • 具有“经理”或“外部购买者”等名称的角色对用户(人工或外部服务)有意义,因为它们定义了用户应该能够执行的操作的“用例”。
  • 但是,当角色应用于 ASP.NET 核心操作或 Razor 页面时,角色效果不佳。在这里,您需要一个更细粒度的解决方案,其名称类似于“CanRequestHoliday”,“可以批准假日” - 我称之为权限
  • 解决方案是将用户的角色映射到一组权限,并将这些权限存储在用户的声明中。
  • 然后,我使用 ASP.NET Core 新的基于策略的授权系统来检查用户的权限声明是否包含放置在他们想要访问的操作/页面上的权限。
  • 本文提供了一个 ASP.NET Core 应用程序的开源示例

设置场景 – 了解不同的应用程序安全需求

如果您了解 ASP.NET 的授权和身份验证功能,则可以跳过本节。

有数十亿个Web应用程序,对您可以执行的操作的控制范围为“任何人都可以做任何事情”,例如Google搜索,以及一些需要密钥,生物识别等的军事系统。当您需要证明您是系统的有效用户时,例如通过登录,这称为身份验证。登录后,您可以执行的操作将由所谓的授权控制。

授权分为两部分:

  1. 我可以访问哪些数据?例如,您可以看到您的个人信息,但不能看到其他人的个人信息。
  2. 您可以使用哪些功能?例如,您是否可以更改可以看到的书的标题?

注意:本文介绍了一种管理第二部分的方法,您可以使用哪些功能。

ASP.NET MVC 和现在 ASP.NET Core 都有各种系统来帮助进行授权和身份验证。有些系统只需要一个简单的授权 - 我可以想象一个非常简单的电子商务系统可以逃脱:a)没有登录 - 浏览,b)登录 - 购买,以及c)管理员 - 添加/删除待售商品。这可以使用 ASP.NET 基于角色的身份验证来完成。

但许多企业对企业(B 对 B)系统具有更复杂的授权需求。例如,考虑一个人力资源(HR)系统,人们可以申请休假,他们的经理必须批准这些请求 - 内部有很多事情要做,以确保只有正确的用户才能使用这些功能。

像我的例子HR系统这样的系统通常最终会有很多复杂的授权规则。我的经验是,基于角色的身份验证 ASP.NET 在实现这种类型的系统时开始出现问题,这就是我创建角色到权限代码的原因。

可以从角色到权限方法中受益的另一种类型的应用程序是订阅系统,用户可以访问的功能取决于他们支付的订阅。角色到权限方法可以控制用户根据他们购买的订阅可以访问的功能。

角色授权:它是什么,它的局限性是什么?

角色授权在 ASP.NET MVC 应用程序中已经存在了很多年,我已经在许多应用程序中使用了它。下面是一个 ASP.NET Core 控制器的示例,该控制器只能由具有“员工”角色或“经理”角色的登录用户访问。

1
2
3
4
5
[Authorize(Roles = "Staff,Manager")]
public ActionResult Index()
{
    return View(MyData);
}

这适用于具有相当简单且定义明确的角色的应用程序,例如用户/管理员或员工/经理/管理员,那么角色是一个不错的选择。但以下是我发现的一些问题:

  1. 如果你有很多角色,你最终会得到很长的授权属性,例如[授权(角色=“员工,人力资源经理,业务经理,开发人员经理,管理员,超级管理员”)]。
  2. 因为授权是一个属性,所以字符串必须是一个常量,例如,你不能有$“{角色常量.StaffRole}”。这意味着如果事情发生了变化,你正在编辑字符串,你可以很容易地拼错一些东西。
  3. 对我来说,最大的问题是您的授权规则已硬编码到您的代码中。因此,如果要更改可以访问某个操作的人员,则必须编辑相应的 Authorize 属性并重新部署应用程序。

我以前使用基于角色的授权的应用程序的经验是,在开发或优化应用程序时,我经常需要返回并编辑授权角色部分。一段时间以来,我一直在寻找一种更好的方法,我的客户的要求促使我找到了比角色授权更好的东西。

角色到权限系统的体系结构

1. 角色、权限和模块简介

事实证明,用户拥有角色的想法没有错。用户(人工或外部服务)通常可以通过其功能或部门来描述,例如“开发人员”或“HR”,或者可以使用“HolidayAdmin”之类的副角色。将角色视为“用户的用例”。

注意:在示例应用程序中,我有“员工”,“经理”和“管理员”的角色。

但问题是角色并不适合控制器中的操作。每个控制器操作在角色中都扮演着一小部分角色,或者为了扭转局面,角色由角色允许您访问的一系列控制器操作组成。我决定将每个操作的授权功能称为“权限”,并使用枚举来定义它们。许可枚举成员可能被称为“可以阅读假日”,“可以请求假日”,“可以批准假日”等。

注意:在示例应用程序中,我对“颜色读取”、“颜色创建”、“颜色更新”和“删除颜色”的颜色控制器具有权限。

现在我们有了权限,我们可以提供另一个功能来控制对可选功能的访问,例如,用户仅基于其对服务的订阅而具有的功能。处理功能的方法有很多种,但是通过将可选功能合并到权限中,可以更轻松地进行设置和控制。

注意:在示例应用程序中,我有名为“功能1”和“功能2”的权限,它们映射到具有相同名称的模块。

2. 如何实现

在定义了我的术语之后,我将向您概述该过程。它由两部分组成:登录阶段和对网站的正常访问。登录阶段是最复杂的,背景中有很多魔术。它的基本工作是将用户的角色转换为权限,并将其添加到用户的信息中。

我已经设置了我的示例应用程序,将用户的声明存储在一个cookie中,该cookie与每个HTTP请求一起读入并转换为声明原则,可以通过名为“用户”的HttpContext属性在 ASP.NET 核心中访问它。

这是该登录阶段的图表。它可能还没有多大意义,但我在本文的其余部分描述了每个部分。此图旨在为您提供概述。

更新:在文章“第 3 部分:处理 ASP.NET 核心授权的更好方法 - 六个月后”中,我展示了一种通过角色到权限数据库处理角色的方法。这使得该方法对于其他身份验证方法(如社交媒体,AzureAd等)更有用。

第二部分更简单,涵盖了每次登录用户访问受保护的控制器操作时发生的情况,基本上,我有一个基于策略的授权,其中包含动态规则,用于检查当前用户是否具有执行 ASP.NET 操作/剃刀页面所需的权限。

注意:如果您想查看实际代码,请不要忘记示例应用程序

现在,我将分阶段构建角色到权限,并解释每个部分的作用。

为什么我对权限使用枚举

使用角色的缺点之一是它使用了字符串,我有点阅读障碍。这意味着我可以错误地键入/拼写内容而不会注意到。因此,我想要一些智能会提示我的东西,如果我仍然输入错误,那将是一个编译错误。但事实证明,还有其他几个原因使得使用Enum获得权限成为一个好主意。让我解释一下。

在大型应用程序中,可能有数百个权限。这会导致两个问题:

  1. 如果我使用饼干授权,则饼干的最大大小为4096字节。如果我有数百个长字符串,我可能会开始填满Cookie,并且我想要一些空间来容纳其他事情,例如我的数据授权。如果我可以将枚举权限存储为一系列整数,它将比一系列字符串小得多。
  2. 其次,我想帮助需要构建从角色到权限的映射的管理员。如果他们需要滚动浏览数百个权限名称,则可能很难确定需要哪些权限名称。事实证明,Enum成员可以具有属性,因此我可以添加额外的信息来帮助管理员。

所以,这是我的权限枚举代码的一部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public enum Permissions
{
    //Here is an example of very detailed control over something
    [Display(GroupName = "Color", Name = "Read", Description = "Can read colors")]
    ColorRead = 0x10,
    [Display(GroupName = "Color", Name = "Create", Description = "Can create a color entry")]
    ColorCreate = 0x11,
    [Display(GroupName = "Color", Name = "Update", Description = "Can update a color entry")]
    ColorUpdate = 0x12,
    [Display(GroupName = "Color", Name = "Delete", Description = "Can delete a color entry")]
    ColorDelete = 0x13,
 
    [Display(GroupName = "UserAdmin", Name = "Read users", Description = "Can list User")]
    UserRead = 0x20,
    //This is an example of grouping multiple actions under one permission
    [Display(GroupName = "UserAdmin", Name = "Alter user", Description = "Can do anything to the User")]
    UserChange = 0x21,
 
    [Obsolete]
    [Display(GroupName = "Old", Name = "Not used", Description = "example of old permission"
    OldPermissionNotUsed = 0x40,
//... other code left out

需要注意的是:

  • 我显示两种类型的权限。
    • 前四行(第 4 行到第 11 行)是细粒度权限,几乎每个操作一个。
    • 接下来的两个(第13行到第17行)更通用,例如,我有一个特定的“UserRead”,但是一个名为“UserChange”的权限,它允许创建,更新,删除,锁定,更改密码等。
  • 5号线、7号线等请注意,我为每个枚举指定了一个特定的编号。如果您正在运行24/7应用程序,新版本无缝替换旧版本,则权限麻木不得更改,否则用户的声明是错误的。这就是为什么我给每个枚举一个特定的数字。
  • 第 19 行。我还支持“已过时”属性,该属性会停止“权限”出现在列表中。有很多关于重用数字的可怕故事,但会产生意想不到的后果。(另外,如果您尝试使用标记为“过时”的内容,则会收到警告)。
  • 4号线等我为每个权限枚举添加一个显示属性。这提供了有用的信息,我可以显示许多有用的信息来帮助正在构建角色的人。
  • 4、6、8、10 号线。我“分组”在同一位置使用的权限。这使管理员更容易找到他们想要的内容。我也用十六进制编号,这给了我一个组中16个可能的权限(我尝试了10个,你可以超过它,所以16更好)。

下面是通过“用户->列出权限”导航下拉列表列出的示例应用程序中的一些权限的列表。

以及生成该输出的代码(链接到整个内容的权限显示类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public static List<PermissionDisplay> GetPermissionsToDisplay(Type enumType)
{
    var result = new List<PermissionDisplay>();
    foreach (var permissionName in Enum.GetNames(enumType))
    {
        var member = enumType.GetMember(permissionName);
        //This allows you to obsolete a permission and it won't be shown as a
        //possible option, but is still there so you won't reuse the number
        var obsoleteAttribute = member[0].GetCustomAttribute<ObsoleteAttribute>();
        if (obsoleteAttribute != null)
            continue;
        //If there is no DisplayAttribute then the Enum is not used
        var displayAttribute = member[0].GetCustomAttribute<DisplayAttribute>();
        if (displayAttribute == null)
            continue;
 
        //Gets the optional PaidForModule that a permission can be linked to
        var moduleAttribute = member[0].GetCustomAttribute<PermissionLinkedToModuleAttribute>();
 
        var permission = (Permissions)Enum.Parse(enumType, permissionName, false);
 
        result.Add(new PermissionDisplay(displayAttribute.GroupName, displayAttribute.Name,
                displayAttribute.Description, permission, moduleAttribute?.PaidForModule.ToString()));
    }
 
    return result;
}

如何处理可选/付费功能?

我的客户提供了一个企业对企业应用程序,并计划添加客户可以订阅的新功能。处理此问题的一种方法是创建不同的角色,如“经理”、“经理与功能1”、“经理与功能2”,或添加必须手动应用于用户的单独功能角色。这有效,但管理起来非常可怕,人为错误可能会导致问题。我的首选系统是将链接到付费功能的权限标记为根据用户的订阅过滤它们。

使用枚举很容易将权限标记为链接到模块 - 我只是添加另一个属性。下面是链接到模块的权限示例(请参阅第 5 行)。

1
2
3
4
5
6
7
8
9
10
11
public enum Permissions
{
    //… other Permissions removed for clarity
 
    [LinkedToModule(PaidForModules.Feature1)]
    [Display(GroupName = "Features", Name = "Feature1", Description = "Can access feature1")]
    Feature1Access = 0x30,
    [LinkedToModule(PaidForModules.Feature2)]
    [Display(GroupName = "Features", Name = "Feature2", Description = "Can access feature2")]
    Feature2Access = 0x31
}

付费模块再次由枚举表示,但有一个标记了 [Flags] 属性,因为用户可以拥有他们已订阅的多个模块。这是我的付费模块枚举代码

1
2
3
4
5
6
7
8
[Flags]
public enum PaidForModules : long
{
    None = 0,
    Feature1 = 1,
    Feature2 = 2,
    Feature3 = 4
}

注意 我在Enum中添加了“:long”,这在我的系统中提供了多达64个不同的模块。

发生的事情是,链接到用户尚未订阅的模块的权限在确定用户应具有的权限时被过滤掉 - 稍后我将演示如何。这使得角色的设置更加简单,因为您构建每个角色都具有对该角色有意义的所有权限,包括映射到付费模块的功能。然后,在登录时,系统将删除当前用户无权访问的任何权限。这对管理员来说更简单,对应用程序来说更安全。

如何将角色转换为权限声明?

我客户的系统我们使用0Auth2身份验证,但对于此示例,我使用 ASP.NET 核心身份Role来保存用户拥有的角色。这意味着我可以使用所有 ASP.NET 核心内置标识代码来设置用户和角色。但是,如何将用户的角色转换为权限声明?

更新:后续文章中添加了两项改进:

  1. 在名为“第 3 部分:处理 ASP.NET 核心授权的更好方法 – 六个月后”的文章中,我展示了一种使用 UserClaimsPrincipalFactory 将角色转换为权限声明的更简单方法 - 请参阅此部分
  2. 权限访问控件 2 存储库中的第二个代码版本中,我改进/简化了“OnValidatePrincipal”调用的代码,并为我添加的各种额外功能提供了版本。请参阅“授权设置”文件夹以获取代码。

同样,有几种方法可以做到这一点,但最终我利用了授权Cookie中的一个名为“OnValidate原则”的事件(这里是指向示例应用程序启动类中的行的链接)。这调用下面的代码,但要注意它非常复杂,所以这里总结了它所经历的步骤:

  1. 如果声明已具有“权限”声明类型,则不会快速返回任何操作。
  2. 然后,我们从角色声明中获取用户具有的角色
  3. 我需要访问我的数据库部分。我不能使用依赖注入,所以我使用额外的AuthDbContextOptions,这是我可以在启动时提供的单例。
  4. 然后,我获取所有角色的所有权限,并使用 Distinct 删除不必要的重复项。
  5. 然后,我筛选出链接到用户无权访问的模块的任何权限。
  6. 然后,我添加了一个权限声明,其中包含允许用户使用的所有权限,但以十六进制数字的形式打包在单个字符串中,这样它就不会占用太多空间(我使用了十六进制格式,因为它使调试更容易)。
  7. 最后,我必须创建一个新的声明原则,并告诉 ASP.NET 核心替换当前的声明原则,并设置所有重要的上下文。应该更新为真,这会更新Cookie,否则在每个HTTP请求上都使用这种复杂(慢)的方法!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public async Task ValidateAsync(CookieValidatePrincipalContext context)
{
    if (context.Principal.Claims.Any(x =>
        x.Type == PermissionConstants.PackedPermissionClaimType))
        return;
 
    //No permissions in the claims so we need to add it
    //This is only happens once after the user has logged in
    var claims = new List<Claim>();
    foreach (var claim in context.Principal.Claims)
    {
        claims.Add(claim);
    }
 
    var usersRoles = context.Principal.Claims
        .Where(x => x.Type == ClaimTypes.Role)
        .Select(x => x.Value)
        .ToList();
    //I can't inject the DbContext here because that is dynamic,
    //but I can pass in the database options because that is a
    //From that I can create a valid dbContext to access the database
    using (var dbContext = new ExtraAuthorizeDbContext(_extraAuthDbContextOptions))
    {
        //This gets all the permissions, with a distinct to remove duplicates
        var permissionsForUser = await dbContext.RolesToPermissions
            .Where(x => usersRoles.Contains(x.RoleName))
            .SelectMany(x => x.PermissionsInRole)
            .Distinct()
            .ToListAsync();
 
        //we get the modules this user is allows to see
        var userModules =
            dbContext.ModulesForUsers
                .Find(context.Principal.Claims
                     .SingleOrDefault(x => x.Type == ClaimTypes.Name).Value)
                ?.AllowedPaidForModules ?? PaidForModules.None;
        //Now we remove permissions that are linked to modules that the user has no access to
        var filteredPermissions =
            from permission in permissionsForUser
            let moduleAttr = typeof(Permissions).GetMember(permission.ToString())[0]
                .GetCustomAttribute<LinkedToModuleAttribute>()
            where moduleAttr == null || userModules.HasFlag(moduleAttr.PaidForModule)
            select permission;
 
          //Now add it to the claim
          claims.Add(new Claim(PermissionConstants.PackedPermissionClaimType,
              filteredPermissions.PackPermissionsIntoString()));    }
 
    var identity = new ClaimsIdentity(claims, "Cookie");
    var newPrincipal = new ClaimsPrincipal(identity);
 
    context.ReplacePrincipal(newPrincipal);
    context.ShouldRenew = true;
}

如何将我的权限转换为基于策略的授权?

好的,我现在可以通过用户声明访问权限,但是我如何将其转换为 ASP.NET Core可用于授权的内容。这就是 .NET 开发人员和朋友杰里·佩尔瑟帮助我的地方。

当我开始这个项目时,我给Jerrie Pelser发了电子邮件,他经营着 ASP.NET 周刊(很棒的时事通讯!请注册),因为我知道Jerrie是身份验证和授权方面的专家。他向我指出了一些架构问题,我还发现他自己的文章“使用 ASP.NET 核心动态创建授权策略”非常有用。Jerris 的文章向我展示了如何动态构建策略,这正是我所需要的。

我不打算在这里重复Jerrie文章(使用上面的链接),但我会向您展示我的权限处理程序,该处理程序在策略中使用,用于检查当前用户的权限声明是否存在,并包含操作/ Razor页面上的权限。它使用一个名为“允许此权限”的扩展方法进行检查。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class PermissionHandler :
    AuthorizationHandler<PermissionRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        PermissionRequirement requirement)
    {
        var permissionsClaim = context.User.Claims
            .SingleOrDefault(c =>
                 c.Type == PermissionConstants.PackedPermissionClaimType);
        // If user does not have the scope claim, get out of here
        if (permissionsClaim == null)
            return Task.CompletedTask;
 
        if (permissionsClaim.Value
            .ThisPermissionIsAllowed(requirement.PermissionName))
            context.Succeed(requirement);
 
        return Task.CompletedTask;
    }
}

还有另外两个类涉及使基于策略的动态授权工作。以下是它们的链接:

政策是由字符串定义的,但正如我所说,我讨厌字符串,因为我可能会犯错误。因此,我创建了这个非常简单的 HasPermission 属性,它允许我应用授权属性,但使用权限枚举

1
2
3
4
5
6
7
[AttributeUsage(AttributeTargets.Method
    | AttributeTargets.Class, Inherited = false)]
public class HasPermissionAttribute : AuthorizeAttribute
{
    public HasPermissionAttribute(Permissions permission)
       : base(permission.ToString()) { }
}

这很简单,但这意味着当我添加权限时,我会变得智能。

将它们放在一起

因此,我们在代码中具有权限,我们可以使用HasPermission属性将它们应用于我们希望通过授权保护的每个操作。下面是从我的示例应用程序中的颜色控制器执行的操作。

1
2
3
4
5
[HasPermission(Permissions.ColorRead)]
public ActionResult Index()
{
    return View(MyData);
}

我们还需要向应用程序使用的任何数据库添加两个表。两个 EF 核心实体类是:

应用程序启动并运行后,管理员类型的用户必须:

  1. 使用 ASP.NET 核心标识代码创建一些角色,例如“员工”,“经理”等。
  2. 为每个角色创建匹配的 RoleTo 权限,并指定映射到每个角色的权限。

然后,对于每个新用户,管理员(或一些自动订阅代码)必须:

  • 创建用户(如果是仅限邀请类型的应用程序)
  • 为该新用户添加正确的角色和模块用户。

一旦完成,我展示的所有代码都将接管。用户登录,获取权限,他们可以访问的内容由 ASP.NET Core基于策略的身份验证进行管理。

我没有在其他地方报道的事情

有几件事我没有详细介绍,但这里有一些指向这些项目的链接:

  • 启动。有关注册内容的重要部分显示在指向 Startup 类的此链接中的突出显示行中。(注意:我使用 ASP.NET Core 2.1 构建了应用程序,但我知道标识部分在 2.2 中发生了变化,因此您可能必须更新我在 Startup 类中放入的代码,以获得较新版本的 ASP.NET Core)。
  • 您根本不需要使用 ASP.NET 核心身份系统 - 我说过客户端的版本使用外部身份验证系统。您只需创建一个“角色到用户”表,即可为每个用户分配角色。
  • 我没有介绍我如何打包/解压缩权限。您可以在权限包中找到用于执行此操作的扩展方法。
  • 您可能需要在剃须刀页面中检查显示/隐藏链接的权限。我在“权限扩展”类中创建了一个简单的方法,并在 _Layout.cshtml Razor 页面中使用它。

结论

好吧,这是一篇很长的文章,最后做得很好。我已经描述了我构建的身份验证,该身份验证处理复杂的身份验证规则,同时(相对)易于通过管理员人员进行理解和管理。当然,如果您有数百个权限,那么设置初始角色到权限并不难,但是管理员有很多信息可以帮助他们。

对我来说,角色到权限方法解决了我在使用 ASP.NET MVC 角色构建的旧系统中遇到的许多问题。我必须编写更多的代码,但它使a)更容易更改授权规则和b)帮助管理员管理具有许多角色/权限的应用程序。我希望它能帮助你,让你想到更好的方法来为你的项目构建更好的身份验证系统。

更新:请参阅权限访问控件 2 存储库和新文章中的新代码。

新的代码和文章增加了更多的功能,特别是使代码更容易复制到你自己的应用程序中。请参阅名为“将”更好的 ASP.NET 核心授权“代码添加到应用中”的文章,该文章提供了有关如何将本文和后续文章中的功能添加到你自己的代码中的分步教程。

进一步阅读

如果你对 ASP.NET 或实体框架感兴趣,别忘了注册Jerrie Pelser的,ASP.NET 每周通讯。这是我收到的最重要的时事通讯

 

posted @ 2022-10-13 13:18  岭南春  阅读(74)  评论(0)    收藏  举报