首页 > 学院 > 开发设计 > 正文

在中间层.NET应用程序中通过授权管理器使用基于角色的安全

2019-11-14 16:51:48
字体:
来源:转载
供稿:网友

基于角色的安全是从 Windows NT 的第一个版本开始在 Windows 平台上发展而来的。使用角色,操作系统可以通过检查称为 BUILTIN/Administrators 的组的安全上下文做出一些决定,例如,进程是否有特权。操作系统基于该逻辑角色做出决定(例如,是否让您安装服务或设备驱动程序)。在安装操作系统时,您可以通过将相应的用户添加到 Administrators 组来选择谁将承担该角色。

Microsoft 事务服务 (MTS) 和 COM+ 试图使基于角色的安全成为一种让应用程序开发人员感觉愉快的功能,并且为 COM 服务器提供了一个简单的、基于角色的授权基础结构。目标是为多层服务器应用程序启用受信任的子系统模型,其中应用程序服务器受到后端资源的信任以批准请求。通过及早执行授权,可以避免向后端服务器委托客户端凭据的需要。委托充满了从潜在的安全漏洞到可伸缩性问题等大量问题。

如果您一直在寻找中间层中的通用授权解决方案,那么您的搜索可以告一段落了。

授权管理器简介

授权管理器(通常称为 AzMan)是 Windows 的一种通用的、基于角色的新安全体系结构。AzMan 与 COM+ 无关,因此它可以用在任何需要基于角色的授权的应用程序中,包括 asp.net Web 应用程序或 Web 服务、基于 .NET Remoting 的客户-服务器系统等等。在撰写本文时,授权管理器仅在 Windows Server?2003、Windows 2000 的 Service Pack 4 中提供,并且预计作为 Windows xp 未来的 Service Pack 发布。

AzMan 有两个部分:运行库和管理 UI。运行库由 AZROLES.DLL 提供,它公开了一组供那些利用基于角色的安全的应用程序使用的 COM 接口。管理 UI 是一个 MMC 管理单元,您可以通过运行 AZMAN.MSC 或者通过向您选择的 MMC 控制台中添加授权管理器管理单元对其进行试验。(请注意,管理 UI 与较旧的平台不兼容,因此您将需要使用运行 Windows Server 2003 的计算机来管理 AzMan。)[编辑更新 — 1/9/2004Windows Server 2003 管理工具包使您可以在运行 Windows XP PRofessional 的计算机上安装 Windows Server 2003 管理工具。]

当运行图 1 中显示的 AzMan 管理工具时,您将注意到的第一件事情是它比 COM+ 提供的功能复杂得多。您不再只具有角色和角色分配操作。您现在具有可以分配给任务的低级别操作,而任务随后又可以分配给角色。任务可以包含其他任务,而角色可以包含其他角色。这一分层方法有助于改进目前复杂的应用程序中所需的好像不受限制的角色集。

图 1 管理管理器

以下是任务和角色的创建方式。应用程序设计器定义了被视为安全敏感的整个低级别操作集。然后,该设计器定义了一组映射到这些操作的任务。任务被设计为可由业务分析师理解,因此一个给定任务总是由一个或多个低级别操作组成。如果用户被授予执行某个任务的权限,则他或她就被授予了执行该任务中所有操作的权限。作为一个示例,一个名为“提交采购定单”的任务可能由下列操作组成:获得下一个 PO 编号、将 PO 排队和发送通知。当然,您可以总是简单地将每个任务映射到单个操作,以使事情尽可能保持简单,但是如果您需要的话,则可以利用分隔任务和操作所具有的灵活性。

在定义任务和操作之后,就可以开始编码了,并且无论何时需要执行敏感操作,都可以包含对 AzMan 运行库的调用。该调用是 IAzClientContext.accessCheck,稍后我将说明一个有关它的用法的示例。

在部署时,应用程序安装程序会设置一个 AzMan 存储区(作为 Active Directory? 的一部分,或者在简单的 xml 文件中),并安装基本的低级别操作和任务。管理员使用 AzMan 管理单元来查看该应用程序的任务的定义和说明。然后,管理员定义对于他/她的组织有意义的角色。就像任务被定义为一组低级别操作一样,角色通常被定义为一组任务。然后,管理员可以向这些角色分配用户和组。实际上,从这时开始,管理员在维护应用程序方面的主要工作将是随着人员加入或离开公司或者更改职衔,在角色中添加和移除用户。

迄今为止,我已经重点介绍了应用程序开发人员和管理员,但是实际上可能存在第三个帮助部署的人 — 业务逻辑脚本撰写者。每个任务都可以具有关联的脚本。这里的思路是:找到通常通过调用 IsCallerInRole 制定的动态安全决策,将它们移出应用程序代码并移至某个位置,以便管理员无需修改和重新编译代码就可以对应用程序的安全策略进行更改。

返回页首返回页首

示例应用程序:公司库

让我们观察一个示例。假设您要构建一个系统以管理公司图书馆。您需要能够管理书籍库存以及书籍的借阅和归还等等。您将使用 AzMan 实现基于角色的安全。

首先,您需要创建设计中出现的敏感操作列表:

阅读目录

占位(为自己或他人)

借书

还书

将书籍添加到库存

从库存中移除书籍

阅读顾客历史记录(为自己或他人)

请注意,有几个操作对只在运行时才会具有的信息敏感。例如,当试图阅读顾客的历史记录时,应用程序必须提供上下文信息,指示用户是在试图访问他/她自己的历史记录还是他人的历史记录。当建立原型时,可以使用 AzMan 管理单元将这些操作添加到简单的 XML 存储区。图 1 显示了这一添加过程。

如果您要尝试自己继续操作,请运行 AZMAN.MSC,并且通过“Action | Options”菜单确保您处于开发人员模式。在 XML 文件中创建一个新的存储区,然后在该存储区中创建一个新的应用程序。接下来,逐个地添加操作,并且赋予它们名称和代表操作编号的唯一整数。应用程序开发人员使用该编号来标识 AccessCheck 调用中的操作。请注意,在命名操作时,我已经用前缀“op.”对名称进行了编码。这只是为了在稍后创建任务和角色时避免命名冲突,因为这些名称全部来自相同的池并且必须是唯一的。

AzMan 管理单元在两种模式下操作:开发人员和管理员。在管理员模式下,您没有选择创建存储区或应用程序的自由,并且您不能改动应用程序代码所依赖的低级别操作定义。坦白地说,没有什么东西能够妨碍系统管理员进入开发人员模式并完成这些操作,但要点是在管理员模式下,UI 中选项的数量将减少,以简化管理员的工作并帮助他们避免错误。

图 2 AzMan 中的任务定义

下一个步骤是定义一组映射到这些低级别操作的任务,以便管理员能够轻松定义角色。因为您使操作列表保持简单,所以可以为每个操作定义单个任务。除非您绝对需要更高的复杂性,否则有一个保持事情简单的很好的理由,并且它必须与业务逻辑脚本有关 — 我稍后才会对其进行详细讨论。因此,现在让我们定义一系列基本上与我的操作相同的任务。图 2 显示了在 AzMan 中编辑任务定义时的工作模式。

返回页首返回页首

授权存储区

现在是改变您的身份并假装您是部署应用程序的管理员的时候了。在“Action | Options”菜单下切换到管理员模式,并且注意 GUI 是如何更改的:您不再能够编辑应用程序的低级别操作了。继续操作,并且按照图 3 中的定义为应用程序添加角色。

misauthorizationmanagerfig03

图 3 角色和任务

在这里,一种简化事情的方式是将角色嵌套。例如,可以根据 Patron 角色定义 Clerk,并且添加“借书”和“还书”任务,如图 4 中所示。请尝试用 COM+ 完成该工作。

图 4 嵌套角色

管理员需要做的最后一件事情是通过向这些抽象角色中添加真实的用户使它们变得具体。为此,请选择“Role Assignments”文件夹,并选择“Assign Roles”操作。请注意,角色只有在被添加到该文件夹之后,才会实际上变为活动角色。例如,IAzapplication.Roles 属性只返回已经添加到 Role Assignments 文件夹中的角色集合,而不是已经定义的所有角色。在分配角色之后,请立即右键单击它以添加 Windows 用户和组,或者添加您先前已经在 AzMan 存储区中定义的应用程序组。我将在本文的稍后部分对应用程序组进行介绍。

返回页首返回页首

AzMan 运行库接口

在定义了一些操作和任务之后,就可以开始在代码中实现访问检查了。您需要考虑的第一件事情是身份验证。如果您可以使用一些内置的 Windows 管线(就像 Web 服务器对 Kerberos 身份验证的支持一样),则可以获得客户端的令牌。这是到目前为止使用 AzMan 的最普通的方式,因为令牌包含用户所在的所有组,从而可以快速地将该用户映射到一组 AzMan 角色。另一方面,如果您要使用表单或 X.509 证书对用户进行身份验证,则您将不会具有令牌。相反,您将只具有该用户的名称。这并不意味着您无法使用 AzMan,甚至也不意味着您将必须编写更多的代码。但是这确实意味着将需要付出更大的代价,因为 AzMan 运行库将必须手动查找该用户的组。这会引起与域控制器之间的往返行程。

应用程序需要做的第一件事情是初始化 AzMan 运行库,使其指向它计划使用的存储区,并且向下探测到应用程序中存放授权设置的位置。现在,让我们使用简单的基于 XML 的存储区:

AzAuthorizationStore store = new AzAuthorizationStoreClass();store.Initialize(0, @"msxml://c:/MyStore.xml", null);IAzApplication app = store.OpenApplication(    "Corporate Library Application", null);

要生成该应用程序,项目需要引用 AzMan interop 程序集,它位于 %WINDIR%/Microsoft.NET/Framework/AuthMan 目录中。

既然要引导该应用程序,那么在对新的客户端进行身份验证时,您需要构建客户端的安全上下文的表示。该上下文在缓存用户的角色映射方面非常类似于令牌:

IAzClientContext ctx =  app.InitializeClientContextFromToken(htoken, null);

到哪里去获得客户端的令牌?唔,这取决于您要编写哪个类型的应用程序。例如,下面是某个 ASP.NET 页中的用于获得客户端令牌的 C# 代码。在该示例中,web.config 指定身份验证模式为“Windows”,并且已经将 IIS 配置为需要集成 Windows 身份验证:

WindowsIdentity id = (WindowsIdentity)User.Identity;IntPtr htoken = id.Token;

如果您只知道客户端的名称并且不能访问它们的令牌,请尝试弄清楚是否存在您可以获得的令牌,因为令牌是发现客户端的组的最权威方式。就像我先前提到的那样,它还是最快的方式。如果您肯定自己无法获得该客户端的令牌,则请使用下面的备用方法来根据形如“域/用户”的帐户名称来初始化上下文。该调用可能引起为发现域组而产生的往返行程,所以请做好需要花费一些时间来执行该操作的思想准备:

IAzClientContext ctx =  app.InitializeClientContextFromName(name, null);

在具有客户端上下文以后,就可以运行访问检查。该调用采用很多参数,但现在我将使事情保持简单。让我们假设您要实现一个向库存中添加书籍的函数。我将“向库存中添加书籍”定义为操作编号 5,因此代码可能如图 5 所示。

第一个参数 nameOfBook 是一个在启用运行库审核后使用的字符串。它标识您要对其执行操作的对象,因此您应当总是在这里提供一些有意义的信息。我已经使用了第二个参数 scopes 的默认值。稍后我将对该参数进行解释。第三个参数用于列出您要测试的一个或多个操作。结果是一个总是与操作数组大小相同的数组,带有与每个操作相对应的整数状态代码,以指示是授予还是拒绝访问权限。零表示访问检查成功,并且允许该上下文标识的客户端执行指定操作。其他任何值都表示失败(通常您将看到的是数字 5,即 ERROR_ACCESS_DENIED)。

AzMan 运行库接口不是强类型的。它对于自己的大多数参数都使用 VARIANT。这允许传统的使用脚本语言的 ASP 程序员使用 AzMan,但是意味着使用强类型语言(如 C# 和 Visual Basic .NET)的程序员可能在调用 AccessCheck 时犯下一些直到运行时才会发觉的错误。例如,操作数组的类型必须是 object[],而不是 int[],但是如果您传递 int[],则编译器不会抱怨,因为参数的实际类型是对象。当我一开始学习该 API 时,这个问题让我感到困惑,我花费了好长时间才弄清楚究竟应该如何编写代码才能避免出现由于参数类型不匹配而造成的运行时错误。我已经听到有传闻说最终将出现 AzMan 的托管接口,但是在此之前,您可能希望为 AccessCheck 编写强类型的包装以避免犯错误。下面的代码显示了一个示例,它还简化了调用该函数的最常见方式:

public class AzManWrapper {  public static int AccessCheck(             IClientContext ctx,             string objectName,             int Operation) {    object[] results = (object[])ctx.AccessCheck(         objectName, scopes, ops,         null, null, null, null, null);    return (int)results[0];  }}

通过包装,可以提供自己的 AccessCheck 重载,以便处理在需要其他可选参数时出现的更复杂情况。特别地,为该函数使用包装应当能够减少很多困难,并且降低应用程序代码的混乱程度。您甚至可以使用该包装将 AccessCheck 失败转换为异常,而不是与 ipermission.Demand 行一起返回状态代码。尽管如此,请不要过于执着以至于包装整套接口,因为该函数实际上是唯一一个难以调用的函数。

此刻,您可能想知道的一件事情是:在使用 AzMan 时是否可以不使用 Windows 帐户来表示用户。运行库在设计时考虑了该问题,尽管您需要为每个用户定义自定义安全标识符 (SID) — 这不是非常困难,并且您必须调用一个备用方法来初始化客户端安全上下文,即 InitializeClientContextFromStringSid。最大的障碍是您将无法使用 AzMan 管理单元来管理存储区(它们与 Windows 用户和组非常紧密地耦合在一起)。有关如何处理该问题的详细信息,请参阅本文开头引用的由 Dave McPherson 撰写的白皮书。

返回页首返回页首

存储区、应用程序和范围

我希望能回顾一些内容,稍微介绍一下授权存储区的结构。首先,您具有两个用于存储授权设置的选择:可以将整个存储区放到 XML 文件中,或者在 Active Directory 中承载它。我强烈建议对于生产应用程序使用 Active Directory,因为它比简单的 XML 文件提供了更多的功能,并且通常还会提供更好的性能。

如果您在实验室中具有可以利用的测试域,请尝试启动 AzMan 管理单元,在 Active Directory 中创建一个新的存储区,并赋予其如下所示的独特名称:“CN=MyStore, CN=Program Data, DC=MyDomain, DC=com”(将 MyDomain 替换为您自己的域)。要查看 AzMan 在 Active Directory 中创建了哪些对象,请使用诸如 ADSIEdit(这是一个可以从 Windows Server 2003 CD 中通过运行 SUPPORT/TOOLS/SUPTOOLS.MSI 安装的 MMC 管理单元)之类的工具。在该存储区中创建一个应用程序,并且启动新应用程序的属性页。您将注意到,该属性页上含有在使用简单 XML 文件时不存在的安全和审核选项卡。

在 Active Directory 存储区中,可以委托管理存储区中的单个应用程序的职责,并且可以在非常详细的级别审核对存储区进行的更改。使用 XML 文件,您会受到用 NTFS 权限和审核保护文件本身的限制。当前,只有在将存储区放置在目录中的时候,运行时审核才会受到支持,而审核对于大多数应用程序而言都是非常重要的。如果 Active Directory 可用,则我强烈敦促您将 AzMan 存储区放到它里面,因为它是用于承载 Windows 中安全策略的最佳处所。

单个存储区可以容纳多个应用程序。每个应用程序都具有它自己的用于操作、任务和角色的命名空间。如果您在多个应用程序中共享存储区,则请注意并发问题,因为存储区尚不支持并发编辑。如果您认为两个管理员有可能同时编辑单个存储区,则需要提供一些外部锁定来序列化对该存储区的访问;否则,该存储区可能会被破坏。AzMan 管理单元不提供该功能,等到它提供该功能时,您最好限制每个存储区的内容以避免并发编辑。最简单的解决方案是将每个存储区限制为容纳单个应用程序。

每个应用程序还可以定义多个范围,这是授权管理器的一种高级功能,并且我只向已经进一步学习 AzMan 并且绝对需要这一额外级别复杂性的人们推荐该功能。范围使您可以对应用程序的不同部分具有不同的授权设置。例如,在大型 Web 应用程序中,可以在特定子目录下面以某种方式分配角色;在不同的子目录下,可能以不同方式分配角色,或者可能定义一组完全不同的角色。

在这种情况下,范围可能很方便,因为它们可以共享低级别的操作定义,甚至可能共享某些任务、角色和应用程序组。遗憾的是,它们还可能使人感到困惑并且容易滥用。例如,在调用 AccessCheck 时,可以使用第二个参数指定要用于检查的范围。如果用户将提供范围名称(或许通过请求中的 URL),则您在将其传递给 AccessCheck 之前,最好能够确保该范围名称是规范化的;否则,聪明的用户可能通过以意外的方式对名称进行编码,欺骗您使用更脆弱的范围。如果您不熟悉这种类型的攻击,则应当阅读 Writing Secure Code, Second Edition (Microsoft Press, 2002) 中有关规范化的章节。要了解有关像范围这样的高级功能的详细信息,请参阅本文开头引用的由 Dave McPherson 撰写的白皮书。

返回页首返回页首

应用程序组

在 AzMan 中,有一种称为“应用程序组”的非常好的功能。在大型组织中,将新组添加到目录中以便应用程序使用可能非常辛苦。实际上,如果只有您的应用程序需要特定的组定义,则当疲惫不堪的域管理员拒绝向他/她的已经几乎无法管理的组列表中添加另外一个条目时,那么您可能会非常不走运。在这种情况下,应用程序组可以拯救您。在存储区、应用程序或范围级别,可以定义用户组并且向它们分配逻辑组名称。然后,您可以在角色分配中使用这些应用程序组。

AzMan 提供了两种类型的应用程序组:基本组和轻量级目录访问协议 (LDAP) 查询组。基本组非常类似于 Active Directory 中的组,但是有一点儿不同:可以定义包含成员和排除成员。例如,您可以定义一个名为 EveryoneButBob 的组,就像我在图 6 中所做的那样。优点是功能和方便性都得到了增加。缺点是确定该应用程序组中的成员身份所需的 CPU 周期数以及存储应用程序组中的成员身份列表所需的内存都增加了,因此请谨慎使用该功能。如果喜欢图 6 中显示的排除功能,您仍然可以通过使用域组作为应用程序组中的成员来获得该功能,从而减少 AzMan 需要在内存中保持的成员身份列表的大小。

misauthorizationmanagerfig06

图 6 允许除一人 (Bob) 之外的所有人

LDAP 查询组是 AzMan 的一项代价高昂但是很不错的功能。在这里,您可以使用 LDAP 查询语法定义在某种程度上类似的用户组。例如,以下是可以用来定义至少 21 岁的工程师集合的方式:

(&(age>=21)(memberOf= CN=eng,DC=foo,DC=com))

不管组的类型是基本组还是 LDAP 查询组,管理员都可以使用这些应用程序组作为向角色分配用户的备用方式。

返回页首返回页首

脚本

对于静态授权不能满足需要的情况,应用程序可以用变量和对象引用的形式向 AccessCheck 提供额外的上下文。这使脚本编写者可以使用 JScript? 或 VBScript 添加业务逻辑,而无需更改和重新编译应用程序。例如,可以向先前定义的阅读顾客历史记录任务提供额外的上下文(或许是用户所属的角色集),以及一个指示客户是访问他/她自己的历史记录还是他人的历史记录的布尔值。这将使您能够编写脚本,以允许经理查看任何顾客的历史记录,但是普通顾客只能限于查看他们自己的历史记录。可以为该任务编写如图 7 中所示的脚本。

要将该脚本与阅读顾客历史记录任务相关联,请调出该任务的定义页,浏览到包含该脚本的文件,将语言指定为 VBScript,然后按“Reload Rule into Store”按钮。

返回页首返回页首

支持授权脚本

要支持图 7中显示的脚本,您将需要向任何涉及阅读顾客历史记录任务的访问检查多传递几个参数。由于您已经将事情简单化,并且每个操作只定义了一个任务,因此这意味着每当您询问有关相应操作的情况时,都可以提供该上下文。以下是一个代码片段,它显示了如何为脚本编写者提供这些额外的上下文:

bool self = _userIsCheckingOwnHistory();object[] operations = { opReviewPatronHistory };object[] scopes     = { "" };object[] varNames   = { "roles", "self" };object[] varValues  = { ctx.GetRoles(""), self };
object[] results = (object[])ctx.AccessCheck(nameOfPatronHistory,                scopes, operations,                varNames, varValues,                null, null, null);

AzMan 对于 varNames 和 varValues 数组的顺序有一点儿挑剔。您必须像我一样按字母顺序对 varNames 进行排序。然后,varValues 数组必须为 varNames 中的每个命名参数提供相应值,这一点非常明显。如果您要显得更加独出心裁,则可以使用 AccessCheck 的最后三个参数传入命名的对象引用。这会将脚本编写者看到的对象模型扩展至默认的 AzBizRuleContext 对象之上。我将让您自己对该功能进行试验,以及解决您在决定支持脚本后可能遇到的一些难题。

您将可能注意到的有关脚本的第一件奇怪的事情是,它们是在任务和角色级别定义的,而不是在操作级别定义的。但是,应用程序程序员为单个操作执行访问检查以及提供上下文变量,因此脚本编写者如何知道将向给定任务提供哪些变量?很明显,应该由开发人员基于各个操作来仔细编写相关文档。一种策略是使事情真正简单化,并且无论要执行哪个操作,都总是传递相同的上下文变量。这当然可以为脚本编写者简化事情。在我的图书馆示例中,我小心地针对每个操作定义了一个任务,因此我可以为每个任务自定义上下文变量,但请记住,管理员在管理模式下运行时可以定义新任务。如果系统管理员要定义一个新任务以便将两个分别提供不同上下文变量的操作集成在一起,那么会发生什么情况呢?只需努力使事情简单化,并且仔细说明应该如何编写脚本以便避免出现这些令人讨厌的情况。

在编写脚本时需要警惕的另外一件事情是,脚本的结果被缓存在客户端上下文对象中以便提高效率。扔掉客户端上下文时,就同时扔掉了缓存。了解这一点是有好处的,因为某些脚本或许依赖于可能随时间而更改的外部数据。

请注意,设计脚本的目的是使它们所附加到的任务或角色具有某种资格。例如,假设 Alice 是角色 R1 和 R2 的成员。角色 R1 被直接授予执行操作 X 的权限。角色 R2 被授予相同的权限,但是该授权是通过脚本进行的。当 Alice 试图执行操作 X 时,AzMan 甚至不必运行角色 R2 中的脚本,因为操作 X 已经通过角色 R1 进行了静态授权。因而,脚本不能用来彻底拒绝权限。它们只能用来决定是否应当在某个访问检查中考虑特定的任务或角色。仅仅是因为脚本化的任务或角色由于其相应脚本计算为假而被忽略,并不意味着不存在仍会向要测试的操作授权的完全不同的任务或角色。Dave McPherson 撰写的白皮书提供了有关运行库如何实现访问检查的非常详细的说明。您可以在“Performance”一节中找到相关内容。对于所有对使用 AzMan 持认真态度的读者,我建议您仔细研究该节的内容。

返回页首返回页首

审核

访问检查的运行库审核是一项重要功能,并且仅当您使用 Active Directory 中的授权管理器存储区时才可用。如果要启用该功能,请右键单击应用程序,选择“Properties”,然后通过“Auditing”选项卡启用该功能。在运行时,用来运行服务器进程的帐户很重要:必须授予它“生成审核”特权。内置的帐户 Local Service、Network Service 和 SYSTEM 默认情况下都具有该特权。最后,请注意,服务器计算机必须启用对象访问权限审核功能,才能在安全事件日志中捕获这些审核。

您在启用审核之后将看到的现象是,对 AccessCheck 的每个调用都会产生一个审核条目,该条目中的对象名称是您作为 AccessCheck 的第一个参数传递的任何字符串。操作的易记名称将与客户端的名称一起显示在审核中。如果检查成功,则将记录成功访问;否则,您将在该日志中看到失败的访问。您是否能够同时看到成功和失败审核,取决于您通过 Windows 安全策略启用的对象访问审核的级别。

返回页首返回页首

小结

授权管理器是一种用于在 Windows 中构建安全系统的重要工具 。它对 MTS 和 COM+ 所普及的基于角色的安全的思想进行了扩展,它几乎可以由任何服务器应用程序而不仅仅是基于 COM 的服务器使用。授权管理器致力于帮助用户将安全逻辑集中到可以存储在 Active Directory 中的简洁安全策略中,并且提供了用于执行访问检查的简单 API。运行库审核还满足了长期需要。

授权管理器具有大量功能,用户在编写安全代码时的部分工作是弄清楚应用程序需要这些功能的哪个子集。最后,请记住,应该尽可能地将事情简单化,以避免打开安全漏洞。


发表评论 共有条评论
用户名: 密码:
验证码: 匿名发表