Angular 安全性 - 第 3 部分






4.79/5 (5投票s)
本系列关于保护您的 Angular 应用程序的第三个也是最后一个部分
在我之前的两篇博文中,您创建了一组 Angular 类来支持用户身份验证和授权。您还构建了一个 .NET Core Web API 项目,通过调用 Web API 方法来验证用户。创建了一个授权对象,其中包含您希望在应用程序中保护的每个项目的单独属性。在这篇博文中,您将构建一个声明数组,并消除对您希望保护的每个项目的单个属性的使用。对于大型应用程序来说,使用声明数组是一种更灵活的方法。
启动应用程序
要跟随本文,请下载附带的 ZIP 文件。从 ZIP 文件中提取示例后,有一个 VS Code 工作区文件,您可以使用它来加载此应用程序中的两个项目。如果您双击此工作区文件,将加载如图 1 所示的解决方案。有两个项目;PTC
是 Angular 应用程序。PtcApi
是 ASP.NET Core Web API 项目。
在上一篇文章中,使用了 mock 数据来表示产品、类别、用户和授权。本文中的启动应用程序已删除所有 mock 数据,现在连接到 SQL Server 数据库以获取这些数据。Angular 应用程序没有进行任何更改。
PTC 数据库
ZIP 文件中包含一个名为 PTC 的 SQL Server Express 数据库。打开位于 \PtcApi\Model 文件夹中的 PtcDbContext.cs 文件。将连接字符串常量中的路径更改为指向您从此 ZIP 文件安装文件的文件夹。如果您没有安装 SQL Server Express,您可以使用 \SqlData 文件夹中找到的 PTC.sql 文件在您自己的 SQL Server 实例中创建相应的表。
安全表概述
PTC 数据库除了 product
和 category
表外,还有两个表;User
和 UserClaim
(图 2)。这些表类似于 Microsoft 的 ASP.NET Identity System 中的表。我简化了结构,只是为了保持此示例应用程序的代码量较少。
用户表
用户表包含特定用户的信息,例如用户名、密码、名字、姓氏等。为了本文的需要,我将此表简化为仅包含一个唯一 ID (UserId
)、登录名 (UserName
) 和用户密码。
请注意,我在本应用程序的示例中使用了明文密码。在生产应用程序中,此密码应始终加密或哈希。
用户声明表
在 UserClaim
表中,有四个字段;ClaimId
、UserId
、ClaimType
和 ClaimValue
。ClaimId
是声明记录的唯一标识符。UserId
是指向 User
表的外键关系。ClaimType
字段中的值用于您的 Angular 应用程序,以确定用户是否具有执行某些操作的适当授权。ClaimValue
中的值可以是您想要的任何值,我在此博文中使用了“true
”或“false
”值。
如果您不希望向用户授予特定声明,则无需为特定用户和声明类型输入记录。例如,授权对象中的 CanAddProduct
属性可以为用户“bjones
”省略,或者您可以为 ClaimValue
字段输入“false
”值。稍后在本博文中,您将了解此过程的工作原理。
修改 Web API 项目
在上一篇博文中,AppUserAuth
类为每个声明包含一个布尔属性。您使用 Angular *ngIf
指令测试了这个布尔值,以从 DOM 中移除 HTML 元素,从而消除了用户执行某些操作的能力。既然您现在有了用户和声明的数据库表,您需要修改 Web API 应用程序中的一些内容以支持声明数组。
当您拥有多个声明时,为每个声明使用单独的属性会使您的 AppUserAuth
类变得非常大且难以管理。这也意味着当您希望添加另一个声明时,您必须向 SQL Server 添加新记录,在 Web API 的 AppUserAuth
类中添加一个属性,在 Angular 应用程序的 AppUserAuth
类中添加一个属性,并在任何您希望保护的 DOM 元素上添加一个指令。
使用基于数组的方法,您只需要向 SQL Server 添加一条记录,并在您希望保护的 DOM 元素上添加一个指令。这意味着您需要修改的代码更少,需要进行的测试更少,因此,部署新的安全更改所需的时间会缩短。
修改 AppUserAuth 类
打开 \PtcApi\Model 文件夹中的 AppUserAuth.cs 文件,并删除上篇博文中创建的每个单独的声明属性。添加一个通用的 AppUserClaim
对象列表,属性名称为 Claims
。您需要添加一个 using
语句来导入 System.Collections.Generic
命名空间。您还应该在类的构造函数中将 Claims
属性初始化为空列表。进行这些更改后,AppUserAuth
类应如下所示
using System.Collections.Generic;
namespace PtcApi.Model
{
public class AppUserAuth
{
public AppUserAuth()
{
UserName = "Not authorized";
BearerToken = string.Empty;
Claims = new List<AppUserClaim>();
}
public string UserName { get; set; }
public string BearerToken { get; set; }
public bool IsAuthenticated { get; set; }
public List<AppUserClaim> Claims { get; set; }
}
}
修改安全管理器
\PtcApi\Model 文件夹中的 SecurityManager.cs 文件负责与 Entity Framework 交互,从您的 SQL Server 表中检索安全信息。打开 SecurityManager.cs 文件,并删除 BuildUserAuthObject()
方法中用于通过反射设置属性名称的 for 循环。在您进行了这些更改后,BuildUserAuthObject()
方法应如下代码段所示。
protected AppUserAuth BuildUserAuthObject(AppUser authUser)
{
AppUserAuth ret = new AppUserAuth();
// Set User Properties
ret.UserName = authUser.UserName;
ret.IsAuthenticated = true;
ret.BearerToken = BuildJwtToken(ret);
// Get all claims for this user
ret.Claims = GetUserClaims(authUser);
return ret;
}
您还需要找到 BuildJWTToken()
方法并删除正在设置的各个属性。在 Visual Studio Code 中,这些属性的设置代码行会显示语法错误,因为这些属性已不再存在。
修改 Angular 应用程序
与 Angular 应用程序的常见情况一样,如果您在 Web API 项目中进行了更改,则还需要在 Angular 应用程序中进行更改。现在让我们进行这些更改。
添加 AppUserClaim 类
由于您现在将从 Web API 返回 AppUserClaim
对象数组,因此您需要在 Angular 应用程序中创建一个名为 AppUserClaim
的类。右键单击 \security 文件夹,然后添加一个名为 app-user-claim.ts 的新文件。在此文件中添加以下代码。
export class AppUserClaim {
claimId: string = "";
userId: string = "";
claimType: string = "";
claimValue: string = "";
}
修改 AppUserAuth 类
打开 app-user-auth.ts 文件,删除所有单独的布尔声明属性。就像您从 Web API 类中删除它们一样,您也需要从 Angular 应用程序中删除它们。接下来,将 AppUserClaim
对象数组添加到此类中,如以下代码片段所示。
import { AppUserClaim } from "./app-user-claim";
export class AppUserAuth {
userName: string = "";
bearerToken: string = "";
isAuthenticated: boolean = false;
claims: AppUserClaim[] = [];
}
修改安全服务
打开位于 \src\app\security 文件夹中的 security.service.ts 文件。找到 resetSecurityObject()
方法,并删除各个布尔属性。添加一行代码将声明数组重置为空声明数组,如以下代码片段所示。
resetSecurityObject(): void {
this.securityObject.userName = "";
this.securityObject.bearerToken = "";
this.securityObject.isAuthenticated = false;
this.securityObject.claims = [];
localStorage.removeItem("bearerToken");
}
声明验证
现在您已经在服务器端和客户端都进行了代码更改,Web API 调用返回了带有用户声明数组的授权类。您现在需要能够检查用户是否具有执行操作的有效声明(授权),或者从 DOM 中移除 HTML 元素。您最终将创建一个自定义结构指令,可以在菜单上使用,如下所示。
<a routerLink="/products" *hasClaim="'canAccessProducts'">
Products
</a>
要做到这一点,您需要一个方法,该方法接受传递给 *hasClaim
指令的 string
,并验证该声明是否存在于从 Web API 下载的数组中。此方法还应能够检查使用此声明类型设置的声明值。请记住,SQL Server UserClaim
表中的 ClaimValue
字段的类型是 string
。您可以将任何值放入此字段。这意味着您还希望能够传递一个值来检查,如下所示。
<a routerLink="/products" *hasClaim="'canAccessProducts:false'">
Products
</a>
注意冒号的使用,然后是您要为此声明检查的值。包含声明类型、冒号和声明值的此 string
被传递给 hasClaim
指令。您将要创建的新方法应该能够解析此 string
并确定声明类型和值(如果有)。将此新方法添加到 SecurityService
类中,并将其命名为 isClaimValid()
,如下所示
private isClaimValid(claimType: string) : boolean {
let ret: boolean = false;
let auth: AppUserAuth = null;
let claimValue: string = '';
// Retrieve security object
auth = this.securityObject;
if (auth) {
// See if the claim type has a value
// *hasClaim="'claimType:value'"
if (claimType.indexOf(":") >= 0) {
let words: string[] = claimType.split(":");
claimType = words[0].toLowerCase();
claimValue = words[1];
}
else {
claimType = claimType.toLowerCase();
// Either get the claim value, or assume 'true'
claimValue = claimValue ? claimValue : "true";
}
// Attempt to find the claim
ret = auth.claims.find(
c => c.claimType.toLowerCase() == claimType
&& c.claimValue == claimValue) != null;
}
return ret;
}
isClaimValid
在 SecurityService
类中声明为 private
方法,因此您需要一个 public
方法来调用它。创建一个 hasClaim()
方法,如下所示
hasClaim(claimType: any) : boolean {
return this.isClaimValid(claimType);
}
创建结构指令来检查声明
要添加您自己的结构指令 hasClaim
,请在 VS Code 中打开一个终端窗口,然后输入以下 Angular CLI 命令。此命令将一个新的指令添加到 \security 文件夹中。
ng g d security/hasClaim --flat
打开新创建的 has-claim.directive.ts 文件,并修改 import
语句以添加更多类。
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
修改 @Directive
函数中的 selector
属性以读取 hasClaim
。
@Directive({ selector: '[hasClaim]' })
修改构造函数以注入 TemplateRef
、ViewContainerRef
和 SecurityService
。
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef,
private securityService: SecurityService) { }
就像将属性从一个元素绑定到另一个元素一样,您需要使用 Input
类来告诉 Angular 将指令中等号右侧的数据传递到指令中的 hasClaim
属性。将以下代码添加到构造函数下方
@Input() set hasClaim(claimType: any) {
if (this.securityService.hasClaim(claimType)) {
// Add template to DOM
this.viewContainer.createEmbeddedView(this.templateRef);
} else {
// Remove template from DOM
this.viewContainer.clear();
}
}
@Input()
装饰器告诉 Angular 将等号右侧的值传递给名为 hasClaim()
的 'set
' 属性。hasClaim
属性的参数名为 claimType
。将此参数传递给您在 SecurityService
类中创建的新 hasClaim()
方法。如果此方法返回 true
,表示声明存在,则将使用 createEmbeddedView()
方法在屏幕上显示应用了此指令的 UI 元素。如果声明不存在,则通过调用 viewContainer
上的 clear()
方法来移除 UI 元素。
修改授权守卫
仅仅因为您移除了菜单项并不意味着用户无法直接导航到菜单指向的路径。在第一篇博文中,您创建了一个 Angular 守卫,用于阻止用户直接导航到他们没有相应声明的路由。既然您现在使用数组而不是布尔属性来验证声明,因此需要修改您创建的授权守卫。打开 auth.guard.ts 文件,找到 canActivate()
方法,并将 if
语句更改为如下代码所示
if (this.securityService.securityObject.isAuthenticated
&& this.securityService.hasClaim(claimName)) {
return true;
}
保护菜单
您差不多可以尝试所有更改了。如果您查看 app.component.html 文件中的 **Products and Categories** 菜单项,您会发现您使用 *ngIf
指令来仅显示菜单项(如果 securityObject
属性不为 null
,并且布尔属性设置为 true
值)。
<li>
<a routerLink="/products"
*ngIf="securityObject.canAccessProducts">Products</a>
</li>
<li>
<a routerLink="/categories"
*ngIf="securityObject.canAccessCategories">Categories</a>
</li>
由于 *ngIf
指令使用双向数据绑定绑定到 securityObject
,因此如果此属性发生更改,菜单将被重新绘制。然而,您刚刚创建的结构指令会将一个 string
传递给一个执行代码的 'set
' 属性,因此没有绑定到实际属性。这意味着如果像前面所示那样添加 *hasClaim
结构指令,菜单不会被重新绘制。另一个问题是,您不能在单个 HTML 元素上使用两个指令。不用担心,您可以将两个 anchor 标签包装在 ng-container
中,并在 anchor 标签上使用 *ngIf
指令来绑定到 securityObject
的 isAuthenticated
属性。当用户登录后,此属性会发生变化,因此您可以控制菜单的可见性。然后,您可以使用 *hasClaim
来根据用户声明是否有效来控制可见性。打开 app.component.html 文件,并按照以下代码修改两个菜单项
<li>
<ng-container *ngIf="securityObject.isAuthenticated">
<a routerLink="/products"
*hasClaim="'canAccessProducts'">Products</a>
</ng-container>
</li>
<li>
<ng-container *ngIf="securityObject.isAuthenticated">
<a routerLink="/categories"
*hasClaim="'canAccessCategories'">Categories</a>
</ng-container>
</li>
试一试
您终于可以尝试所有更改,并验证您的菜单项是根据用户是否已通过身份验证以及他们在 UserClaim
表中是否具有适当的声明来打开和关闭的。保存 VS Code 中的所有更改。启动 Web API 和 Angular 项目并在浏览器中查看。**Products and Categories** 菜单应不可见。点击 Login 菜单,然后使用用户名 "psheriff
" 和密码 'P@ssw0rd
' 登录。现在您应该会看到两个菜单出现。
打开 User
表,找到 "bjones
" 用户,并记住此用户的 UserId
。打开 UserClaim
表,找到 "bjones
" 的 CanAccessCategories
记录,并将值从 true
更改为 false
。返回浏览器,注销 "psheriff
",然后以 "bjones
" 身份重新登录。您应该会看到 **Products** 菜单,但 **Categories** 菜单不出现。返回 UserClaim
表,并将 "bjones
" 的 CanAccessCategories
声明值字段改回 true
。
保护新增产品按钮
将 *hasClaim
指令添加到 product-list.component.html 文件中的 "Add New Product" 按钮。删除绑定到旧 canAddProduct
属性的 *ngIf
指令,并使用新的结构指令,如以下代码所示
<button class="btn btn-primary" (click)="addProduct()"
*hasClaim="'canAddProduct'">
Add New Product
</button>
不要忘记在双引号内添加单引号。如果您忘记了,Angular 将尝试绑定到组件中一个名为 canAddProduct
的不存在的属性。
试一试
保存所有更改,然后返回浏览器。以 "psheriff
" 身份登录。点击 **Products** 菜单,您应该会看到 "Add New Product" 按钮出现。以 "psheriff
" 身份注销,然后以 "bjones
" 身份登录。现在应该看不到 "Add New Product" 按钮了。
请记住,您添加了在声明名称后指定声明值的功能。在声明类型后添加冒号,然后为 **Add New Product** 按钮添加 'false
',如以下代码所示
<button class="btn btn-primary" (click)="addProduct()"
*hasClaim="'canAddProduct:false'">
Add New Product
</button>
如果您现在以 "psheriff
" 身份登录,则 Add New Product
按钮将消失。以 "bjones
" 身份登录,它应该出现。在测试完此功能后,从声明中移除 ":false
"。
添加多个声明
有时,您的安全要求是您需要使用多个声明来保护 UI 元素。例如,您希望显示一个按钮供拥有一种声明类型的用户使用,而另一个声明类型的用户则使用另一个按钮。要实现这一点,您需要将声明数组传递给 *hasClaim
指令,如下所示
*hasClaim="['canAddProduct', 'canAccessCategories']"
您需要修改 SecurityService
类中的 hasClaim()
方法,以检查传入的是单个 string
值还是数组。打开 security.service.ts 文件,并将 hasClaim()
方法修改为如下所示
hasClaim(claimType: any) : boolean {
let ret: boolean = false;
// See if an array of values was passed in.
if (typeof claimType === "string") {
ret = this.isClaimValid(claimType);
}
else {
let claims: string[] = claimType;
if (claims) {
for (let index = 0; index < claims.length; index++) {
ret = this.isClaimValid(claims[index]);
// If one is successful, then let them in
if (ret) {
break;
}
}
}
}
return ret;
}
既然您现在有两个不同的数据类型可以传递给 hasClaim()
方法,请使用 typeof
运算符检查 claimType
参数是否为 string
。如果是,则调用 isClaimValid()
方法并传递两个参数。如果不是 string
,则假定它是一个数组。将 claimType
参数转换为名为 claims
的 string
数组。验证它是一个数组,然后循环遍历数组中的每个元素,并将每个元素传递给 isClaimValid()
方法。即使有一个声明匹配,也会从此方法返回 true
,以便显示 UI 元素。
保护其他按钮
打开 product-list.component.html 文件,并将 **Add New Product** 按钮修改为使用数组,如以下代码片段所示
*hasClaim="['canAddProduct', 'canAccessCategories']"
试一试
保存应用程序中的所有更改,然后返回浏览器。以 "bjones
" 身份登录,因为他拥有 canAccessCategories
声明,所以他可以看到 "Add New Product" 按钮。
摘要
在关于 Angular 安全性的最后一篇博文中,您学习了如何构建更适合企业级应用程序的安全系统。而不是为每个需要保护的项目使用单独的属性,您从 Web API 调用返回声明数组。您构建了一个自定义结构指令,您可以向该指令传递一个或多个声明。此指令负责根据用户的声明集来包含或移除 HTML 元素。如果您希望添加或删除声明,这种方法使您的代码更灵活,并需要更少的代码更改。