[译] Angular 安全 —— 使用 JSON 网络令牌(JWT)的身份认证:彻底指南

本文是在 Angular 应用中设计和实现基于 JWT(JSON Web Tokens)身份验证的分步指南。前端

咱们的目标是系统的讨论基于 JWT 的认证设计和实现,衡量取舍不一样的设计方案,并将其应用到某个 Angular 应用特定的上下文中。node

咱们将追踪一个 JWT 从被认证服务器建立开始,到它被返回到客户端,再到它被返回到应用服务器的全程,并讨论其中涉及的全部的方案以及作出的决策。android

因为身份验证一样须要一些服务端代码,因此咱们将同时显示这些信息,以便咱们能够掌握整个上下文,而且看清楚各个部分之间如何协做。ios

服务端代码是 Node/Typescript,Angular 开发者对这些应该是很是熟悉的。可是涵盖的概念并非特定于 Node 的。git

若是你使用另外一种服务平台,主须要在 jwt.io 上为你的平台选择一个 JWT 库,这些概念仍然适用。github

目录

在这篇文章中,咱们将介绍如下主题:web

  • 第一步 —— 登录页面
    • 基于 JWT 的身份验证
    • 用户在 Angular 应用中登陆
    • 为何要使用单独托管的登录页面?
    • 在咱们的单页应用(SPA)中直接登陆
  • 第二步 —— 建立基于 JWT 的用户会话
  • 第三步 —— 将 JWT 返回到客户端
    • 在哪里存储 JWT 会话令牌?
    • Cookie 与 Local Storage
  • 第四步 —— 在客户端存储使用 JWT
    • 检查用户过时时间
  • 第五步 —— 每次请求携带 JWT 发回到服务器
    • 如何构建一个身份验证 HTTP 拦截器
  • 第六步 —— 验证用户请求
    • 构建用于 JWT 验证的定制 Express 中间件
    • 使用 express-jwt 配置 JWT 验证中间件
    • 验证 JWT 签名 —— RS256
    • RS256 与 HS256
    • JWKS (JSON Web 密钥集) 终节点和密钥轮换
    • 使用 node-jwks-rsa 实现 JWKS 密钥轮换
  • 总结

因此无需再费周折(without further ado),咱们开始学习基于 JWT 的 Angular 的认证吧!express

基于 JWT 的用户会话

首先介绍如何使用 JSON 网络令牌来创建用户会话:简而言之,JWT 是数字签名以 URL 友好的字符串格式编码的 JSON 有效载荷(payload)。json

JWT 一般能够包含任何有效载荷,但最多见的用例是使用有效载荷来定义用户会话。后端

JWT 的关键在于,咱们只须要检查令牌自己验证签名就能够肯定它们是否有效,而无需为此单独联系服务器,不须要将令牌保存到内存中,也不须要在请求的时候保存到服务器或内存中。

若是使用 JWT 身份验证,则它们将至少包含用户 ID 和过时时间戳。

若是你想要深刻了解有关 JWT 格式的详细信息(包括最经常使用的签名类型如何工做),请参阅本文后面的 JWT: The Complete Guide to JSON Web Tokens 一文。

若是想知道 JWT 是什么样子的话,下面是一个例子:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzNTM0NTQzNTQzNTQzNTM0NTMiLCJleHAiOjE1MDQ2OTkyNTZ9.zG-2FvGegujxoLWwIQfNB5IT46D-xC4e8dEDYwi6aRM
复制代码

你可能会想:这看起来不像 JSON!那么 JSON 在哪里?

为了看到它,让咱们回到 jwt.io 并将完成的 JWT 字符串粘贴到验证工具中,而后咱们就能看到 JSON 的有效内容:

{
  "sub": "353454354354353453",
  "exp": 1504699256
}
复制代码

查看 raw01.ts ❤托管于 GitHub

sub 属性包含用户标识符,exp 包含用户过时时间戳.这种类型的令牌被称为不记名令牌(Bearer Token),意思是它标识拥有它的用户,并定义一个用户会话。

不记名令牌是用户名/密码组合的签名临时替换!

若是想进一步了解 JWT,请看看这里。对于本文的其他部分,咱们将假定 JWT 是一个包含可验证 JSON 有效载荷的字符串,它定义了一个用户会话。

实现基于 JWT 的身份验证第一步是发布不记名令牌并将其提供给用户,这是登陆/注册页面的主要目的。

第一步 —— 登录页面

身份验证以登录页面开始,该页面能够托管在咱们的域中或者第三方域中。在企业场景中,登录页面通常会托管在单独的服务器上。这是公司范围内单点登陆解决方案的一部分。

在公网(Public Internet)上,登陆页面也多是:

  • 由第三方身份验证程序(如 Auth0)托管
  • 在咱们的单页应用中可用的登陆页面路径或模式下直接使用。

单独托管的登陆页面是一种安全性的改进,由于这样密码永远不会直接由咱们的应用代码来处理。

单独托管的登陆页面能够具备最少许的 JavaScript 甚至彻底没有,而且能够将其作到不论看起来仍是用起来都像是总体应用的一部分的效果。

可是,用户在咱们应用中经过内置登陆页面登陆也是一种可行且经常使用的解决方案,因此咱们也会介绍一下。

直接在 SPA 应用上的登陆页面

若是直接在咱们的 SPA 程序中建立登陆页面,它将看起来是这样的:

@Component({
  selector: 'login',
  template: `
<form [formGroup]="form">
    <fieldset>
        <legend>Login</legend>
        <div class="form-field">
            <label>Email:</label>
            <input name="email" formControlName="email">
        </div>
        <div class="form-field">
            <label>Password:</label>
            <input name="password" formControlName="password" 
                   type="password">
        </div>
    </fieldset>
    <div class="form-buttons">
        <button class="button button-primary" 
                (click)="login()">Login</button>
    </div>
</form>`})
export class LoginComponent {
    form:FormGroup;

    constructor(private fb:FormBuilder, 
                 private authService: AuthService, 
                 private router: Router) {

        this.form = this.fb.group({
            email: ['',Validators.required],
            password: ['',Validators.required]
        });
    }

    login() {
        const val = this.form.value;

        if (val.email && val.password) {
            this.authService.login(val.email, val.password)
                .subscribe(
                    () => {
                        console.log("User is logged in");
                        this.router.navigateByUrl('/');
                    }
                );
        }
    }
}
复制代码

查看 raw02.ts ❤托管于 GitHub

正如咱们所看到的,这个页面是一个简单的表单,包含两个字段:电子邮件和密码。当用户点击登陆按钮的时候,用户和密码将经过 login() 调用发送到客户端身份验证服务。

为何要建立一个单独的认证服务

把咱们全部的客户端身份验证逻辑放在一个集中的应用范围内的单个 AuthService(认证服务)中将帮助咱们保持咱们代码的组织结构。

这样,若是之后咱们须要更改安全提供者或者重构咱们的安全逻辑,咱们只须要改变这个类。

在这个服务里,咱们将使用一些 JavaScript API 来调用第三方服务,或者使用 Angular HTTP Client 进行 HTTP POST 调用。

这两种方案的目标是一致的:经过 POST 请求将用户和密码组合经过网络传送到认证服务器,以便验证密码并启动会话。

如下是咱们如何使用 Angular HTTP Client 构建本身的 HTTP POST:

@Injectable()
export class AuthService {
     
    constructor(private http: HttpClient) {
    }
      
    login(email:string, password:string ) {
        return this.http.post<User>('/api/login', {email, password})
            // 这只是一个 HTTP 调用, 
            // 咱们还须要去处理 token 的接收
        	.shareReplay();
    }
}
复制代码

查看 raw03.ts ❤托管于 GitHub

咱们调用的 shareReplay 能够防止这个 Observable 的接收者因为屡次订阅而意外触发多个 POST 请求。

在处理登陆响应以前,咱们先来看看请求的流程,看看服务器上发生了什么。

第二步 —— 建立 JWT 会话令牌

不管咱们在应用级别使用登陆页面仍是托管登陆页面,处理登陆 POST 请求的服务器逻辑是相同的。

目标是在这两种状况下都会验证密码并创建一个会话。若是密码是正确的,那么服务器将会发出一个不记名令牌,说:

该令牌的持有者的专业 ID 是 353454354354353453, 该会话在接下来的两个小时有效

而后服务器应该对令牌进行签名并发送回用户浏览器!关键部分是 JWT 签名:这是防止攻击者伪造会话令牌的惟一方式。

这是使用 Express 和 Node 包 node-jsonwebtoken 建立新的 JWT 会话令牌的代码:

import {Request, Response} from "express";
import * as express from 'express';
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');
import * as jwt from 'jsonwebtoken';
import * as fs from "fs";

const app: Application = express();

app.use(bodyParser.json());

app.route('/api/login')
    .post(loginRoute);

const RSA_PRIVATE_KEY = fs.readFileSync('./demos/private.key');

export function loginRoute(req: Request, res: Response) {

    const email = req.body.email,
          password = req.body.password;

    if (validateEmailAndPassword()) {
       const userId = findUserIdForEmail(email);

        const jwtBearerToken = jwt.sign({}, RSA_PRIVATE_KEY, {
                algorithm: 'RS256',
                expiresIn: 120,
                subject: userId
            }

          // 将 JWT 发回给用户
          // TODO - 多种可选方案                              
    }
    else {
        // 发送状态 401 Unauthorized(未经受权)
        res.sendStatus(401); 
    }
}
复制代码

查看 raw04.ts ❤托管于 GitHub

代码不少,咱们逐行分解:

  • 咱们首先建立一个 Express 应用
  • 接下来,咱们配置 bodyParser.json() 中间件,使 Express 可以从 HTTP 请求体中读取 JSON 有效载荷
  • 而后,咱们定义了一个名为 loginRoute 的路由处理程序,若是服务器收到一个目标地址是 /api/login 的 POST 请求,就会触发它

loginRoute 方法中,咱们有一些代码展现了如何实现登陆路由:

  • 因为 bodyParser.json() 中间件的存在,咱们可使用 req.body 访问 JSON 请求主体有效载荷。
  • 咱们先从请求主体中检索电子邮件和密码
  • 而后咱们要验证密码,看看它是否正确
  • 若是密码错误,那么咱们返回 HTTP 401 状态码表示未经受权
  • 若是密码正确,咱们从检索用户专用标识开始
  • 而后咱们使用用户 ID 和过时时间戳建立一个普通的 JavaScript 对象,而后将其发送回客户端
  • 咱们使用 node-jsonwebtoken 库对有效载荷进行签名,而后选择 RS256 签名类型(稍后详细介绍)
  • .sign() 调用结果是 JWT 字符串自己

总而言之,咱们验证了密码并建立一个 JWT 会话令牌。如今咱们已经对这个代码的工做原理有了一个很好的了解,让咱们来关注使用了 RS256 签名的包含用户会话详细信息的 JWT 签名的关键部分。

为何签名的类型很重要?由于没有理解它,咱们就没法理解应用程序服务端上对相关令牌的验证代码。

什么是 RS256 签名?

RS256 是基于 RSA 的 JWT 签名类型,是一种普遍使用的公钥加密技术。

使用 RS256 签名的主要优势之一是咱们能够将建立令牌的能力与验证他们的能力分开。

若是您想知道如何手动重现它们,能够阅读 JWT 指南中使用此类签名的全部优势。

简而言之,RS256 签名的工做方式以下:

  • 私钥(如咱们的代码中的 RSA_PRIVATE_KEY)用于对 JWT 进行签名
  • 一个公钥用来验证它们
  • 这两个密钥是不可互换的:它们只能标记 token,或者只能验证,它们中的任何一个都不能同时作这两件事

为何用 RS256?

为何使用公钥加密签署 JWT ?如下是一些安全和运营优点的例子:

  • 咱们只须要在认证服务器部署签名私钥,不是在多个应用服务器使用相同认证服务器。
  • 咱们没必要为了同时更改每一个地方的共享密钥而以协同的方式关闭认证服务器和应用服务器。
  • 公钥能够在 URL 中公布而且被应用服务器在启动时以及定时自动读取。

最后一部分是一个很好的特性:可以发布验证密钥给咱们内置的密钥轮换或者撤销,咱们将在这篇文章中实现!

这是由于(使用 RS256)为了启用一个新的密钥对,咱们只须要发布一个新的公钥,而且咱们会看到这个公钥。

RS256 vs HS256

另外一个经常使用的签名是 HS256,没有这些优点。

HS256 仍然是经常使用的,可是例如 Auth0 等供应商如今都默认使用 RS256。若是你想了解有关 HS256,RS256 和 JWT 签名的更多信息,请查看这篇文章

抛开咱们使用的签名类型不谈,咱们须要将新签名的令牌发送回用户浏览器。

第三步 —— 将 JWT 发送回客户端

咱们有几种不一样的方式将令牌发回给用户,例如:

  • 在 Cookie 中
  • 在请求正文中
  • 在一个普通的 HTTP 头

JWT 和 Cookie

让咱们从 cookie 开始,为何不使用 Cookie 呢?JWT 有时候被称为 Cookie 的替代品,但这是两个彻底不一样的概念。 Cookie 是一种浏览器数据存储机制,能够安全地存储少许数据。

该数据能够是诸如用户首选语言之类的任何数据。但它也能够包含诸如 JWT 的用户识别令牌。

所以,咱们能够将 JWT 存储在 Cookie 中!而后,咱们来谈谈使用 Cookie 存储 JWT 与其余方法相比较的优势和缺点。

浏览器如何处理 Cookie

Cookie 的一个独特之处在于,浏览器会自动为每一个请求附加到特定域和子域的 Cookie 到 HTTP 请求的头部。

这就意味着,若是咱们将 JWT 存储到了 Cookie 中,假设登陆页面和应用共享一个根域,那么在客户端上,咱们不须要任何其余的的逻辑,就可让 Cookie 随每个请求发送到应用服务器。

而后,让咱们把 JWT 存储到 Cookie 中,看看会发生什么。下面是咱们对登陆路由的实现,发送 JWT 到浏览器,存入 :

... continuing the implementation of the Express login route

// this is the session token we created above
const jwtBearerToken = jwt.sign(...);

// set it in an HTTP Only + Secure Cookie
res.cookie("SESSIONID", jwtBearerToken, {httpOnly:true, secure:true});
复制代码

查看 raw05.ts ❤托管于 GitHub

除了使用 JWT 值设置 Cookie 外,咱们还设置了一些咱们将要讨论的安全属性。

Cookie 独特的安全属性 —— HttpOnly 和安全标志

Cookie 另外一个独特之处在于它有着一些与安全相关的属性,有助于确保数据的安全传输。

一个 Cookie 能够标记为“安全”,这意味着若是浏览器经过 HTTPS 链接发起了请求,那么它只会附加到请求中。

一个 Cookie 一样能够被标记为 Http Only,这就意味着它 根本不能 被 JavaScript 代码访问!请注意,浏览器依旧会将 Cookie 附加到对服务器的每一个请求中,就像使用其余 Cookie 同样。

这意味着,当咱们删除 HTTP Only 的 Cookie 的时候,咱们须要向服务器发送请求,例如注销用户。

HTTP Only Cookie 的优势

HTTP Only 的 Cookie 的一个优势是,若是应用遭受脚本注入攻击(或称 XSS),在这种荒谬的状况下, Http Only 标志仍然会阻止攻击者访问 Cookie ,阻止使用它冒充用户。

Secure 和 Http Only 标志常常能够一块儿使用,以得到最大的安全性,这可能使咱们认为 Cookie 是存储 JWT 的理想场所。

可是 Cookie 也有一些缺点,那么咱们来谈谈这些:这将有助于咱们知晓在 JWT 中存储 Cookie 是不是一种适合咱们应用的好方案。(译者注:原文是 “this will help us decide if storing cookies in a JWT is a good approach for our application”,可是上面的部分讲的是将 JWT 存入 Cookie 中,因此译者认为原文有误,可是仍是选择尊重原文)

Cookie 的缺点 —— XSRF(跨站请求伪造)

将不记名令牌存储在 Cookie 中的应用,所以(由于这个 Cookie)遭受的攻击被称为跨站请求伪造(Cross-Site Request Forgery),也成为 XSRF 或者 CSRF。下面是其原理:

  • 有人发给你一个连接,而且你点击了它
  • 这个连接向受到攻击的网站最终发送了一个 HTTP 请求,其中包含了全部连接到该网站的 Cookie
  • 若是你登录了网站,这意味着包含咱们 JWT 不记名令牌的 Cookie 也会被转发,这是由浏览器自动完成的
  • 服务器接收到有效的 JWT,所以服务器没法区分这是攻击请求仍是有效请求

这就意味着攻击者能够欺骗用户表明他去执行某些操做,只须要发送一封电子邮件或者公共论坛上发布连接便可。

这个攻击不像看起来那么吓人,但问题是执行起来很简单:只须要一封电子邮件或者社交媒体上的帖子。

咱们会在后文详细介绍这种攻击,如今须要知道的是,若是咱们选择将咱们的 JWT 存储到 Cookie 中,那么咱们还须要对 XSRF 进行一些防护。

好消息是,全部的主流框架都带有防护措施,能够很容易地对抗 XSRF,由于它是一个众所周知的漏洞。

就像是发生过不少次同样,Cookie 设计上鱼和熊掌不能兼得:使用 Cookie 意味着利用 HTTP Only 能够很好的防护脚本注入,可是另外一方面,它引入了一个新的问题 —— XSRF。

Cookie 和第三方认证提供商

在 Cookie 中接收会话 JWT 的潜在问题是,咱们没法从处理验证逻辑的第三方域接收到它。

这是由于在 app.example.com 运行的应用不能从 security-provider.com 等其余域访问 Cookie。 所以在这种状况下,咱们将没法访问包含 JWT 的 Cookie,并将其发送到咱们的服务器进行验证,这个问题致使了 Cookie 不可用。

咱们能够获得两个方案中的最优解吗?

第三方认证提供商可能会容许咱们在咱们本身网站的可配置子域名中运行外部托管的登陆页面,例如 login.example.com

所以,将全部这些解决方案中最好的部分组合起来是有可能的。下面是解决方案的样子:

  • 将外部托管的登陆页面托管到咱们本身的子域 login.example.com 上,example.com 上运行应用
  • 该页面设置了仅包含 JWT 的 HTTP Only 和 Secure 的 Cookie,为咱们提供了很好的保护,以低于依赖窃取用户身份的多种类型的 XSS 攻击
  • 此外,咱们须要添加一些 XSRF 防护功能,这里有一个很好理解的解决方案

这将为咱们提供最大限度的保护,防止密码和身份令牌被盗:

  • 应用永远不会获取密码
  • 应用代码从不访问会话 JWT,只访问浏览器
  • 该应用的请求不容易被伪造(XSRF)

这种状况有时用于企业门户,能够提供很好的安全功能。可是这须要咱们的登陆页面支持托管到自定义域,且使用了安全提供程序或企业安全代理。

可是,此功能(登陆页面托管到自定义子域)并不老是可用,这使得 HTTP Only Cookie 方法可能失效。

若是你的应用属于这种状况,或者你正寻找不依赖 Cookie 的替代方案,那么让咱们回到最初的起点,看看咱们能够作什么。

在 HTTP 响应正文中发送回 JWT

具备 HTTP Only 特性的 Cookie 是存储 JWT 的可靠选择,可是还会有其余很好的选择。例如咱们不使用 Cookie,而是在 HTTP 响应体中将 JWT 发送回客户端。

咱们不只要发送 JWT 自己,并且还要将过时时间戳做为单独的属性发送。

的确,过时时间戳在 JWT 中也能够获取到,可是咱们但愿让客户端可以简单地得到会话持续时间,而没必要要为此再安装一个 JWT 库。

如下使咱们如何在 HTTP 响应体中将 JWT 发送回客户端:

... 继续 Express 登陆路由的实现

// 这是咱们上面建立的会话令牌
const jwtBearerToken = jwt.sign(...);

// 将其放入 HTTP 响应体中
res.status(200).json({
  idToken: jwtBearerToken, 
  expiresIn: ...
});

复制代码

查看 raw06.ts ❤托管于 GitHub

这样,客户端将收到 JWT 及其过时时间戳。

为了避免使用 Cookie 存储 JWT 所进行的设计妥协

不使用 Cookie 的优势是咱们的应用再也不容易受到 XSRF 攻击,这是这种方法的优势之一。

可是这一样意味着咱们将不得不添加一些客户端代码来处理令牌,由于浏览器将再也不为每一个向应用服务器发送的请求转发它。

这也意味着,在成功的脚本注入攻击的状况下,攻击者此时能够读取到 JWT 令牌,而存储到 HTTP Only Cookie 则不可能读取到。

这是与选择安全解决方案有关的设计折衷的一个好例子:一般是安全与便利的权衡。

让咱们继续跟随咱们的 JWT 不记名令牌的旅程。因为咱们将 JWT 经过请求体发回给客户端,咱们须要阅读并处理它。(译者注:原文是“Since we are sending the JWT back to the client in the request body”,译者认为应该是响应体(response body),可是尊重原文)

第四步 —— 在客户端存储使用 JWT

一旦咱们在客户端收到了 JWT,咱们须要把它存储在某个地方。不然,若是咱们刷新浏览器,它将会丢失。那么咱们就必需要从新登陆了。

有不少地方能够保存 JWT(Cookie 除外)。本地存储(Local Storage)是存储 JWT 的实用场所,它是以字符串的键值对的形式存储的,很是适合存储少许数据。

请注意,本地存储具备同步 API。让咱们来看看实用本地存储的登陆与注销逻辑的实现:

import * as moment from "moment";

@Injectable()
export class AuthService {

    constructor(private http: HttpClient) {

    }

    login(email:string, password:string ) {
        return this.http.post<User>('/api/login', {email, password})
            .do(res => this.setSession) 
            .shareReplay();
    }
          
    private setSession(authResult) {
        const expiresAt = moment().add(authResult.expiresIn,'second');

        localStorage.setItem('id_token', authResult.idToken);
        localStorage.setItem("expires_at", JSON.stringify(expiresAt.valueOf()) );
    }          

    logout() {
        localStorage.removeItem("id_token");
        localStorage.removeItem("expires_at");
    }

    public isLoggedIn() {
        return moment().isBefore(this.getExpiration());
    }

    isLoggedOut() {
        return !this.isLoggedIn();
    }

    getExpiration() {
        const expiration = localStorage.getItem("expires_at");
        const expiresAt = JSON.parse(expiration);
        return moment(expiresAt);
    }    
}
复制代码

查看 raw07.ts ❤托管于 GitHub

让咱们分析一下这个实现过程当中发生了什么,从 login 方法开始:

  • 咱们接收到包含 JWT 和 expiresIn 属性的 login 调用的结果,并直接将它传递给 setSession 方法
  • setSession 中,咱们直接将 JWT 存储到本地存储中的 id_token 键值中
  • 咱们使用当前时间和 expiresIn 属性计算过时时间戳
  • 而后咱们还将过时时间戳保存为本地存储中 expires_at 条目中的一个数字值

在客户端使用会话信息

如今咱们在客户端拥有所有的会话信息,咱们能够在客户端应用的其他部分使用这些信息。

例如,客户端应用须要知道用户是否登录或者注销,以判断某些好比登陆/注销菜单按钮这类的 UI 元素的显示与否。

这些信息如今能够经过 isLoggedIn(), isLoggedOut()getExpiration() 获取。

对服务器的每次请求都携带 JWT

如今咱们已经将 JWT 保存在用户浏览器中,让咱们继续追随其在网络中的旅程。

让咱们来看看如何使用它来让应用服务器知道一个给定的 HTTP 请求属于特定用户。这是认证方案的所有要点。

如下是咱们须要作的事情:咱们须要用某种方式为 HTTP 附加 JWT,并发送到应用服务器。

而后应用服务器将验证请求并将其连接到用户,只须要检查 JWT,检查其签名并从有效内容中读取用户标识。

为了确保每一个请求都包含一个 JWT,咱们将使用一个 Angular HTTP 拦截器。

如何构建一个身份验证 HTTP 拦截器

如下是 Angular 拦截器的代码,用于为每一个请求附加 JWT 并发送给应用服务器:

@Injectable()
export class AuthInterceptor implements HttpInterceptor {

    intercept(req: HttpRequest<any>,
              next: HttpHandler): Observable<HttpEvent<any>> {

        const idToken = localStorage.getItem("id_token");

        if (idToken) {
            const cloned = req.clone({
                headers: req.headers.set("Authorization",
                    "Bearer " + idToken)
            });

            return next.handle(cloned);
        }
        else {
            return next.handle(req);
        }
    }
}

复制代码

查看 raw08.ts ❤托管于 GitHub

那么让咱们来分解如下这个代码是如何工做:

  • 咱们首先直接从本地存储检索 JWT 字符串
  • 请注意,咱们没有在这里注入 AuthService,由于这里会致使循环依赖错误
  • 而后咱们将检查 JWT 是否存在
  • 若是 JWT 不存在,那么请求将经过服务器进行修改
  • 若是 JWT 存在,那么咱们就克隆 HTTP 头,并添加额外的认证(Authorization)头,其中将包含 JWT

而且在此处,最初在认证服务器上建立的 JWT 如今会随着每一个请求发送到应用服务器。

咱们来看看应用服务器如何使用 JWT 来识别用户。

验证服务端的 JWT

为了验证请求,咱们须要从 Authorization 头中提取 JWT,并检查时间戳和用户标识符。

咱们不但愿将这个逻辑应用到全部的后端路由,由于某些路由是全部用户公开访问的。例如,若是咱们创建了本身的登录和注册路由,那么这些路由应该能够被全部用户访问。

另外,咱们不但愿在每一个路由基础上都重复验证逻辑,所以最好的解决方案是建立一个 Express 认证中间件,并将其应用于特定的路由。

假设咱们已经定义了一个名为 checkIfAuthenticated 的 express 中间件,这是一个可重用的函数,它只在一个地方包含认证逻辑。

如下是咱们如何将其应用于特定的路由:

import * as express from 'express';

const app: Application = express();

// ... 定义 checkIfAuthenticated 中间件
// 检查用户是否仅在某些路由进行身份验证
app.route('/api/lessons')
    .get(checkIfAuthenticated, readAllLessons);
复制代码

查看 raw10.ts ❤托管于 GitHub

在这个例子中,readAllLessons 是一个 Express 路由,若是一个 GET 请求到达 /api/lessons Url,它就会提供一个 JSON 列表。

咱们已经经过在 REST 端点以前应用 checkIfAuthenticated 中间件,使得这个路由只能被认证的用户访问,这意味着中间件功能的顺序很重要。

若是没有有效的 JWT,checkIfAuthenticated 中间件将会报错,或容许请求经过中间件链继续。

在 JWT 存在的状况下,若是签名正确可是过时,中间件也须要抛出错误。请注意,在使用基于 JWT 的身份验证的任何应用中,全部这些逻辑都是相同的。

咱们可使用 node-jsonwebtoken 本身编写的中间件,可是这个逻辑很容易出错,因此咱们使用第三方库。

使用 express-jwt 配置 JWT 验证中间件

为了建立 checkIfAuthenticated 中间件,咱们将使用 express-jwt 库。

这个库可让咱们快速建立经常使用的基于 JWT 的身份验证设置的中间件,因此咱们来看看如何使用它来验证 JWT,好比咱们在登陆服务中建立 JWT(使用 RS256 签名)。

首先假定咱们首先在服务器的文件系统中安装了签名验证公钥。如下是咱们如何使用它来验证 JWT:

const expressJwt = require('express-jwt');

const RSA_PUBLIC_KEY = fs.readFileSync('./demos/public.key');

const checkIfAuthenticated = expressJwt({
    secret: RSA_PUBLIC_KEY
}); 

app.route('/api/lessons')
    .get(checkIfAuthenticated, readAllLessons);
复制代码

查看 raw11.ts ❤托管于 GitHub

如今让咱们逐行分解代码:

  • 咱们经过从文件系统读取公钥来开始,这将用于验证 JWT
  • 此密钥只能用于验证现有的 JWT,而不能建立和签署新的 JWT
  • 咱们将公钥传递给了 express-jwt,而且咱们获得一个准备使用的中间件函数!

若是认证头没有正确签名的 JWT,那么这个中间件将会抛出错误。若是 JWT 签名正确,可是已通过期,中间件也会抛出错误。

若是咱们想要改变默认的异常处理方法,好比不将异常抛出。而是返回一个状态码 401 和一个 JSON 负载的消息,这也是能够的

使用 RS256 签名的主要优势之一是咱们不须要像咱们在这个例子中所作的那样,在应用服务器上安装公钥。

想象一下,服务器上有几个正在运行的实例:在任何地方同时替换公钥都会出现问题。

利用 RS256 签名

由认证服务器在公开访问的 URL 中发布用于验证 JWT 的公钥。而不是在应用服务器上安装公钥。

这给咱们带来了不少好处,好比说能够简化密钥轮换和撤销。若是咱们须要一个新的密钥对,咱们只须要发布一个新的公钥。

一般密钥周期轮换期间内,咱们会将两个密钥发布和激活一段时间,这段时间通常大于会话时序时间,目的是不中断用户体验,然而撤销可能会更有效。

攻击者可使用公钥,可是这没有危险。攻击者可使用公钥进行攻击的惟一方法是验证现有 JWT 签名,但是这对攻击者无用。

攻击者没法使用公钥伪造新建立的 JWT,或者以某种方式使用公钥猜想私钥签名值。(译者注:这一部分主要涉及的是对称加密和非对称加密,感受说的很啰嗦)

如今的问题是,如何发布公钥?

JWKS (JSON Web 密钥集) 端点和密钥轮换

JWKS 或者 JSON Web 密钥集 是用于在 REST 端点中基于 JSON 标准发布的公钥。

这种类型的端点输出有点吓人,但好消息是咱们没必要直接使用这种格式,由于有一个库直接使用了它:

{
  "keys": [
    {
      "alg": "RS256",
      "kty": "RSA",
      "use": "sig",
      "x5c": [
        "MIIDJTCCAg2gAwIBAgIJUP6A\/iwWqvedMA0GCSqGSIb3DQEBCwUAMDAxLjAsBgNVBAMTJWFuZ3VsYXJ1bml2LXNlY3VyaXR5LWNvdXJzZS5hdXRoMC5jb20wHhcNMTcwODI1MTMxNjUzWhcNMzEwNTA0MTMxNjUzWjAwMS4wLAYDVQQDEyVhbmd1bGFydW5pdi1zZWN1cml0eS1jb3Vyc2UuYXV0aDAuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwUvZ+4dkT2nTfCDIwyH9K0tH4qYMGcW\/KDYeh+TjBdASUS9cd741C0XMvmVSYGRP0BOLeXeaQaSdKBi8uRWFbfdjwGuB3awvGmybJZ028OF6XsnKH9eh\/TQ\/8M\/aJ\/Ft3gBHJmSZCuJ0I3JYSBEUrpCkWjkS5LtyxeCPA+usFAfixPnU5L5lyacj3t+dwdFHdkbXKUPxdVwwkEwfhlW4GJ79hsGaGIxMq6PjJ\/\/TKkGadZxBo8FObdKuy7XrrOvug4FAKe+3H4Y5ZDoZZm5X7D0ec4USjewH1PMDR0N+KUJQMRjVul9EKg3ygyYDPOWVGNh6VC01lZL2Qq244HdxRwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH\/MB0GA1UdDgQWBBRwgr0c0DYG5+GlZmPRFkg3+xMWizAOBgNVHQ8BAf8EBAMCAoQwDQYJKoZIhvcNAQELBQADggEBACBV4AyYA3bTiYWZvLtYpJuikwArPFD0J5wtAh1zxIVl+XQlR+S3dfcBn+90J8A677lSu0t7Q7qsZdcsrj28BKh5QF1dAUQgZiGfV3Dfe4\/P5wUaaUo5Y1wKgFiusqg\/mQ+kM3D8XL\/Wlpt3p804dbFnmnGRKAJnijsvM56YFSTVO0JhrKv7XeueyX9LpifAVUJh9zFsiYMSYCgBe3NIhIfi4RkpzEwvFIBwtDe2k9gwIrPFJpovZte5uvi1BQAAoVxMuv7yfMmH6D5DVrAkMBsTKXU1z3WdIKbrieiwSDIWg88RD5flreeTDaCzrlgfXyNybi4UTUshbeo6SdkRiGs="
      ],
      "n": "wUvZ-4dkT2nTfCDIwyH9K0tH4qYMGcW_KDYeh-TjBdASUS9cd741C0XMvmVSYGRP0BOLeXeaQaSdKBi8uRWFbfdjwGuB3awvGmybJZ028OF6XsnKH9eh_TQ_8M_aJ_Ft3gBHJmSZCuJ0I3JYSBEUrpCkWjkS5LtyxeCPA-usFAfixPnU5L5lyacj3t-dwdFHdkbXKUPxdVwwkEwfhlW4GJ79hsGaGIxMq6PjJ__TKkGadZxBo8FObdKuy7XrrOvug4FAKe-3H4Y5ZDoZZm5X7D0ec4USjewH1PMDR0N-KUJQMRjVul9EKg3ygyYDPOWVGNh6VC01lZL2Qq244HdxRw",
      "e": "AQAB",
      "kid": "QzY0NjREMjkyQTI4RTU2RkE4MUJBRDExNzY1MUY1N0I4QjFCODlBOQ",
      "x5t": "QzY0NjREMjkyQTI4RTU2RkE4MUJBRDExNzY1MUY1N0I4QjFCODlBOQ"
    }
  ]
}
复制代码

查看 raw12.ts ❤托管于 GitHub

关于这种格式的一些细节:kid 表明密钥标识符,而 x5c 属性是公钥自己(它是 x509 证书链)。

再次强调,咱们没必要要编写代码来使用这种格式,可是咱们须要对这个 REST 端点中发生的事情有一点了解:他只是简单地发布一个公钥。

使用 node-jwks-rsa 库实现 JWT 密钥轮换

因为公钥的格式是标准化的,咱们须要的是一种读取密钥的方法,并将其传递给 express-jwt ,如此以便它能够代替从文件系统中读取出来的公钥。

而这正是 node-jwks-rsa 库让咱们作的!咱们来看看这个库的运做:

const jwksRsa = require('jwks-rsa');
const expressJwt = require('express-jwt');

const checkIfAuthenticated = expressJwt({
    secret: jwksRsa.expressJwtSecret({
        cache: true,
        rateLimit: true,
        jwksUri: "https://angularuniv-security-course.auth0.com/.well-known/jwks.json"
    }),
    algorithms: ['RS256']
});

app.route('/api/lessons')
    .get(checkIfAuthenticated, readAllLessons);

复制代码

查看 raw14.ts ❤托管于 GitHub

这个库经过 jwksUri 属性指定 URL 读取公钥,并使用其验证 JWT 签名。咱们须要作的只是匹配网址,若是须要的话还须要设置一些额外参数。

使用 JWT 端点的配置选项

建议将 cache 属性设置为 true,以防每次都检索公钥。默认状况下,一个密钥会保留 10 小时,而后再检查它是否有效,同时最多缓存 5 个密钥。

rateLimit 属性也应该被启用,以确保库每分钟不会向包含公钥服务器发起超过 10 个请求。

这是为了不出现拒绝服务的状况,因为某种状况(包括攻击,但也许是一个 bug),公共服务器会不断进行公钥轮换。

这将使应用服务器很快中止,由于它有很好的内置防护措施!若是你想要更改这些默认参数,请查看库文档来获取更多详细信息。

这样,咱们已经完成了 JWT 的网络之旅!

  • 咱们已经在应用服务器中建立并签名了一个 JWT
  • 咱们已经展现了如何在客户端使用 JWT 并将其随每一个 HTTP 请求发送回服务器
  • 咱们已经展现了应用服务器如何验证 JWT,并将每一个请求连接到给定用户

咱们已经讨论了这个往返过程当中涉及到的多个设计方案。让咱们总结一下咱们所学到的。

总结和结论

将认证和受权等安全功能委派给第三方基于 JWT 的提供商或者产品比以往更加合适,但这并不意味着安全性能够透明地添加到应用中。

即便咱们选择了第三方认证提供商或企业级单点登陆解决方案,若是没有其余能够用来理解咱们所选的产品或者库的文档,咱们至少也要知道其中关于 JWT 的一些处理细节。

咱们仍然须要本身作不少安全设计方案,选择库和产品,选择关键配置选项,如 JWT 签名类型,设置托管登陆页面(若是可用),并放置一些很是关键的、很容易出错安全相关代码。

但愿这篇文章对你有帮助而且你能喜欢它!若是您有任何问题或者意见,请在下面的评论区告诉我,我将尽快回复您。

若是有更多的贴子发布,咱们将通知你订阅咱们的新闻列表。

相关连接

Auth0 的 JWT 手册

浏览 RS256 和 JWKS

爆破 HS256 是可能的: 使用强密钥在签署 JWT 的重要性

JSON Web 密钥集(JWKS)

YouTube 上提供的视频课程

看看 Angular 大学的 Youtube 频道,咱们发布了大约 25% 到三分之一的视频教程,新视频一直在出版。

订阅 获取新的视频教程:

Angular 上的其余帖子

一样能够看看其余很受欢迎的帖子,你可能会以为有趣:


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索