作者都是各自领域经过审查的专家,并撰写他们有经验的主题. All of our content is peer reviewed and validated by Toptal experts in the same field.
冈萨洛·赫希的头像

冈萨洛赫希

Gonzalo是一名全栈开发人员,专注于JavaScript和Node的安全实现方面的专家.js. 他专门为金融和教育领域的初创公司提供解决方案, 拥有阿根廷布宜诺斯艾利斯研究所Tecnológico软件工程硕士学位.

以前的角色

完整的开发人员

工作经验

5

以前在

Croud
Share

保护专有数据, it is imperative to secure any API that provides services to clients through requests. A well-built API identifies intruders and prevents them from gaining access, and a JSON Web令牌 (JWT)允许对客户端请求进行验证和潜在的加密.

在本教程中,我们将演示向控件中添加JWT安全性的过程 Node.js API的实现. 虽然有多种方法可以实现API层安全性, JWT被广泛采用, 开发人员友好的安全实现 Node.js API projects.

JWT解释

JWT是一种开放标准,它允许在空间受限的环境中使用 JSON format. 它简单而紧凑, 支持广泛的应用程序,这些应用程序优雅地结合了许多其他安全标准.

携带我们编码数据的jwt可能被加密和隐藏,也可能被签名并易于读取. 如果令牌是加密的, all required hash and algorithmic information is contained in it to support its decryption. 如果令牌已签名, 它的接收者将分析JWT的内容,并且应该能够检测到它是否已被篡改. 通过支持篡改检测 JSON Web签名 (JWS),最常用的签名令牌方法.

JWT consists of three major parts, each composed of a name-value pair collection:

定义JWT的标头 何塞标准 指定令牌的类型和加密信息. 所需的名称-值对是:

Name

值描述

typ

内容类型("JWT" 在我们的例子中)

alg

令牌签名算法,从 JSON Web算法 (JWA) list

JWS签名支持对称和非对称算法,以提供令牌篡改检测. (额外的报头名称-值对是由各种算法需要和指定的,但是a 完整的探索 这些标题名称超出了本文的讨论范围.)

Payload

JWT所需的有效负载是一方可以发送给另一方的编码(可能加密)内容. 有效载荷是一组 claims,每个都由一个名称-值对表示. 这些声明是消息传输数据的有意义的部分.e.,不包括消息头和元数据). The payload is enclosed in a secure communication, sealed with our token’s signature.

每个声明可以使用来自JWT保留集的名称, 或者我们可以自己定义一个名称. 如果我们自己定义索赔名称, best practices dictate to steer clear of any name listed in the following reserved word list, 为了避免混淆.

具体保留名称必须包含在有效载荷中,无论是否存在任何额外的索赔要求:

Name

值描述

aud

令牌的受众或接受者

sub

令牌的主题, a unique identifier for whichever programmatic entity is referenced within the token (e.g.,用户ID)

iss

令牌的发行者ID

iat

代币的“发布时间”戳

nbf

A token’s “not before” time stamp; the token is rendered invalid before said time

exp

A token’s “expiration” time stamp; the token is rendered invalid at said time

Signature

为了安全地实现JWT,一个签名(i.e.(JWS)建议由预期的令牌接收者使用. A signature is a simple, URL-safe, base64-encoded string that verifies a token’s authenticity.

签名功能依赖于头文件指定的算法. 头和有效载荷部分都传递给算法,如下所示:

base64_url (fn_signature (base64_url(头)+ base64_url(载荷)))

Any party, 包括收件人, 可以独立运行此签名计算,将其与令牌内的JWT签名进行比较,以查看签名是否匹配.

虽然包含敏感数据的令牌应该加密(例如.e., using JWE), 如果我们的令牌不包含敏感数据, 对于非加密的、因此是公开的,使用JWS是可以接受的, yet encoded, 负载要求. JWS允许我们的签名包含信息,使令牌的接收者能够确定令牌是否已被修改, 因此腐化了, 由第三方提供.

常见的JWT用例

With JWT’s structure and intent explained, let’s explore the reasons to use it. Though there is a broad spectrum of JWT use cases, we’ll focus on the most common scenarios.

API认证

当客户端使用我们的API进行身份验证时, 返回一个JWT——这个用例在电子商务应用程序中很常见. 然后,客户端将此令牌传递给每个后续API调用. The API layer will validate the authorization token, verifying that the call may proceed. 客户端可以访问API的路由, services, 以及适合已验证客户端的级别的资源.

联合身份

JWT通常在联邦身份生态系统中使用, 其中,用户的身份在多个独立的系统中链接, 比如使用Gmail登录的第三方网站. A 集中认证系统 负责验证客户端的标识并生成JWT,以便与连接到联邦标识的任何API或服务一起使用.

而非联邦API令牌则很简单, 联邦身份系统通常使用两种令牌类型: 访问令牌 and refresh令牌. An access token is short-lived; during its period of validity, 访问令牌授权访问受保护的资源. 刷新令牌是长期存在的,允许客户端从授权服务器请求新的访问令牌,而不需要重新输入客户端凭据.

无状态会话

无状态会话身份验证类似于API身份验证, but with more information packed into a JWT and passed along to an API with each request. A stateless session mainly involves client-side data; for example, 对购物者进行身份验证并存储其购物车商品的电子商务应用程序可能使用JWT来存储它们.

在这个用例中, 服务器避免存储每个用户的状态, 将其操作限制为仅使用传递给它的信息. 在服务器端拥有无状态会话涉及到在客户端存储更多信息, 因此要求JWT包含有关用户交互的信息, 例如购物车或它将重定向到的URL. 这就是为什么无状态会话的JWT比可比的有状态会话的JWT包含更多信息的原因.

JWT安全最佳实践

To avoid common attack vectors, it is imperative to follow JWT best practices:

最佳实践

Details

始终执行算法验证.

信任不安全的令牌会让我们容易受到攻击. Avoid trusting security libraries to autodetect the JWT algorithm; instead, 显式设置验证代码的算法.

选择算法并验证加密输入.

JWA 定义一组可接受的算法和每个算法所需的输入. 对称算法的共享秘密应该很长, complex, random, 不需要对人类友好.

验证所有索赔.

Tokens should only be considered valid when both the signature and the contents are valid. 各方之间传递的令牌应该使用一组一致的声明.

Use the typ 声明单独的令牌类型.

当使用多种令牌类型时,系统必须验证每种令牌类型是否被正确处理. 每种令牌类型都应该有自己明确的验证规则.

要求运输安全.

Use 传输层安全性 (TLS),以减轻不同或相同收件人的攻击. TLS防止第三方访问传输中的令牌.

依赖可信的JWT实现.

避免自定义实现. Use the most tested libraries and read a library’s documentation to understand how it works.

生成唯一的 sub representation without exposing implementation details or personal information.

从安全的角度来看,存储直接或间接指向用户的信息(例如.g.(如电子邮件地址、用户ID)是不可取的. 不管怎样,考虑到 sub 声明用于标识令牌的主体, 我们必须为它配备某种类型的引用,以便令牌能够工作. 为了最小化通过令牌暴露的信息,a 单向加密算法 and checksum 函数可以一起实现,并作为 sub claim.

记住这些最佳实践, 让我们转向创建JWT和Node的实际实现.在Js的例子中,我们使用了这些点. 在高水平上, 我们将创建一个新项目,在这个项目中,我们将使用JWT对端点进行身份验证和授权, 以下三个主要步骤.

我们将使用Express,因为它提供了一种快速的方法来创建企业级和业余级的后端应用程序, 使JWT安全层的集成变得简单和直接. 我们将使用Postman进行测试,因为它允许与其他开发人员进行有效的协作以进行标准化 端到端测试.

完整项目的最终、可部署版本 repository 在浏览项目时是否可以作为参考.

步骤1:创建节点.js API

创建项目文件夹并初始化Node.js project:

mkdir jwt-nodejs-security
cd jwt-nodejs-security
npm init -y

接下来,添加项目依赖项并生成一个基本的 tsconfig 文件(在本教程中我们不会对其进行编辑),用于 TypeScript:

NPM install typescript ts-node-dev @types/bcrypt @types/express——save-dev
NPM安装bcrypt body-parser
NPX TSC——init

With the project folder and dependencies in place, we’ll now define our API project.

配置API环境

项目将在我们的代码中使用系统环境值. 让我们首先创建一个新的配置文件, src / config /索引.ts,它从操作系统中检索环境变量,使它们对我们的代码可用:

从'dotenv'导入* as dotenv;
dotenv.config();

//创建一个配置对象来保存这些环境变量.
Const config = {
    // JWT重要变量
    jwt: {
        //秘密用于签名和验证签名.
        秘密:过程.env.JWT_SECRET,
        //受众和发行者用于验证目的.
        观众:过程.env.JWT_AUDIENCE,
        发行人:过程.env.JWT_ISSUER
    },
    //基本API端口和前缀配置值如下:
    端口:过程.env.端口|| 3000;
    前缀:过程.env.API_PREFIX || 'api'
};

//使确认对象对其他代码可用.
导出默认配置;

The dotenv library allows environment variables to be set in either the operating system or within an .env file. 我们将使用 .env 文件来定义以下值:

  • JWT_SECRET
  • JWT_AUDIENCE
  • JWT_ISSUER
  • PORT
  • API_PREFIX

Your .env 文件看起来应该类似于 库的例子. With the basic API configuration complete, we now move to coding our API’s storage.

设置内存存储

以避免完全成熟的数据库所带来的复杂性, 我们将把数据本地存储在服务器状态. 让我们创建一个TypeScript文件, src /州/用户.ts,以容纳存储和 CRUD操作 浏览API使用者资料:

从'bcrypt'导入bcrypt;
导入{NotFoundError}../ /异常notFoundError ';
导入{ClientError}../ /异常clientError ';

//定义用户对象的代码接口. 
导出接口IUser {
    id: string;
    用户名:字符串;
    //密码被标记为可选,以允许我们返回这个结构 
    //没有密码值. 我们将在创建用户时验证它是否为空.
    password?: string;
    角色:角色;
}

//我们的API支持管理员和普通用户,由角色定义.
导出enum角色{
    Admin = ' Admin ',
    User = ' User '
}

//让我们用一些用户记录初始化我们的示例API.
//注意:我们使用Node生成密码.js命令行:
// "await require('bcrypt') ".散列(“PASSWORD_TO_HASH”,12)”
让用户:{[id: string]: IUser} = {
    '0': {
        id: '0',
        用户名:“testuser1”,
        //明文密码:testuser1_password
        密码:2 b 12美元ov6s318JKzBIkMdSMvHKdeTMHSYMqYxCI86xSHL9Q1gyUpwd66Q2e美元, 
        role: Roles.USER
    },
    '1': {
        id: '1',
        用户名:“testuser2”,
        //明文密码:testuser2_password
        密码:“l0br1winifbfunhaoew 2 b 12美元63美元.55yh8.a3QcpCy7hYt9sfaIDg.rnTAPC', 
        role: Roles.USER
    },
    '2': {
        id: '2',
        用户名:“testuser3”,
        //明文密码:testuser3_password
        密码:“2 b 12美元美元工联会/ nKtkTsNO91tM7wd5yO6LyY1HpyMlmVUE9SM97IBg8eLMqw4mu评分”,
        role: Roles.USER
    },
    '3': {
        id: '3',
        用户名:“testadmin1”,
        //明文密码:testadmin1_password
        密码:“2 b 12美元美元tuzkBzJWCEqN1DemuFjRuuEs4z3z2a3S5K0fRukob / E959dPYLE3i ',
        role: Roles.ADMIN
    },
    '4': {
        id: '4',
        用户名:“testadmin2”,
        //明文密码:testadmin2_password
        密码:“2 b 12美元美元.dN3BgEeR0YdWMFv4z0pZOXOWfQUijnncXGz.3 yoychsaeczxqldq’,
        role: Roles.ADMIN
    }
};

让nextUserId = Object.keys(users).length;

在我们实现特定的API路由和处理函数之前, 让我们把重点放在项目的错误处理支持上,以便在整个项目代码中传播JWT最佳实践.

添加自定义错误处理

Express不支持proper 错误处理 with 异步处理程序,因为它不会从异步处理程序中捕获承诺拒绝. To catch such rejections, we need to implement an error-handling wrapper function.

让我们创建一个新文件, src /中间件/ asyncHandler.ts:

import {NextFunction, Request, Response} from 'express';

/**
 * Async处理器包装API路由,允许异步错误处理.
 * @param fn函数调用的API端点
 @用catch语句返回Promise
 */
导出const asyncHandler = (fn: (req:)请求, res:响应, next: NextFunction) => void) => (req: Request, res:响应, next: NextFunction) => {
    回报承诺.解析(fn(req, res, next)).抓住(下);
};

The asyncHandler 函数包装API路由,并将承诺错误传播到错误处理程序中. 在编写错误处理程序之前,我们将定义一些自定义异常 src / / customError异常.ts 在我们的应用中使用:

// Note: Our custom error extends from Error, so we can throw this error as an exception.
导出类CustomError扩展错误{
    message!: string;
    status!: number;
    additionalInfo!: any;

    constructor(message: string, status: number = 500, additionalInfo: any = undefined) {
        超级(消息);
        this.Message =消息;
        this.Status =状态;
        this.additionalInfo = additionalInfo;
    }
};

导出接口IResponseError {
    消息:字符串;
    additionalInfo?: string;
}

现在我们在文件中创建错误处理程序 src /中间件/ errorHandler.ts:

import {Request, Response, NextFunction} from 'express';
导入{CustomError, IResponseError}../ /异常customError ';

export function errorHandler(err: any, req: Request, res:响应, next: NextFunction) {
    console.error(err);
    if (!(err instanceof CustomError)) {
        res.status(500).send(
            JSON.stringify({
                消息:“服务器错误,请稍后再试”
            })
        );
    } else {
        const customError = err as customError;
        让response = {
            信息:customError.message
        }作为IResponseError;
        //检查是否有更多信息返回.
        如果(customError.additionalInfo)响应.additionalInfo = customError.additionalInfo;
        res.状态(customError.status).类型(json).send(JSON.stringify(响应));
    }
}

我们已经为我们的API实现了一般的错误处理, 但我们还希望支持从API处理程序中抛出丰富的错误. 现在让我们定义这些丰富的错误实用函数,每个函数都在一个单独的文件中定义:

src / / clientError异常.ts:处理状态 code 400 errors.

导入{CustomError}./ customError”;

导出类ClientError扩展CustomError {
    构造函数(message: string) {
        超级(消息,400);
    }
}

src / / unauthorizedError异常.ts:处理状态 code 401 errors.

导入{CustomError}./ customError”;

导出类UnauthorizedError扩展CustomError {
    构造函数(message: string) {
        超级(消息,401);
    }
}

src / / forbiddenError异常.ts:处理状态 code 403 errors.

导入{CustomError}./ customError”;

导出类ForbiddenError扩展CustomError {
    构造函数(message: string) {
        超级(消息,403);
    }
}

src / / notFoundError异常.ts:处理状态 code 404 errors.

导入{CustomError}./ customError”;

导出类NotFoundError扩展CustomError {
    构造函数(message: string) {
        超级(消息,404);
    }
}

实现了基本的项目和错误处理功能, 让我们定义API端点及其处理程序函数.

定义我们的API端点

让我们创建一个新文件, src/index.ts,来定义API的入口点:

从“express”中输入express;
从'body-parser'中导入{json};
导入{errorHandler}./中间件/ errorHandler”;
从'导入配置'./config';

//实例化一个Express对象.
Const app = express();
app.使用(json ());

//添加错误处理作为最后一个中间件,就在我们的应用程序之前.listen call.
//这确保所有的错误总是被处理.
app.使用(errorHandler);

//让我们的API监听配置的端口.
app.听(配置.port, () => {
    console.日志('服务器正在监听端口${config.port}`);
});

我们需要更新npm生成的 package.json 文件添加默认应用程序入口点. 注意,我们想把这个端点文件引用放在main对象属性列表的顶部:

{
    “主要”:“指数.js",
    "脚本":{
        "start": "ts-node-dev src/index.ts"
...

Next, our API needs its routes defined, and for those routes to redirect to their handlers. 让我们创建一个文件, src /线路/索引.ts,将用户操作路由链接到我们的应用程序中. 我们将很快定义路由细节和它们的处理程序定义.

从“express”中导入{Router};
从'导入用户'./user';

const routes = Router();
//所有用户操作都将在"users"路由前缀下可用.
routes.使用(' /用户、用户);
//允许我们的路由器在这个文件之外被使用.
导出默认路由;

我们现在将把这些路线包含在 src/index.ts 导入路由对象,然后要求应用程序使用导入的路由. 作为参考,您可以比较 完整文件版本 使用编辑过的文件.

从'导入路由'./线路/指数”;

//将我们的路由对象添加到Express对象中. 
//这必须在应用程序之前.listen call.
app.使用('/' + config.前缀,路线);

// app.listen... 

Now our API is ready for us to implement the actual user routes and their handler definitions. 类中定义用户路由 src /线路/用户.ts 文件并链接到即将定义的控制器, 用户控件:

从“express”中导入{Router};
导入用户控件../控制器/用户控件”;
导入{asyncHandler}../中间件/ asyncHandler”;

const router = router ();

//注意:每个处理函数都被错误处理函数包装.
//获取所有用户.
router.get('/', [], asyncHandler(用户控件.listAll));

//获取一个用户.
router.get (' /: id ([0-9a-z]{24})”,[],asyncHandler(用户控件.getOneById));

//创建新用户.
router.post('/', [], asyncHandler(用户控件.newUser));

//编辑一个用户.
router.补丁(' /:id ([0-9a-z]{24})”,[],asyncHandler(用户控件.editUser));

//删除一个用户.
router.删除(' /:id ([0-9a-z]{24})”,[],asyncHandler(用户控件.deleteUser));

我们的路由将调用的处理程序方法依赖于辅助函数来操作我们的用户信息. 让我们将这些辅助函数添加到 src /州/用户.ts 文件,然后再定义 用户控件:

//将这些函数放在文件的末尾.
//注意:验证错误直接在这些函数中处理.

//生成一个没有密码的用户副本.
const generateSafeCopy = (user : IUser) : IUser => {
    让_user = { ...user };
    删除_user.password;
    返回_user;
};

//恢复存在的用户.
export const getUser = (id: string): IUser => {
    if (!(用户id))抛出新的NotFoundError('用户id ${id}未找到');
    (id)返回generateSafeCopy(用户);
};

// Recover a user based on username if present, using the username as the query.
export const getUserByUsername = (username: string): IUser | undefined => {
    const possibleUsers =对象.值(用户).filter((user) => user.Username === Username);
    //如果该用户名不存在,则未定义.
    如果(possibleUsers.长度== 0)返回未定义;
    返回generateSafeCopy (possibleUsers [0]);
};

export const getAllUsers = (): IUser[] => {
    返回对象.值(用户).map((elem) => generateSafeCopy(elem));
};


export const createUser = async (username: string), 密码:字符串, role: Roles): Promise => {
    用户名=用户名.trim();
    Password = Password.trim();

    // Reader:根据您的自定义用例添加检查.
    如果用户名.length === 0)抛出new ClientError('无效用户名');
    否则if (password).length === 0)抛出新的ClientError('无效密码');
    //检查是否有重复.
    如果(getUserByUsername(用户名) != undefined)抛出新的ClientError('Username被占用');

    //生成一个用户id.
    const id: string = nextUserId.toString();
    nextUserId + +;
    //创建用户.
    Users [id] = {
        username,
        密码:await bcrypt.散列(密码,12),
        role,
        id
    };
    (id)返回generateSafeCopy(用户);
};

export const updateUser = (id: string, username: string, role: Roles): IUser => {
    //检查用户是否存在.
    if (!(用户id))抛出新的NotFoundError('用户id ${id}未找到');

    // Reader:根据您的自定义用例添加检查.
    如果用户名.trim().length === 0)抛出new ClientError('无效用户名');
    用户名=用户名.trim();
    const userIdWithUsername = getUserByUsername(username)?.id;
    如果(userIdWithUsername != =未定义 && userIdWithUsername !== id)抛出新的ClientError('Username被占用');

    //应用更改.
    users[id].Username = Username;
    users[id].role = role;
    (id)返回generateSafeCopy(用户);
};

export const deleteUser = (id: string) => {
    if (!(用户id))抛出新的NotFoundError('用户id ${id}未找到');
    删除用户(id);
};

export const isPasswordCorrect = async (id: string, 密码:字符串): Promise => {
    if (!(用户id))抛出新的NotFoundError('用户id ${id}未找到');
    返回等待比特币.比较(密码,用户(id).password!);
};

export const changePassword = async (id: string, 密码:字符串) => {
    if (!(用户id))抛出新的NotFoundError('用户id ${id}未找到');
    
    Password = Password.trim();
    // Reader:根据您的自定义用例添加检查.
    if (password.length === 0)抛出新的ClientError('无效密码');

    //存储加密后的密码
    users[id].密码=等待密码.散列(密码,12);
};

最后,可以创建 src /控制器/用户控件.ts file:

import {NextFunction, Request, Response} from 'express';
import { getAllUsers, Roles, getUser, createUser, updateUser, deleteUser } from '../ /用户的状态;

类用户控件 {
    static listAll = async (req: Request, res:响应, next: NextFunction) => {
        //检索所有用户.
        const users = getAllUsers();
        //返回用户信息.
        res.status(200).类型(json).发送(用户);
    };

    static getOneById = async (req: Request, res:响应, next: NextFunction) => {
        //从URL获取ID.
        Const id: string = req.params.id;

        //获取请求ID的用户.
        const user = getUser(id);

        //注意:我们只会到达这里,如果我们找到一个用户与请求的ID.
        res.status(200).类型(json).send(user);
    };

    static newUser = async (req: Request, res:响应, next: NextFunction) => {
        //获取用户名和密码.
        让{username, password} = req.body;
        //我们只能通过这个函数创建普通用户.
        const user = await createUser(用户名,密码,角色).USER);

        //注意:只有当所有的新用户信息 
        //是有效的,并且用户已经创建.
        //发送一个HTTP "Created"响应.
        res.status(201).类型(json).send(user);
    };

    static editUser = async (req: Request, res:响应, next: NextFunction) => {
        //获取用户ID.
        Const id = req.params.id;

        //从body中获取值.
        Const {username, role} = req.body;

        if (!Object.值(角色).包括(角色))
            抛出新的ClientError('Invalid role');

        //检索和更新用户记录.
        const user = getUser(id);
        const updatedUser = updateUser(id, username || user).用户名、角色||用户.role);

        //注意:只有当所有的新用户信息 
        //是有效的,并且用户已经更新.
        //发送一个HTTP "No Content"响应.
        res.status(204).类型(json).发送(updatedUser);
    };

    static deleteUser = async (req: Request, res:响应, next: NextFunction) => {
        //从URL获取ID.
        Const id = req.params.id;

        deleteUser (id);

        //注意:我们只会到达这里,如果我们找到一个用户的请求ID和    
        //删除.
        //发送一个HTTP "No Content"响应.
        res.status(204).类型(json).send();
    };
}

导出默认用户控件;

该配置暴露了以下端点:

  • / API_PREFIX /用户获取所有用户.
  • / API_PREFIX /用户:创建新用户.
  • / API_PREFIX /用户/ {ID}删除:删除指定用户.
  • / API_PREFIX /用户/ {ID}补丁:更新指定用户.
  • / API_PREFIX /用户/ {ID}:获取特定用户.

至此,我们的API路由和它们的处理程序就实现了.

步骤2:添加和配置JWT

现在我们有了基本的 API的实现, but we still need to implement authentication and authorization to keep it secure. 我们将使用jwt来实现这两种目的. 当用户进行身份验证并验证每个后续调用是否使用该身份验证令牌获得授权时,API将发出JWT.

对于每个客户端调用,授权头包含一个 不记名的令牌 将生成的JWT传递给API: Authorization: Bearer .

为了支持JWT,让我们在项目中安装一些依赖项:

NPM install @types/jsonwebtoken——save-dev
安装jsonwebtoken

One way to sign and validate a payload in JWT is through a shared secret algorithm. 对于我们的设置,我们选择 HS256 这个算法, 因为它是JWT规范中最简单的对称(共享秘密)算法之一. 我们将使用Node CLI,以及 crypto 包生成一个唯一的秘密:

要求(加密).randomBytes (128).toString(十六进制);

我们可以随时更改秘密. 但是,每次更改都会使所有用户的身份验证令牌无效,并强制他们注销.

创建JWT身份验证控制器

用于用户登录和更新其密码, 我们的API的身份验证和授权功能需要端点支持这些操作. 为了实现这一目标,我们将创造 src /控制器/ AuthController.ts,我们的JWT认证控制器:

import {NextFunction, Request, Response} from 'express';
从“jsonwebtoken”中导入{sign};
导入{CustomRequest}../中间件/ checkJwt”;
从'导入配置'../config';
导入{ClientError}../ /异常clientError ';
导入{UnauthorizedError}../ /异常unauthorizedError ';
导入{getUserByUsername, isPasswordCorrect, changePassword}../ /用户的状态;

类AuthController {
    static login = async (req: Request, res:响应, next: NextFunction) => {
        //确保提供了用户名和密码.
        //如果这些值不存在,则向客户端抛出异常.
        让{username, password} = req.body;
        if (!(username && throw new ClientError('需要用户名和密码');

        const user = getUserByUsername(用户名);

        //检查提供的密码是否与我们的加密密码匹配.
        if (!user || !(等待isPasswordCorrect(用户.id, password))) throw new UnauthorizedError("Username and password don't match");

        //生成并签名一个JWT,有效期为一小时.
        const token = sign({userId:用户名.Id,用户名:user.用户名、角色:user.Role}, config.jwt.secret!, {
            expiresIn:‘1 h ',
            notBefore: '0', //现在不能使用,可以配置为延迟.
            算法:“HS256”,
            观众:配置.jwt.audience,
            发行人:配置.jwt.issuer
        });

        //在响应中返回JWT.
        res.类型(json).Send ({token: token});
    };

    static changePassword = async (req: Request, res:响应, next: NextFunction) => {
        //从传入的JWT中检索用户ID.
        const id = (req = CustomRequest).token.payload.userId;

        //从请求体中获取提供的参数.
        const {oldPassword, newPassword} = req.body;
        if (!(oldPassword && newPassword))抛出新的ClientError("密码不匹配");

        // Check if old password matches our currently stored password, then we proceed.
        //如果旧密码不匹配,则向客户端抛出错误.
        if (!(等待isPasswordCorrect (id, 抛出新的UnauthorizedError("旧密码不匹配");

        //更新用户密码.
        //注意:如果旧密码比较失败,我们将不会击中此代码.
        等待changePassword(id, newPassword);

        res.status(204).send();
    };
}
导出默认AuthController;

我们的身份验证控制器现在完成了, 使用用于登录验证和用户密码更改的单独处理程序.

实现授权挂钩

确保我们的每个API端点都是安全的, 我们需要创建一个公共的JWT验证和角色身份验证挂钩,我们可以将其添加到每个处理程序中. 我们将在中间件中实现这些钩子, 的传入JWT令牌 src /中间件/ checkJwt.ts file:

import {Request, Response, NextFunction} from 'express';
从“jsonwebtoken”中导入{verify, JwtPayload};
从'导入配置'../config';

// CustomRequest接口使我们能够向控制器提供jwt.
导出接口CustomRequest扩展请求
    令牌:JwtPayload;
}

export const checkJwt = (req: Request, res:响应, next: NextFunction) => {
    //从请求头中获取JWT.
    const token = req.标题(“授权”);
    让jwtPayload;

    //验证令牌并检索其数据.
    try {
        //验证有效负载字段.
        jwtPayload = verify(token?.拆分(' ')[1],配置.jwt.secret!, {
            完成:没错,
            观众:配置.jwt.audience,
            发行人:配置.jwt.issuer,
            算法:[' HS256 '),
            clockTolerance: 0,
            ignoreExpiration:假的,
            ignoreNotBefore:假
        });
        //添加负载到请求中,以便控制器可以访问它.
        (请求为CustomRequest).token = jwtPayload;
    } catch (error) {
        res.status(401)
            .类型(json)
            .send(JSON.stringify({message: '缺少或无效的令牌'}));
        return;
    }

    //将可编程流传递给下一个中间件/控制器.
    next();
};

我们的代码将令牌信息添加到请求中,然后将其转发. 请注意,错误处理程序此时在我们的代码上下文中不可用,因为错误处理程序尚未包含在我们的Express管道中.

接下来我们创建一个JWT授权文件, src /中间件/ checkRole.ts,以验证用户角色:

import {Request, Response, NextFunction} from 'express';
导入{CustomRequest}./checkJwt';
导入{getUser, Roles}../ /用户的状态;

export const checkRole = (roles: Array) => {
    return async (req: Request, res:响应, next: NextFunction) => {
        //查找请求ID的用户.
        const user = getUser((请求为CustomRequest)).token.payload.userId);

        //确保找到一个用户.
        if (!user) {
            res.status(404)
                .类型(json)
                .send(JSON.stringify({message: 'User not found'}));
            return;
        }

        //确保用户的角色包含在授权的角色中.
        if (roles.indexOf(用户.role) > -1) next();
        else {
            res.status(403)
                .类型(json)
                .send(JSON.stringify({message: 'Not enough permissions'}));
            return;
        }
    };
};

注意,我们检索存储在服务器上的用户角色, 而不是JWT中包含的角色. 这允许先前经过身份验证的用户在其身份验证会话中更改其权限. 路由授权将是正确的, 不管存储在JWT中的授权信息是什么.

现在我们更新我们的路由文件. 让我们创建 src /线路/身份验证.ts 授权中间件的文件:

从“express”中导入{Router};
导入AuthController../控制器/ AuthController”;
导入{checkJwt}../中间件/ checkJwt”;
导入{asyncHandler}../中间件/ asyncHandler”;

const router = router ();
//添加认证路由.
router.邮报》(“/登录”,asyncHandler (AuthController.login));

//附加我们的change password路由. 注意,checkJwt强制端点授权.
router.post('/change-password', [checkJwt], asyncHandler(AuthController.changePassword));

导出默认路由器;

为每个端点添加授权和所需的角色, 让我们来更新用户路由文件的内容, src /线路/用户.ts:

从“express”中导入{Router};
导入用户控件../控制器/用户控件”;
导入{角色}../ /用户的状态;
导入{asyncHandler}../中间件/ asyncHandler”;
导入{checkJwt}../中间件/ checkJwt”;
导入{checkRole}../中间件/ checkRole”;

const router = router ();

//定义我们的路由和它们所需的授权角色.
//获取所有用户.
router.get('/', [checkJwt, checkRole].管理])],asyncHandler(用户控件.listAll));

//获取一个用户.
router.get (' /: id([0 - 9]{1, 24})”,[checkJwt checkRole([角色.USER, Roles.管理])],asyncHandler(用户控件.getOneById));

//创建新用户.
router.邮报》(' / ',asyncHandler(用户控件.newUser));

//编辑一个用户.
router.补丁(' /:id([0 - 9]{1, 24})”,[checkJwt checkRole([角色.USER, Roles.管理])],asyncHandler(用户控件.editUser));

//删除一个用户.
router.删除(' /:id([0 - 9]{1, 24})”,[checkJwt checkRole([角色.管理])],asyncHandler(用户控件.deleteUser));

导出默认路由器;

方法验证传入的JWT checkJwt 函数,然后使用 checkRole middleware.

完成认证路由的集成, we need to attach our authentication and user routes to our API’s route list in the src /线路/索引.ts 文件,替换其内容:

从“express”中导入{Router};
从'导入用户'./user';

const routes = Router();
//所有auth操作都将在“auth”路由前缀下可用.
routes.用(/认证,认证);
//所有用户操作都将在"users"路由前缀下可用.
routes.使用(' /用户、用户);
//允许我们的路由器在这个文件之外被使用.
导出默认路由;

这个配置现在暴露了额外的API端点:

  • / API_PREFIX /身份验证/登录:登录用户.
  • / API_PREFIX /认证/更改密码:修改用户密码.

使用我们的身份验证和授权中间件, 以及每个请求中可用的JWT有效负载, 下一步是使端点处理程序更加健壮. We’ll add code to ensure users have access only to the desired functionalities.

将JWT授权集成到端点中

在端点实现中添加额外的验证,以便定义每个用户可以访问和/或修改的数据, 我们会更新 src /控制器/用户控件.ts file:

import {NextFunction, Request, Response} from 'express';
import { getAllUsers, Roles, getUser, createUser, updateUser, deleteUser } from '../ /用户的状态;
import {ForbiddenError} from../ /异常forbiddenError ';
导入{ClientError}../ /异常clientError ';
导入{CustomRequest}../中间件/ checkJwt”;

类用户控件 {
    static listAll = async (req: Request, res:响应, next: NextFunction) => {
        //检索所有用户.
        const users = getAllUsers();
        //返回用户信息.
        res.status(200).类型(json).发送(用户);
    };

    static getOneById = async (req: Request, res:响应, next: NextFunction) => {
        //从URL获取ID.
        Const id: string = req.params.id;

        //新代码:限制USER请求者检索他们自己的记录.
        //允许ADMIN请求者检索任何记录.
        if ((请求为CustomRequest).token.payload.role === Roles.USER && req.params.id !== (req作为CustomRequest).token.payload.userId) {
            抛出新的ForbiddenError('Not enough permissions');
        }

        //获取请求ID的用户.
        const user = getUser(id);

        //注意:我们只会到达这里,如果我们找到一个用户与请求的ID.
        res.status(200).类型(json).send(user);
    };

    static newUser = async (req: Request, res:响应, next: NextFunction) => {
        //注意:这个函数没有变化.
        //获取用户名和密码.
        让{username, password} = req.body;
        //我们只能通过这个函数创建普通用户.
        const user = await createUser(用户名,密码,角色).USER);

        //注意:只有当所有的新用户信息 
        //是有效的,并且用户已经创建.
        //发送一个HTTP "Created"响应.
        res.status(201).类型(json).send(user);
    };

    static editUser = async (req: Request, res:响应, next: NextFunction) => {
        //获取用户ID.
        Const id = req.params.id;

        //新代码:限制USER请求者编辑自己的记录.
        //允许ADMIN请求者编辑任何记录.
        if ((请求为CustomRequest).token.payload.role === Roles.USER && req.params.id !== (req作为CustomRequest).token.payload.userId) {
            抛出新的ForbiddenError('Not enough permissions');
        }

        //从body中获取值.
        Const {username, role} = req.body;

        //新代码:不允许用户将自己更改为ADMIN.
        //如果你是一个用户,验证你不能将自己设置为ADMIN.
        if ((请求为CustomRequest).token.payload.role === Roles.USER && role === Roles.ADMIN) {
            抛出新的ForbiddenError('Not enough permissions');
        }
        //验证角色是否正确.
        else if (!Object.值(角色).包括(角色)) 
             抛出新的ClientError('Invalid role');

        //检索和更新用户记录.
        const user = getUser(id);
        const updatedUser = updateUser(id, username || user).用户名、角色||用户.role);

        //注意:只有当所有的新用户信息 
        //是有效的,并且用户已经更新.
        //发送一个HTTP "No Content"响应.
        res.status(204).类型(json).发送(updatedUser);
    };

    static deleteUser = async (req: Request, res:响应, next: NextFunction) => {
        //注意:这个函数没有变化.
        //从URL获取ID.
        Const id = req.params.id;

        deleteUser (id);

        //注意:我们只会到达这里,如果我们找到一个用户的请求ID和    
        //删除.
        //发送一个HTTP "No Content"响应.
        res.status(204).类型(json).send();
    };
}

导出默认用户控件;

有了一个完整且安全的API,我们就可以开始了 测试我们的代码.

步骤3:测试JWT和Node.js

为了测试我们的API,我们必须首先启动我们的项目:

NPM运行启动

Next, we’ll 安装邮差,然后创建一个请求来验证测试用户:

  1. 为用户身份验证创建一个新的POST请求.
  2. 将此请求命名为“JWT节点”.js身份验证.”
  3. 将请求的地址设置为localhost:3000/api/auth/login.
  4. 将正文类型设置为raw和JSON.
  5. 更新主体以包含这个JSON值:
  6. {
        "username": “testadmin1”,
        "password": “testadmin1_password”
    }
    
  7. 在Postman中运行请求.
  8. 为下一次调用保存返回的JWT信息.

现在我们有了测试用户的JWT, we’ll create another request to test one of our endpoints and get the available USER records:

  1. 新建一个 GET 请求用户身份验证.
  2. 将此请求命名为“JWT节点”.获取用户.”
  3. 将请求的地址设置为 localhost: 3000 / api /用户.
  4. 在请求的授权选项卡上,将类型设置为 不记名的令牌.
  5. Copy the return JWT from our previous request into the “Token” field on this tab.
  6. 在Postman中运行请求.
  7. 查看API返回的用户列表.

这些例子只是许多可能的测试中的一小部分. 以全面探索API调用并测试我们的授权逻辑, 按照演示的模式创建其他测试.

Better Node.js和JWT安全

当我们将JWT组合成一个Node时.js API, 我们利用行业标准的库和实现来最大化我们的结果并最小化开发人员的工作. JWT功能丰富且对开发人员友好, and it is easy to implement in our app with a minimal learning curve for developers.

不过, 开发人员在向他们的项目中添加JWT安全性时仍然必须谨慎,以避免常见的陷阱. 遵循我们的指导, developers should feel empowered to better apply JWT implementations within Node.js. JWT的可信安全性与Node的多功能性相结合.Js为开发人员提供了创建解决方案的极大灵活性.


Toptal工程博客的编辑团队向 Abhijeet Ahuja and 默罕默德·哈立德 for reviewing the code samples and other technical content presented in this article.

了解基本知识

  • 什么是JWT,它是如何工作的?

    JSON Web令牌 (JWT)是一个开放的标准,用于在空间受限的环境中安全地交换信息. JWT包含消息验证所需的所有信息. The provided signature is used to verify that message content is true and unadulterated.

  • JWT是如何编码的?

    JWT uses base64 to encode its three main components: the header, payload, and signature. JWT提供了一个简洁且url安全的令牌.

  • JWT的目的是什么?

    JWT defines an industry-agnostic and open standard for transmitting data compactly.

  • JWT的结构是什么?

    而JWT可以在结构上有所不同, 它的主要组成部分是:header, containing information used for validation; payload, holding custom data from the issuing server; and signature, 确认令牌的内容没有被篡改.

  • 什么是JWT安全性?

    JWT安全性是遵循JWT标准的令牌的实现. 它定义了算法、加密和字段,以允许安全令牌身份验证和授权.

  • JWT如何与Node集成.js?

    JWT provides a security layer that can integrate seamlessly with new or existing Node.js api来验证和授权用户请求. JWT也可以作为无状态会话的基础.

  • 什么是Node.js used for?

    Node.js is a JavaScript runtime environment designed to build scalable network applications. 它通常用于创建服务器和api.

  • 什么时候应该.js be used?

    鉴于Node的可伸缩性.Js,它可以用于业余爱好和企业解决方案. Node的多功能性.Js允许它在任何行业中使用.

聘请Toptal这方面的专家.
Hire Now
冈萨洛·赫希的头像
冈萨洛赫希

Located in 英国伦敦

成员自 2021年2月1日

作者简介

Gonzalo是一名全栈开发人员,专注于JavaScript和Node的安全实现方面的专家.js. 他专门为金融和教育领域的初创公司提供解决方案, 拥有阿根廷布宜诺斯艾利斯研究所Tecnológico软件工程硕士学位.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. All of our content is peer reviewed and validated by Toptal experts in the same field.

以前的角色

完整的开发人员

工作经验

5

以前在

Croud

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

Toptal开发者

加入总冠军® community.