如何使用 Angular 6 SPA 进行 JWT 身份验证

已发表: 2022-03-11

今天我们将看看将 JSON Web 令牌 (JWT) 身份验证集成到您的 Angular 6(或更高版本)单页应用程序 (SPA) 中是多么容易。 让我们从一些背景开始。

什么是 JSON Web 令牌,为什么要使用它们?

这里最简单和最简洁的答案是它们方便、紧凑和安全。 让我们详细看看这些说法:

  1. 方便:登录后使用 JWT 对后端进行身份验证需要设置一个 HTTP 标头,这项任务可以通过函数或子类轻松自动化,我们稍后会看到。
  2. 紧凑:令牌只是一个 base64 编码的字符串,包含一些标头字段,如果需要,还有一个有效负载。 总 JWT 通常少于 200 字节,即使已签名也是如此。
  3. 安全:虽然不是必需的,但 JWT 的一个重要安全功能是可以使用 RSA 公钥/私钥对加密或使用共享密钥的 HMAC 加密对令牌进行签名。 这确保了令牌的来源和有效性。

这一切归结为您拥有一种安全有效的方式来验证用户身份,然后验证对 API 端点的调用,而无需解析任何数据结构或实现自己的加密。

应用理论

前端和后端系统之间 JWT 认证和使用的典型数据流

因此,有了一些背景知识,我们现在可以深入了解这将如何在实际应用程序中工作。 对于这个例子,我假设我们有一个 Node.js 服务器来托管我们的 API,并且我们正在使用 Angular 6 开发一个 SPA 待办事项列表。让我们也使用这个 API 结构:

  • /authPOST (发布用户名和密码以进行身份​​验证并接收 JWT)
  • /todosGET (为用户返回待办事项列表项的列表)
  • /todos/{id}GET (返回特定的待办事项列表项)
  • /usersGET (返回用户列表)

我们将很快完成这个简单应用程序的创建,但现在,让我们专注于理论上的交互。 我们有一个简单的登录页面,用户可以在其中输入他们的用户名和密码。 提交表单后,它会将该信息发送到/auth端点。 然后,Node 服务器可以以任何合适的方式(数据库查找、查询另一个 Web 服务等)对用户进行身份验证,但最终端点需要返回 JWT。

此示例的 JWT 将包含一些保留声明和一些私有声明。 保留声明只是 JWT 推荐的通常用于身份验证的键值对,而私有声明是仅适用于我们的应用程序的键值对:

保留索赔

  • iss :此令牌的发行者。 通常是服务器的 FQDN,但只要客户端应用程序知道期望它,它就可以是任何东西。
  • exp :此令牌的到期日期和时间。 这是自格林威治标准时间 1970 年 1 月 1 日午夜(Unix 时间)以来的秒数。
  • nbf : 在时间戳之前无效。 不经常使用,但给出了有效性窗口的下限。 exp格式相同。

私人索赔

  • uid : 登录用户的用户 ID。
  • role :分配给登录用户的角色。

我们的信息将使用 HMAC 和共享密钥todo-app-super-shared-secret进行 base64 编码和签名。 以下是 JWT 的示例:

 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b2RvYXBpIiwibmJmIjoxNDk4MTE3NjQyLCJleHAiOjE0OTgxMjEyNDIsInVpZCI6MSwicm9sZSI6ImFkbWluIn0.ZDz_1vcIlnZz64nSM28yA1s-4c_iw3Z2ZtP-SgcYRPQ

我们只需要这个字符串来确保我们有一个有效的登录名,知道哪个用户连接了,甚至知道用户有什么角色。

大多数库和应用程序继续将此 JWT 存储在localStoragesessionStorage中以便于检索,但这只是常见的做法。 只要您可以为将来的 API 调用提供它,您对令牌的处理取决于您自己。

现在,每当 SPA 想要调用任何受保护的 API 端点时,它只需要在Authorization HTTP 标头中发送令牌即可。

 Authorization: Bearer {JWT Token}

注意:再一次,这只是常见的做法。 JWT 没有规定任何将自身发送到服务器的特定方法。 您也可以将其附加到 URL,或将其发送到 cookie 中。

一旦服务器接收到 JWT,它就可以对其进行解码,使用 HMAC 共享密钥确保一致性,并使用expnbf字段检查到期时间。 它还可以使用iss字段来确保它是这个 JWT 的原始发行方。

一旦服务器对令牌的有效性感到满意,就可以使用存储在 JWT 中的信息。 例如,我们包含的uid为我们提供了发出请求的用户的 ID。 对于这个特定的示例,我们还包括了role字段,它让我们可以决定用户是否应该能够访问特定的端点。 (您是否信任此信息,或者更愿意进行数据库查找取决于所需的安全级别。)

 function getTodos(jwtString) { var token = JWTDecode(jwtstring); if( Date.now() < token.nbf*1000) { throw new Error('Token not yet valid'); } if( Date.now() > token.exp*1000) { throw new Error('Token has expired'); } if( token.iss != 'todoapi') { throw new Error('Token not issued here'); } var userID = token.uid; var todos = loadUserTodosFromDB(userID); return JSON.stringify(todos); }

让我们构建一个简单的 Todo 应用程序

要继续,您需要安装最新版本的 Node.js(6.x 或更高版本)、npm(3.x 或更高版本)和 angular-cli。 如果您需要安装包含 npm 的 Node.js,请按照此处的说明进行操作。 之后可以使用npm (或yarn ,如果你已经安装它)安装angular-cli

 # installation using npm npm install -g @angular/cli # installation using yarn yarn global add @angular/cli

我不会详细介绍我们将在此处使用的 Angular 6 样板,但对于下一步,我创建了一个 Github 存储库来保存一个小型 todo 应用程序,以说明将 JWT 身份验证添加到您的应用程序的简单性。 只需使用以下内容克隆它:

 git clone https://github.com/sschocke/angular-jwt-todo.git cd angular-jwt-todo git checkout pre-jwt

git checkout pre-jwt命令切换到未实现 JWT 的命名版本。

里面应该有两个文件夹,分别称为serverclient 。 该服务器是一个 Node API 服务器,它将托管我们的基本 API。 客户端是我们的 Angular 6 应用程序。

节点 API 服务器

首先,安装依赖项并启动 API 服务器。

 cd server # installation using npm npm install # or installation using yarn yarn node app.js

您应该能够访问这些链接并获得数据的 JSON 表示。 就目前而言,在我们进行身份验证之前,我们已经硬编码/todos端点以返回userID=1的任务:

  • http://localhost:4000:测试页面查看Node服务器是否正在运行
  • http://localhost:4000/api/users:返回系统上的用户列表
  • http://localhost:4000/api/todos:返回userID=1的任务列表

Angular 应用程序

要开始使用客户端应用程序,我们还需要安装依赖项并启动开发服务器。

 cd client # using npm npm install npm start # using yarn yarn yarn start

注意:根据您的线路速度,下载所有依赖项可能需要一段时间。

如果一切顺利,您现在应该在导航到 http://localhost:4200 时看到如下内容:

我们的 Angular Todo List 应用程序的非 JWT 启用版本。

通过 JWT 添加身份验证

为了增加对 JWT 身份验证的支持,我们将使用一些可用的标准库来简化它。 当然,您可以放弃这些便利,自己实现一切,但这超出了我们的范围。

首先,让我们在客户端安装一个库。 它由 Auth0 开发和维护,这是一个允许您向网站添加基于云的身份验证的库。 使用图书馆本身并不需要您使用他们的服务。

 cd client # installation using npm npm install @auth0/angular-jwt # installation using yarn yarn add @auth0/angular-jwt

我们将在一秒钟内获得代码,但是当我们在处理它时,让我们也设置服务器端。 我们将使用body-parserjsonwebtokenexpress-jwt库来让 Node 理解 JSON POST 正文和 JWT。

 cd server # installation using npm npm install body-parser jsonwebtoken express-jwt # installation using yarn yarn add body-parser jsonwebtoken express-jwt

用于身份验证的 API 端点

首先,我们需要一种在给用户令牌之前对用户进行身份验证的方法。 对于我们的简单演示,我们将使用硬编码的用户名和密码设置一个固定的身份验证端点。 这可以像您的应用程序需要的那样简单或复杂。 重要的是发回 JWT。

server/app.js中,在其他require行下方添加一个条目,如下所示:

 const bodyParser = require('body-parser'); const jwt = require('jsonwebtoken'); const expressJwt = require('express-jwt');

以及以下内容:

 app.use(bodyParser.json()); app.post('/api/auth', function(req, res) { const body = req.body; const user = USERS.find(user => user.username == body.username); if(!user || body.password != 'todo') return res.sendStatus(401); var token = jwt.sign({userID: user.id}, 'todo-app-super-shared-secret', {expiresIn: '2h'}); res.send({token}); });

这主要是基本的 JavaScript 代码。 我们获取传递给/auth端点的 JSON 正文,找到与该用户名匹配的用户,检查我们是否有用户和密码匹配,如果没有,则返回401 Unauthorized HTTP 错误。

重要的部分是令牌生成,我们将通过它的三个参数对其进行分解。 sign的语法如下: jwt.sign(payload, secretOrPrivateKey, [options, callback]) ,其中:

  • payload是您希望在令牌中编码的键值对的对象文字。 然后,任何拥有解密密钥的人都可以从令牌中解码此信息。 在我们的示例中,我们对user.id进行编码,以便当我们在后端再次收到令牌进行身份验证时,我们知道我们正在处理的是哪个用户。
  • secretOrPrivateKey是 HMAC 加密共享密钥(为简单起见,这是我们在应用程序中使用的)或 RSA/ECDSA 加密私钥。
  • options表示可以以键值对的形式传递给编码器的各种选项。 通常,我们至少指定expiresIn (成为exp保留声明)和issuer者( iss保留声明),以便令牌不会永远有效,并且服务器可以检查它实际上是否最初发布了令牌。
  • callback是编码完成后调用的函数,如果希望异步处理令牌编码。

(您还可以阅读有关options的更多详细信息以及如何使用公钥加密而不是共享密钥。)

Angular 6 JWT 集成

使用angular-jwt让 Angular 6 与我们的 JWT 一起工作非常简单。 只需将以下内容添加到client/src/app/app.modules.ts

 import { JwtModule } from '@auth0/angular-jwt'; // ... export function tokenGetter() { return localStorage.getItem('access_token'); } @NgModule({ // ... imports: [ BrowserModule, AppRoutingModule, HttpClientModule, FormsModule, // Add this import here JwtModule.forRoot({ config: { tokenGetter: tokenGetter, whitelistedDomains: ['localhost:4000'], blacklistedRoutes: ['localhost:4000/api/auth'] } }) ], // ... }

这基本上就是所有需要的。 当然,我们需要添加更多代码来进行初始身份验证,但是angular-jwt库会负责将令牌与每个 HTTP 请求一起发送。

  • tokenGetter()函数完全按照它所说的做,但它的实现方式完全取决于你。 我们选择返回我们保存在localStorage中的令牌。 您当然可以自由地提供您想要的任何其他方法,只要它返回JSON Web 令牌编码的字符串即可。
  • 存在whiteListedDomains选项,因此您可以限制将 JWT 发送到哪些域,因此公共 API 也不会接收您的 JWT。
  • blackListedRoutes选项允许您指定不应接收 JWT 的特定路由,即使它们位于白名单域中。 例如,身份验证端点不需要接收它,因为没有意义:无论如何调用令牌时,它通常为空。

让一切协同工作

此时,我们有一种方法可以使用 API 上的/auth端点为给定用户生成 JWT,并且我们在 Angular 上完成了为每个 HTTP 请求发送 JWT 的管道。 很好,但您可能会指出,对于用户而言,绝对没有任何改变。 你是对的。 我们仍然可以导航到应用程序中的每个页面,并且我们可以调用任何 API 端点,甚至无需发送 JWT。 不好!

我们需要更新我们的客户端应用程序以关注谁登录,并更新我们的 API 以要求 JWT。 让我们开始吧。

我们需要一个新的 Angular 组件来登录。为了简洁起见,我会尽可能简单。 我们还需要一个服务来处理我们所有的身份验证要求,以及一个 Angular Guard 来保护在登录之前不应访问的路由。我们将在客户端应用程序上下文中执行以下操作。

 cd client ng g component login --spec=false --inline-style ng g service auth --flat --spec=false ng g guard auth --flat --spec=false

这应该在client文件夹中生成了四个新文件:

 src/app/login/login.component.html src/app/login/login.component.ts src/app/auth.service.ts src/app/auth.guard.ts

接下来,我们需要为我们的应用程序提供认证服务和守卫。 更新client/src/app/app.modules.ts

 import { AuthService } from './auth.service'; import { AuthGuard } from './auth.guard'; // ... providers: [ TodoService, UserService, AuthService, AuthGuard ],

然后更新client/src/app/app-routing.modules.ts中的路由以利用身份验证保护并为登录组件提供路由。

 // ... import { LoginComponent } from './login/login.component'; import { AuthGuard } from './auth.guard'; const routes: Routes = [ { path: 'todos', component: TodoListComponent, canActivate: [AuthGuard] }, { path: 'users', component: UserListComponent, canActivate: [AuthGuard] }, { path: 'login', component: LoginComponent}, // ...

最后,使用以下内容更新client/src/app/auth.guard.ts

 import { Injectable } from '@angular/core'; import { Router, CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; @Injectable() export class AuthGuard implements CanActivate { constructor(private router: Router) { } canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot) { if (localStorage.getItem('access_token')) { return true; } this.router.navigate(['login']); return false; } }

对于我们的演示应用程序,我们只是检查本地存储中是否存在 JWT。 在实际应用程序中,您将解码令牌并检查其有效性、过期等。例如,您可以为此使用 JwtHelperService。

此时,我们的 Angular 应用程序现在将始终将您重定向到登录页面,因为我们无法登录。让我们纠正它,从client/src/app/auth.service.ts中的身份验证服务开始:

 import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; @Injectable() export class AuthService { constructor(private http: HttpClient) { } login(username: string, password: string): Observable<boolean> { return this.http.post<{token: string}>('/api/auth', {username: username, password: password}) .pipe( map(result => { localStorage.setItem('access_token', result.token); return true; }) ); } logout() { localStorage.removeItem('access_token'); } public get loggedIn(): boolean { return (localStorage.getItem('access_token') !== null); } }

我们的身份验证服务只有两个功能, loginlogout

  • login POST将提供的usernamepassword发送到我们的后端,如果收到返回,则在localStorage中设置access_token为了简单起见,这里没有错误处理。
  • logout只是从localStorage中清除access_token ,需要获取一个新令牌,然后才能再次访问任何进一步的内容。
  • loggedIn是一个布尔属性,我们可以快速使用它来确定用户是否登录。

最后是登录组件。 这些与实际使用 JWT 无关,因此请随意复制并粘贴到client/src/app/login/login.components.html

 <h4 *ngIf="error">{{error}}</h4> <form (ngSubmit)="submit()"> <div class="form-group col-3"> <label for="username">Username</label> <input type="text" name="username" class="form-control" [(ngModel)]="username" /> </div> <div class="form-group col-3"> <label for="password">Password</label> <input type="password" name="password" class="form-control" [(ngModel)]="password" /> </div> <div class="form-group col-3"> <button class="btn btn-primary" type="submit">Login</button> </div> </form>

并且client/src/app/login/login.components.ts将需要:

 import { Component, OnInit } from '@angular/core'; import { AuthService } from '../auth.service'; import { Router } from '@angular/router'; import { first } from 'rxjs/operators'; @Component({ selector: 'app-login', templateUrl: './login.component.html' }) export class LoginComponent { public username: string; public password: string; public error: string; constructor(private auth: AuthService, private router: Router) { } public submit() { this.auth.login(this.username, this.password) .pipe(first()) .subscribe( result => this.router.navigate(['todos']), err => this.error = 'Could not authenticate' ); } }

瞧,我们的 Angular 6 登录示例:

我们的示例 Angular Todo List 应用程序的登录屏幕。

在这个阶段,我们应该能够登录(使用jemmapaulsebastian并输入密码todo并再次看到所有屏幕。 但是我们的应用程序显示相同的导航标题,并且无论当前状态如何都无法注销。 在我们继续修复我们的 API 之前,让我们修复它。

client/src/app/app.component.ts中,将整个文件替换为以下内容:

 import { Component } from '@angular/core'; import { Router } from '@angular/router'; import { AuthService } from './auth.service'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent { constructor(private auth: AuthService, private router: Router) { } logout() { this.auth.logout(); this.router.navigate(['login']); } }

对于client/src/app/app.component.html ,将<nav>部分替换为以下内容:

 <nav class="nav nav-pills"> <a class="nav-link" routerLink="todos" routerLinkActive="active" *ngIf="auth.loggedIn">Todo List</a> <a class="nav-link" routerLink="users" routerLinkActive="active" *ngIf="auth.loggedIn">Users</a> <a class="nav-link" routerLink="login" routerLinkActive="active" *ngIf="!auth.loggedIn">Login</a> <a class="nav-link" (click)="logout()" href="#" *ngIf="auth.loggedIn">Logout</a> </nav>

我们已经让我们的导航上下文感知,它应该只显示某些项目,具体取决于用户是否登录。 auth.loggedIn当然,可以在任何可以导入身份验证服务的地方使用。

保护 API

你可能会想,这太棒了……一切看起来都很好。 但是尝试使用所有三个不同的用户名登录,您会注意到一些事情:它们都返回相同的待办事项列表。 如果我们查看我们的 API 服务器,我们可以看到每个用户实际上都有自己的项目列表,那么发生了什么?

好吧,请记住,当我们开始时,我们将/todos API 端点编码为始终返回userID=1的待办事项列表。 这是因为我们无法知道当前登录的用户是谁。

现在我们做到了,让我们看看保护我们的端点和使用 JWT 中编码的信息来提供所需的用户身份是多么容易。 最初,将这一行添加到您的server/app.js文件中,就在最后一个app.use()调用下方:

 app.use(expressJwt({secret: 'todo-app-super-shared-secret'}).unless({path: ['/api/auth']}));

我们使用express-jwt中间件,告诉它共享密钥是什么,并指定一个不需要 JWT 的路径数组。 就是这样。 无需触及每个端点,创建if语句,或任何事情。

在内部,中间件做了一些假设。 例如,它假设Authorization HTTP 标头遵循Bearer {token}的常见 JWT 模式。 (如果不是这种情况,该库有很多选项可以自定义它的工作方式。有关更多详细信息,请参阅 express-jwt 用法。)

我们的第二个目标是使用 JWT 编码的信息来找出谁在拨打电话。 express-jwt再次来救援。 作为读取和验证令牌的一部分,它将我们在签名过程中发送的编码有效负载设置为 Express 中的变量req.user 。 然后我们可以使用它来立即访问我们存储的任何变量。 在我们的例子中,我们将userID设置为经过身份验证的用户的 ID,因此我们可以直接将其用作req.user.userID

再次更新server/app.js ,并将/todos端点更改为如下所示:

 res.send(getTodos(req.user.userID)); 

我们的 Angular Todo List 应用程序利用 JWT 显示登录用户的 todo 列表,而不是我们之前硬编码的那个。

就是这样。 我们的 API 现在可以防止未经授权的访问,并且我们可以安全地确定我们在任何端点中的经过身份验证的用户是谁。 我们的客户端应用程序也有一个简单的身份验证过程,我们编写的任何调用 API 端点的 HTTP 服务都将自动附加一个身份验证令牌。

如果您克隆了 Github 存储库,并且只是想查看最终结果,您可以使用以下命令查看最终形式的代码:

 git checkout with-jwt

我希望您发现本演练对于将 JWT 身份验证添加到您自己的 Angular 应用程序很有价值。 谢谢阅读!

相关: JSON Web Token 教程:Laravel 和 AngularJS 中的示例