Angular 中的安全性 - 第 1 部分






4.82/5 (20投票s)
一种保护 Angular 2/6 应用程序的技术
在大多数业务应用程序中,您都需要根据登录用户的身份以及他们拥有的角色或权限来禁用或隐藏不同的功能,例如菜单项、按钮和其他 UI 元素。Angular 没有内置任何功能来帮助您实现这一点,因此您必须自己创建。Angular 应用程序的安全性需要考虑两个方面。首先,您必须开发客户端安全性,这是本文的主题。其次,您必须保护您的 Web API 调用,这将在另一篇文章中讨论。
安全方法
您可以使用许多不同的方法来保护 Angular 中的 HTML 元素。您可以创建一个简单的安全对象,其中包含您希望保护的应用程序中每个项目的一个属性,如图 1 所示。对于较小的 Angular 应用程序,此方法非常有效,因为您不必保护太多项目。对于较大的 Angular 应用程序,您需要采用基于声明 (claims-based) 和/或基于角色 (role-based) 的解决方案。本文将首先介绍具有一个属性用于保护每个项目的简单安全对象。这种方法可以帮助您在着手处理声明和角色之前,专注于如何实现安全性。本文使用的是模拟安全对象,因此您无需使用任何 Web API 调用。您将在下一篇文章中学习如何从 Web API 检索安全对象。
准备本文
为了演示如何在 Angular 应用程序中应用安全性,我创建了一个示例应用程序,其中包含显示产品、显示单个产品以及显示产品类别列表的几个页面。下载示例 ZIP 文件即可查看起始应用程序。
本文假设您已安装以下工具
- Visual Studio Code
- 节点
- Node 包管理器 (npm)
- Angular CLI
查看示例应用程序
在您下载的示例中,有两个菜单:“产品”和“类别”(图 2),您可以根据分配给用户的权限来选择禁用它们。在产品和类别列表页面(图 2)上,您可能希望根据权限禁用“添加”按钮。
在产品详细信息页面(图 3)上,“保存”按钮可能是您希望禁用的一个选项。也许有人可以查看产品详细信息,但无法修改数据。
最后,在“类别”页面(图 4)上,您可能希望隐藏“添加新类别”按钮。
创建用户安全类
要保护应用程序,您需要几个类来存储用户信息。首先,您需要一个用户类来存储用户名和密码,这些信息可以在登录页面输入并与某个数据源进行验证。在本文的第一部分,将使用一组模拟登录来进行验证。其次,您需要一个用户身份验证/授权类,其中包含您希望保护的应用程序中每个项目的属性。
接下来,您需要一个安全服务类来对用户进行身份验证,并设置用户身份验证/授权对象中的属性。属性值决定了登录用户的权限。您可以使用这些属性来启用或禁用页面上的不同菜单、按钮或其他 UI 元素。
用户类
创建用户类来存储用户在登录页面输入的用户名和密码。右键单击 `src\app` 文件夹,然后添加一个名为 `security` 的新文件夹。右键单击新的 `security` 文件夹,然后添加一个名为 `app-user.ts` 的文件。在 `AppUser` 类中添加两个属性,如下面的代码所示
export class AppUser {
userName: string = "";
password: string = "";
}
用户身份验证/授权类
现在是时候创建用于启用或禁用菜单和按钮的类了。右键单击 `security` 文件夹,然后添加一个名为 `app-user-auth.ts` 的新文件。此类包含 `username` 属性来存储已验证用户的用户名,`bearerToken` 用于与 Web API 调用交互,以及一个布尔属性 `isAuthenticated`,仅当用户已通过身份验证时才设置为 `true`。此类中其余的布尔属性是您希望保护的每个菜单和按钮特有的。
export class AppUserAuth {
userName: string = "";
bearerToken: string = "";
isAuthenticated: boolean = false;
canAccessProducts: boolean = false;
canAddProduct: boolean = false;
canSaveProduct: boolean = false;
canAccessCategories: boolean = false;
canAddCategory: boolean = false;
}
登录模拟
在本文的第一部分,您将在 Angular 应用程序中本地保留所有身份验证和授权。为此,请创建一个包含模拟登录的文件。右键单击 `security` 文件夹,然后添加一个名为 `login-mocks.ts` 的新文件。创建一个名为 `LOGIN_MOCKS` 的常量,它是一个 `AppUserAuth` 对象数组。创建几个文字对象来模拟您可能从后端服务器数据库检索到的两个不同的用户对象。
import { AppUserAuth } from "./app-user-auth";
export const LOGIN_MOCKS: AppUserAuth[] = [
{
userName: "PSheriff",
bearerToken: "abi393kdkd9393ikd",
isAuthenticated: true,
canAccessProducts: true,
canAddProduct: true,
canSaveProduct: true,
canAccessCategories: true,
canAddCategory: false
},
{
userName: "BJones",
bearerToken: "sd9f923k3kdmcjkhd",
isAuthenticated: true,
canAccessProducts: false,
canAddProduct: false,
canSaveProduct: false,
canAccessCategories: true,
canAddCategory: true
}
];
安全服务
Angular 完全围绕服务构建,因此创建一个安全服务类来对用户进行身份验证并返回具有适当属性设置的用户授权对象是有意义的。打开 VS Code 终端窗口,然后键入以下命令来生成一个名为 `SecurityService` 的服务类。添加 `-m` 选项将此服务注册到 `app.module` 文件中。
ng g s security/security --flat
将此新服务注册到 `AppModule` 类。打开 `app.module.ts` 文件并添加一个新的 `import`。
import { SecurityService } from './security/security.service';
将 `SecurityService` 类添加到 `providers` 属性中。
providers: [ProductService, CategoryService, SecurityService],
打开生成的 `security.service.ts` 文件并添加以下 `import` 语句。
import { Observable, of } from 'rxjs';
import { AppUserAuth } from './app-user-auth';
import { AppUser } from './app-user';
import { LOGIN_MOCKS } from './login-mocks';
在 `SecurityService` 类中添加一个属性来存储用户授权对象。将此对象初始化为 `AppUserAuth` 类的新实例,以便在内存中创建该对象。
securityObject: AppUserAuth = new AppUserAuth();
重置安全对象方法
创建此安全对象后,您永远不应将其重置为新对象,而应根据新登录的用户更改此对象的属性。添加一个方法将此安全对象重置为默认值。
resetSecurityObject(): void {
this.securityObject.userName = "";
this.securityObject.bearerToken = "";
this.securityObject.isAuthenticated = false;
this.securityObject.canAccessProducts = false;
this.securityObject.canAddProduct = false;
this.securityObject.canSaveProduct = false;
this.securityObject.canAccessCategories = false;
this.securityObject.canAddCategory = false;
localStorage.removeItem("bearerToken");
}
登录方法
很快,您将创建一个登录页面。该登录组件创建一个 `AppUser` 类的实例,并将属性绑定到该页面上的输入字段。一旦用户输入了他们的用户名和密码,这个 `AppUser` 类的实例将被传递到 `SecurityService` 类中的 `login()` 方法,以确定用户是否存在。如果用户存在,适当的属性将被填充到 `AppUserAuth` 对象中,并从 `login()` 方法返回。
login(entity: AppUser): Observable<AppUserAuth> {
// Initialize security object
this.resetSecurityObject();
// Use object assign to update the current object
// NOTE: Don't create a new AppUserAuth object
// because that destroys all references to object
Object.assign(this.securityObject,
LOGIN_MOCKS.find(user => user.userName.toLowerCase() ===
entity.userName.toLowerCase()));
if (this.securityObject.userName !== "") {
// Store into local storage
localStorage.setItem("bearerToken",
this.securityObject.bearerToken);
}
return of<AppUserAuth>(this.securityObject);
}
首先要做的就是重置安全对象,因此调用 `resetSecurityObject()`。接下来,您使用 `Object.assign()` 方法将 `securityObject` 属性中的所有属性替换为从 `LOGIN_MOCKS` 数组的 `find()` 方法返回的 `AppUserAuth` 对象中的属性。如果找到用户,则将 bearer token 存储到本地存储。这样做是为了在需要将此值传递给 Web API 时使用。本文将不涉及此内容,但未来的文章将涉及。
注销方法
如果您有 `login` 方法,您应该始终有一个 `logout()` 方法。`logout()` 方法将 `securityObject` 属性中的属性重置为空字段或 `false` 值。通过重置属性,任何绑定的属性(如菜单)都会重新读取这些属性,并可能将其状态从可见更改为不可见。
logout(): void {
this.resetSecurityObject();
}
登录页面
既然您有一个用于执行登录的安全服务,您就需要从用户那里获取用户名和密码。通过打开终端窗口并键入以下命令来创建一个登录页面:
ng g c security/login --flat
打开 `login.component.html` 文件并删除已生成的 HTML。在新登录页面上创建三个不同的行。
- 用户名/密码无效消息
- 用于显示 `securityObject` 属性实例的行
- 用于输入用户名和密码的面板
使用 Bootstrap 样式在此登录页面的每行上创建。第一个 `div` 包含一个 `*ngIf` 指令,仅当 `securityObject` 存在且 `isAuthenticated` 属性为 `false` 时才显示消息。第二个 `div` 元素包含对 `securityObject` 属性的绑定。此对象将传递给 json 管道,以将对象显示为 `label` 元素内的 `string`。最后一行是一个 Bootstrap 面板,您可以在其中放置相应的用户名和密码输入字段。
<div class="row">
<div class="col-xs-12">
<div class="alert alert-danger"
*ngIf="securityObject &&
!securityObject.isAuthenticated">
<p>Invalid User Name/Password.</p>
</div>
</div>
</div>
<!-- TEMPORARY CODE TO VIEW SECURITY OBJECT -->
<div class="row">
<div class="col-xs-12">
<label>{{securityObject | json}}</label>
</div>
</div>
<form>
<div class="row">
<div class="col-xs-12 col-sm-6">
<div class="panel panel-primary">
<div class="panel-heading">
<h3 class="panel-title">Log in</h3>
</div>
<div class="panel-body">
<div class="form-group">
<label for="userName">User Name</label>
<div class="input-group">
<input id="userName" name="userName"
class="form-control" required
[(ngModel)]="user.userName"
autofocus="autofocus" />
<span class="input-group-addon">
<i class="glyphicon glyphicon-envelope"></i>
</span>
</div>
</div>
<div class="form-group">
<label for="password">Password</label>
<div class="input-group">
<input id="password" name="password"
class="form-control" required
[(ngModel)]="user.password"
type="password" />
<span class="input-group-addon">
<i class="glyphicon glyphicon-lock"></i>
</span>
</div>
</div>
</div>
<div class="panel-footer">
<button class="btn btn-primary" (click)="login()">
Login
</button>
</div>
</div>
</div>
</div>
</form>
修改登录组件 TypeScript
从您输入的 HTML 中可以看出,`login.component.html` 文件需要两个属性来绑定到 HTML 元素:`user` 和 `securityObject`。打开 `login.component.ts` 文件并添加以下 `import` 语句,或者,如果您愿意,可以使用 VS Code 在添加每个类时为您插入它们。
import { AppUser } from './app-user';
import { AppUserAuth } from './app-user-auth';
import { SecurityService } from './security.service';
添加两个属性来存储用户和用户授权对象。
user: AppUser = new AppUser();
securityObject: AppUserAuth = null;
要设置 `securityObject`,您需要将 `SecurityService` 注入此类。修改构造函数以注入 `SecurityService`。
constructor(private securityService: SecurityService) { }
Bootstrap 面板底部的按钮会将 `click` 事件绑定到名为 `login()` 的方法。添加此 `login()` 方法,如下所示。此方法首先删除本地存储中可能存在的任何先前 bearer token。`SecurityService` 类上的 `login()` 方法将被订阅,返回的响应将被分配到此 `login` 组件中定义的 `securityObject` 属性。
login() {
this.securityService.login(this.user)
.subscribe(resp => {
this.securityObject = resp;
});
}
保护菜单
现在登录已正常工作并且您拥有有效的安全对象,您需要将此安全对象绑定到主菜单。菜单系统在 `app.component.html` 文件中创建,因此您需要打开该文件并添加一个新菜单项来调用登录页面。在用于创建其他菜单的 `` 结束标签下方添加以下 HTML。此 HTML 创建了一个右对齐的菜单,当用户尚未通过身份验证时显示“`Login`”字样。一旦通过身份验证,菜单将更改为“`Logout
<ul class="nav navbar-nav navbar-right">
<li>
<a routerLink="login"
*ngIf="!securityObject.isAuthenticated">
Login
</a>
<a href="#" (onclick)="logout()"
*ngIf="securityObject.isAuthenticated">
Logout {{securityObject.userName}}
</a>
</li>
</ul>
在您刚刚添加的代码正上方,修改其他两个菜单项,也使用安全对象来确定它们是否需要显示。使用 `*ngIf` 指令来检查您将添加到 `AppComponent` 类中的 `securityObject` 属性。您需要检查与每个菜单对应的适当布尔属性。
<li>
<a routerLink="/products"
*ngIf="securityObject.canAccessProducts">Products</a>
</li>
<li>
<a routerLink="/categories"
*ngIf="securityObject.canAccessCategories">Categories</a>
</li>
修改 AppComponent 类
从您输入的 HTML 中可以看出,您需要将 `securityObject` 添加到与应用程序关联的组件中。打开 `app.component.ts` 文件并添加 `securityObject` 属性。您确实需要将其初始化为 `null` 值,以便“无效用户名/密码”消息不会显示。
securityObject: AppUserAuth = null;
修改 `AppComponent` 类的构造函数以注入 `SecurityService` 并将 `securityObject` 属性分配给您刚刚在此处创建的属性。
constructor(private securityService: SecurityService) {
this.securityObject = securityService.securityObject;
}
向此类添加一个 `logout()` 方法,该方法调用安全服务类上的 `logout()` 方法。此方法绑定到您在 HTML 中添加的“`Logout`”菜单项上的 `click` 事件。
logout(): void {
this.securityService.logout();
}
添加登录路由
要访问 `login` 页面,您需要添加一个路由。打开 `app-routing.module.ts` 文件并添加一个新路由,如下所示
{
path: 'login',
component: LoginComponent
},
试一试
保存到目前为止的所有更改。使用 `npm start` 启动应用程序。单击“登录”菜单并使用“`psheriff`”登录,然后注意返回的安全对象中设置的属性。单击注销按钮,然后再次以“`bjones`”登录,并注意设置了不同的属性,并且“产品”链接消失了。这是因为 `LOGIN_MOCKS` 数组中 `BJones` 的 `canAccessProducts` 属性设置为 `false`。
打开 `logins-mock.ts` 文件,并将 `BJones` 对象 的 `canAccessProducts` 属性设置为 `true`。
{
userName: "BJones",
bearerToken: "sd9f923k3kdmcjkhd",
isAuthenticated: true,
canAccessProducts: true,
canAddProduct: false,
canSaveProduct: false,
canAccessCategories: true,
canAddCategory: true
}
您将尝试使用一些不同的授权属性,为了尝试它们,需要将此设置为 `true`。
保护按钮
除了添加到菜单的权限之外,您可能还希望将相同的权限应用于执行操作的按钮。例如,添加新产品或类别。或者,保存产品数据。对于本文,您只学习如何隐藏 HTML 元素。如果这些按钮后面有 Web API 方法调用,这些方法在此处并未受到保护。您需要使用某种令牌系统来保护 Web API。这些技术将在未来的文章中介绍。
让我们通过使用登录后创建的安全对象来保护“添加新产品”按钮。打开 `product-list.component.html` 文件,并将“添加新产品”按钮修改为如下所示
<button class="btn btn-primary"
(click)="addProduct()"
*ngIf="securityObject.canAddProduct">
Add New Product
</button>
打开 `product-list.component.ts` 文件并添加一个名为 `securityObject` 的属性,其类型为 `AppUserAuth`。您将希望将此相同属性添加到您希望使用安全性的任何组件中。
securityObject: AppUserAuth = null;
将您刚刚创建的 `securityObject` 属性分配给 `SecurityService` 类中的 `securityObject` 属性。在构造函数中注入服务并检索安全对象。您将希望在您希望保护的任何组件中使用相同的设计模式。
constructor(private productService: ProductService,
private router: Router,
private securityService: SecurityService) {
this.securityObject = securityService.securityObject;
}
打开 `product-detail.component.html` 文件,并修改“保存”按钮以使用 `securityObject` 上的 `canSaveProduct` 属性。如果 `canSaveProduct` 属性为 `false`,`*ngIf` 指令将导致按钮消失。
<button class="btn btn-primary"
(click)="saveData()"
*ngIf="securityObject.canSaveProduct">
Save
</button>
打开 `product-detail.component.ts` 文件,并添加 `securityObject` 属性,就像在 `product-list.component.ts` 文件中所做的那样。
securityObject: AppUserAuth = null;
修改构造函数以注入 `SecurityService` 并将此处的 `securityObject` 属性从 `SecurityService` 分配给您刚刚在此类中创建的 `securityObject` 属性。
constructor(private productService: ProductService,
private router: Router,
private securityService: SecurityService) {
this.securityObject = securityService.securityObject;
}
打开 `category-list.component.html` 文件,并将“添加新类别”按钮修改为使用 `securityObject` 上的 `canAddCategory` 属性。
<button class="btn btn-primary"
(onclick)="addCategory()"
*ngIf="securityObject.canAddCategory">
Add New Category
</button>
打开 `category-list.component.ts` 文件并添加 `securityObject` 属性。
securityObject: AppUserAuth = null;
修改构造函数以注入 `SecurityService` 并将此处的 `securityObject` 属性从 `SecurityService` 分配给您刚刚在此类中创建的 `securityObject` 属性。
constructor(private categoryService: CategoryService,
private securityService: SecurityService) {
this.securityObject = securityService.securityObject;
}
试一试
保存您所做的所有更改,然后转到浏览器。单击“登录”菜单并使用“`psheriff`”登录,然后注意返回的安全对象中设置的属性。
打开“产品”页面,您可以单击“添加新产品”按钮。如果您单击其中一个产品旁边的“编辑”按钮,您可以在产品详细信息页面上看到“保存”按钮。打开“类别”页面,并注意“添加新类别”按钮对您不可见。
单击注销按钮,然后以“`bjones`”身份登录。注意安全对象上设置了不同的属性。
打开“产品”页面,您会注意到“添加新产品”按钮不可见。如果您单击其中一个产品旁边的“编辑”按钮,您将无法在产品详细信息页面上看到“保存”按钮。打开“类别”页面,并注意“添加新类别”按钮可见。
使用守卫保护路由
尽管您可以控制菜单项的可见性,但仅仅因为您无法单击它们并不意味着您无法访问路由。您可以直接在浏览器地址栏中键入路由,即使 `canAccessProducts` 属性未设置为 `true`,您也可以访问产品页面。
要保护路由,您需要构建一个路由守卫。路由守卫是 Angular 中的一个特殊类,用于确定页面是否可以激活,甚至是否可以停用。让我们学习如何构建一个 `CanActivate` 守卫。打开终端并创建一个名为 `AuthGuard` 的新守卫。
ng g g security/auth --flat
将此新守卫注册到 `AppModule` 类。打开 `app.module.ts` 文件并添加一个新的 `import`。
import { AuthGuard } from './security/auth.guard';
将 `AuthGuard` 类添加到 `providers` 属性中。
providers: [ProductService, CategoryService, SecurityService, AuthGuard],
要保护路由,请打开 `app-routing.module.ts` 文件,并将 `canActivate` 属性添加到您希望保护的路径。您可以将一个或多个守卫传递给此属性。在这种情况下,将 `AuthGuard` 类添加到守卫数组中。对于每个路由,您还需要指定要检查的安全对象上的属性名称,该属性与此路由相关联。添加一个 `data` 属性并传递一个名为 `claimType` 的属性,并将该属性的值设置为与路由关联的属性的名称。此 `data` 属性将传递给 `canActivate` 属性中列出的每个守卫。
{
path: 'products',
component: ProductListComponent,
canActivate: [AuthGuard],
data: {claimType: 'canAccessProducts'}
},
{
path: 'productDetail/:id',
component: ProductDetailComponent,
canActivate: [AuthGuard],
data: {claimType: 'canAccessProducts'}
},
{
path: 'categories',
component: CategoryListComponent,
canActivate: [AuthGuard],
data: {claimType: 'canAccessCategories'}
},
授权守卫
让我们在 `AuthGuard` 中编写适当的代码来保护路由。由于您将需要访问通过 data 属性传递的属性,请打开 `auth-guard.ts` 文件并在构造函数中注入 `SecurityService`。
constructor(private securityService: SecurityService) { }
修改 `canActivate()` 方法以检索 `data` 属性中的 `claimType` 属性。删除“`return true`”语句,并用以下代码行替换它。
canActivate(
next: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean>
| Promise<boolean> | boolean {
// Get property name on security object to check
let claimType: string = next.data["claimType"];
return this.securityService.securityObject.isAuthenticated
&& this.securityService.securityObject[claimType];
}
使用 `next` 参数检索要在安全对象上检查的属性名称。此属性是 `ActivatedRouteSnapshot`,包含您之前创建的路由通过的 `data` 对象。从此守卫返回 `true` 值意味着用户有权导航到此路由。检查 `securityObject` 上的 `isAuthenticated` 属性是否为 `true` 值,并且 `data` 对象中传递的属性名是否也为 `true` 值。
试一试
保存您所做的所有更改,转到浏览器,然后直接在浏览器地址栏中键入 `https://:4200/products`。如果您未登录,则无法访问产品页面。您的守卫正在工作;但是,它最终会显示一个空白页面。最好重定向到登录页面。
重定向到登录页面
要重定向到登录页面,请修改 `AuthGuard` 类以在用户未授权访问当前路由时执行重定向。打开 `auth-guard.ts` 文件并在构造函数中注入 `Router` 服务。
constructor(private securityService: SecurityService,
private router: Router) { }
修改 `canActivate()` 方法。删除当前的 `return` 语句,并用以下代码行替换它。
if (this.securityService.securityObject.isAuthenticated
&& this.securityService.securityObject[claimType]) {
return true;
}
else {
this.router.navigate(['login'],
{ queryParams: { returnUrl: state.url } });
return false;
}
如果用户已通过身份验证并获得授权,则 `Guard` 返回 `true`,Angular 会导航到该路由。否则,使用 `Router` 对象导航到登录页面。将用户尝试访问的当前路由作为查询参数传递。这会将路由放置在地址栏上,供登录组件在有效登录后检索并用于导航到请求的路由。
试一试
保存所有更改,转到浏览器,然后直接在浏览器地址栏中键入 `https://:4200/products`。页面将重置,您将被重定向到登录页面。您应该在地址栏中看到一个 `returnUrl` 参数。您可以登录,但您不会被重定向到 `products` 页面,您需要在登录组件中添加一些代码。
重定向回请求的页面
如果用户使用允许他们访问请求页面的适当凭据登录,那么您希望在登录后将他们重定向到该页面。`LoginComponent` 类应返回 `returnUrl` 查询参数,并在成功登录后尝试导航到该路由。打开 `login.component.ts` 文件,并将 `ActivatedRoute` 和 `Router` 对象注入构造函数。
constructor(private securityService: SecurityService,
private route: ActivatedRoute,
private router: Router) { }
向此类添加一个属性来存储返回 URL(如果从地址栏中检索到)。
returnUrl: string;
在 `ngOnInit()` 方法中添加一行以检索此 `returnUrl` 查询参数。如果您直接单击“登录”菜单,`queryParamMap.get()` 方法将返回 `null`。
ngOnInit() {
this.returnUrl = this.route.snapshot.queryParamMap.get('returnUrl');
}
找到 `login()` 方法,并在设置 `securityObject` 后添加代码以测试有效 URL,并在存在 URL 时重定向到该路由。
login() {
localStorage.removeItem("bearerToken");
this.securityService.login(this.user)
.subscribe(resp => {
this.securityObject = resp;
if (this.returnUrl) {
this.router.navigateByUrl(this.returnUrl);
}
});
}
试一试
保存所有更改,转到浏览器,然后直接在浏览器地址栏中键入 `https://:4200/products`,您将被重定向到登录页面。以“`psheriff`”身份登录,您将被重定向到产品列表页面。
摘要
在本文中,您学习了如何为 Angular 应用程序添加客户端安全性。使用带有属性的类来表示您要授予每个用户的每个“权限”,可以轻松保护菜单链接和按钮。将路由守卫应用于您的路由,以确保没有人可以通过直接在地址栏中键入来访问页面。您可以做的替代向每个组件添加 `securityObject` 属性的方法是创建一个自定义指令,您将要检查的权限传递给该指令。
本文中的所有操作都在客户端完成;但是,您可以通过 Web API 调用来验证用户并返回安全授权对象。本文中的技术并未解决保护您的 Web API 方法的问题。我们将在未来的文章中研究如何做到这一点。