个性化阅读
专注于IT技术分析

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

点击下载

本文概述

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

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

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

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

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

应用理论

前端和后端系统之间用于JWT身份验证和使用的典型数据流

因此, 有了一些背景知识, 我们现在就可以深入研究这在实际应用程序中的工作方式。在此示例中, 我假设我们有一个托管API的Node.js服务器, 并且我们正在使用Angular 6开发SPA待办事项列表。我们还要使用此API结构:

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

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

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

保留的索赔

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

私人索赔

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

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

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b2RvYXBpIiwibmJmIjoxNDk4MTE3NjQyLCJleHAiOjE0OTgxMjEyNDIsInVpZCI6MSwicm9sZSI6ImFkbWluIn0.ZDz_1vcIlnZz64nSM28yA1s-4c_iw3Z2ZtP-SgcYRPQ

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

大多数库和应用程序都继续将此JWT存储在localStorage或sessionStorage中, 以便于检索, 但这只是通常的做法。只要你可以为将来的API调用提供令牌, 你就可以使用令牌进行处理。

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

Authorization: Bearer {JWT Token}

注意:再次, 这只是简单的惯例。 JWT没有规定任何将其自身发送到服务器的特定方法。你也可以将其附加到URL或以cookie的形式发送。

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

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

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的命名发行版。

内部应有两个文件夹, 分别称为服务器和客户端。该服务器是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 /:”测试”页面以查看节点服务器是否正在运行
  • 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开发和维护, Auth0是一个库, 可让你向网站添加基于云的身份验证。利用库本身并不需要你使用它们的服务。

cd client

# installation using npm
npm install @auth0/angular-jwt

# installation using yarn
yarn add @auth0/angular-jwt

我们将在稍后介绍代码, 但是在编写代码的同时, 我们还要设置服务器端。我们将使用body-parser, jsonwebtoken和express-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未经授权的HTTP错误。

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

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

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

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的工作。很好, 但是你可能会指出, 对于用户而言, 绝对没有任何改变。你是正确的。我们仍然可以导航到应用程序中的每个页面, 并且甚至可以在不发送JWT的情况下调用任何API端点。不好!

我们需要更新客户端应用程序以关注谁登录, 还需要更新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

这应该在客户端文件夹中生成了四个新文件:

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);
  }
}

我们的身份验证服务只有两个功能, 登录和注销:

  • login POST将提供的用户名和密码发送到我们的后端, 并在返回到localStorage时设置access_token。为了简单起见, 这里没有错误处理。
  • 注销仅从localStorage清除access_token, 需要先获取新令牌, 然后才能再次访问其他任何令牌。
  • loggingIn是一个布尔属性, 我们可以快速使用它来确定用户是否已登录。

最后是登录组件。这些与实际使用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应用程序的登录屏幕。

在这一阶段, 我们应该能够登录(使用jemma, paul或sebastian并使用密码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中编码的信息提供所需的用户身份有多么容易。首先, 将此行添加到最后一个app.use()调用下面的server / app.js文件中:

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来显示已登录用户的待办事项列表,而不是我们之前进行硬编码的用户。

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

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

git checkout with-jwt

希望你发现本演练对将JWT身份验证添加到自己的Angular应用中很有用。谢谢阅读!

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

赞(0)
未经允许不得转载:srcmini » 如何使用Angular 6 SPA进行JWT身份验证

评论 抢沙发

评论前必须登录!