所有文章 > API安全 > 使用 TypeScript、PostgreSQL 和 Prisma 构建后端:认证与授权

使用 TypeScript、PostgreSQL 和 Prisma 构建后端:认证与授权

在本系列的第三部分中,我们将探讨如何通过无密码身份验证来保护REST API,具体做法是使用Prisma进行令牌存储并实现授权。

介绍

该系列的目标是通过解决一个具体问题:在线课程的评分系统,探索和演示现代后端的不同模式、问题和架构。这是一个很好的例子,因为它足够复杂,能够代表现实世界的用例,并且包含了多种关系类型。

涵盖的内容

该系列将重点关注数据库在后端开发各个方面的作用,包括:

话题部分
数据建模第 1 部分
增删改查第 1 部分
聚合第 1 部分
REST API 层第2部分
验证第2部分
测试第2部分
无密码认证第 3 部分(当前)
授权第 3 部分(当前)
与外部 API 集成第 3 部分(当前)
部署即将推出

所学内容

在首篇文章里,您针对问题域设计了一个数据模型,并且编写了一个种子脚本,该脚本利用Prisma Client将数据保存到数据库中。

在本系列的第二篇文章中,您在第一篇文章的数据模型和Prisma 架构之上构建了REST API 。您使用Hapi构建了 REST API,它允许通过 HTTP 请求对资源执行 CRUD 操作。

在本系列的第三篇文章中,您将了解身份验证和授权背后的概念、两者有何不同,以及如何使用JSON Web 令牌 (JWT)和 Hapi 来实现基于电子邮件的无密码身份验证和授权,以保护 REST API 的安全。

具体来说,你将在以下几个方面进行发展:

  1. 无密码身份验证:添加通过发送带有唯一令牌的电子邮件来登录和注册的功能。用户通过向API发送他们在电子邮件中收到的令牌,并完成身份验证过程,来获取一个长期有效的JWT令牌。这样,他们就可以访问那些需要身份验证的API端点了。
  2. 授权:添加授权逻辑来限制用户可以访问和操作哪些资源。

在本文结束时,将通过身份验证来保护 REST API 的安全,以访问 REST 端点。此外,您将使用 Hapi 的路由选项将授权规则添加到端点的子集pre,从而根据特定用户的权限授予访问权限。此GitHub存储库中将提供包含所有端点授权规则的API。

注意:在整篇指南中,您会碰到多个检查点,这些检查点可以帮助您确认是否已正确执行了相关步骤。

先决条件

需具备的知识

本系列需要您具备 TypeScript、Node.js 和关系数据库的基本知识。如果您有使用JavaScript的经验,但尚未有机会接触TypeScript,那么您依然可以继续学习下去。该系列将使用 PostgreSQL。然而,大多数概念适用于其他关系数据库,例如 MySQL。熟悉 REST 概念很有用。除此之外,您无需事先了解Prisma的相关知识,因为本系列会对此进行详细的讲解。

开发环境

您应该安装以下内容:

  • Node.js(版本 10、12 或 14)
  • Docker(将用于运行开发 PostgreSQL 数据库)

如果您在使用Visual Studio Code,那么我推荐您安装Prisma扩展,它可以为您提供语法高亮、格式设置以及其他实用的辅助功能。

注意:如果您不想使用 Docker,您可以在 Heroku 上设置本地 PostgreSQL 数据库或托管 PostgreSQL 数据库。

外部服务

需要有SendGrid帐户,以便您可以从后端发送无密码身份验证电子邮件。 SendGrid 提供免费套餐,每天最多可以发送 100 封电子邮件。

注册完成后,请前往SendGrid控制台中的API密钥部分,生成一个新的API密钥,并将其保存在一个安全的位置。

克隆存储库

该系列的源代码可以在GitHub上找到。

首先,克隆存储库并安装依赖项:

git clone -b part-3 git@github.com:2color/real-world-grading-app.git
cd real-world-grading-app
npm install

注意:通过查看part-3分支,您将能够从同一起点关注本文。

启动 PostgreSQL

要启动 PostgreSQL,请从real-world-grading-app文件夹运行以下命令:

docker-compose up -d

注意: Docker 将使用该docker-compose.yml文件来启动 PostgreSQL 容器。

身份验证和授权概念

在深入实施之前,我们将介绍一些与身份验证和授权相关的概念。

虽然这两个术语经常互换使用,但身份验证和授权有不同的目的。一般来说,它们都以互补的方式用于保护应用程序。

简而言之,身份验证是用来确认用户身份的过程,而授权则是用来验证用户是否有权访问特定资源或执行特定操作的过程。

现实世界中身份验证的一个例子是有效护照。您与官方文件中的形象一致,这证实了您的身份。例如,当您去机场时,出示护照,然后就可以通过安检。

在同一示例中,授权是允许您登机的过程:您出示登机牌(通常会根据航班乘客数据库进行扫描和验证),然后地勤人员授权您登机。

Web 应用程序中的身份验证

Web 应用程序通常使用用户名和密码来验证用户身份。如果提供了有效的用户名和密码,应用程序就能够验证您是否真的是您所声称的用户,因为理论上来说,密码应该只有您和应用程序本身才知道。

注意:使用用户名/密码身份验证的 Web 应用程序很少将密码以明文形式存储在数据库中。相反,他们使用一种称为散列的技术来存储密码的散列。这使得后端能够在不直接知晓密码的情况下对密码进行验证。

哈希函数是一种数学函数,它接受任意输入,并且在给定相同输入的情况下始终生成相同的固定长度字符串/数字。哈希函数的强大之处在于,您可以从密码转换为哈希值,但不能从哈希值转换为密码。

这允许验证用户提交的密码而不存储实际密码。存储密码的哈希值可以在数据库被非法访问时保护用户,因为攻击者无法使用哈希值来登录系统。

近年来,鉴于大量重要网站遭到破坏,网络安全已成为人们日益关注的问题。这一趋势通过引入更安全的身份验证方法(例如多因素身份验证)影响了安全性的实现方式。

多因素身份验证是一种身份验证方法,用户在成功出示两个或多个证据(也称为因素)后进行身份验证。例如,从 ATM 机取款时,需要两个身份验证因素:拥有银行卡和 PIN 码。

由于网络应用程序很难验证银行卡的持有情况,因此多因素身份验证通常是通过结合用户名/密码与一次性令牌来实现的,这个令牌是由安装在智能手机或特殊设备上的身份验证器应用程序生成的。

在本文中,您将实现一种基于电子邮件的无密码身份验证方法。这是一种分两步走的策略,旨在提升用户体验并加强安全性。它的工作原理是在尝试登录时向用户的电子邮件帐户发送一个秘密令牌。一旦用户打开电子邮件并将令牌传递给应用程序,应用程序就可以对用户进行身份验证,并确定该用户是电子邮件帐户所有者。

此方法依赖于用户的电子邮件服务,可以假设该服务已经对用户进行了身份验证。用户无需设置密码并记住它,从而改善了用户体验。由于应用程序不再需要承担可能成为攻击面的密码管理责任,因此安全性得到了增强。

将身份验证外包给用户的电子邮件帐户意味着应用程序将继承用户电子邮件帐户安全性的优点和缺点。但如今,大多数电子邮件服务都提供第二因素身份验证和其他安全措施的选项。

尽管如此,这种方法能够有效地防止用户选择弱密码,并避免他们在多个网站上重复使用相同的密码。当密码被完全移除后,这些用户的安全性就得到了进一步的提升。

身份验证和注册/登录流程

基于电子邮件的无密码身份验证是一个两步过程,涉及两种令牌类型。

身份验证流程如下所示:

  1. 用户/login使用负载中的电子邮件调用 API 中的端点以开始身份验证过程。
  2. 如果电子邮件是新的,则会在用户表中创建用户。
  3. 邮件token由后端生成并保存在Token表中
  4. 电子邮件令牌发送到用户的电子邮件,用户将电子邮件地址以及通过电子邮件接收到的令牌发送到/authenticate端点。
  5. 后端验证用户发送的电子邮件令牌。如果有效且令牌未过期,则会生成 JWT 令牌并将其保存在令牌表中。
  6. 用户会通过Authorization标头接收到返回的JWT令牌。

有两种令牌类型:

  1. 电子邮件令牌:八位数字令牌,有效期较短,例如 10 分钟,并发送到用户的电子邮件。这个令牌的唯一作用就是验证用户是否与某个电子邮件地址相关联,它并不会赋予用户访问评分应用程序中任何端点的权限。
  2. 身份验证令牌:tokenId有效负载中的JWT 令牌。通过Authorization在向 API 发出请求时将其传递到标头中,可以使用此令牌来访问受保护的端点。该令牌的有效期很长,即 12 小时内有效。

通过此身份验证策略,单个端点可以处理登录和注册。这是可能的,因为登录和注册之间的唯一区别是您是否在“用户”表中创建行(如果用户已经存在)。

JSON 网络令牌

JSON Web 令牌 (JWT)是一种开放且标准的方法,用于在两方之间安全地表示声明。该标准定义了一种紧凑且独立的方式,用于在各方之间以 JSON 对象的形式安全地传输信息。这条信息是经过数字签名的,因此可以被验证和信任。

JWT 令牌包含使用 Base64 编码的三个部分:headerPayloadSignature,如下所示:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ0b2tlbklkIjo5fQ.
FkKMzLobPl_MaQHB7hRG3nZQZ-ME4lRaanGJVnLMa84

注意:Base64 是另一种表示数据的方式。它不涉及任何加密。

如果您使用 Base64 解码上面的标头和有效负载,您将得到以下内容:

  • 标题:{"alg":"HS256","typ":"JWT"}
  • 有效负载:{"tokenId":9}

令牌的签名部分是通过应用签名算法(本例为HS256)对header、payload和secret进行处理而生成的。该秘密只有后端知道,用于验证令牌的真实性。

在本文中,JWT 将用于长期身份验证令牌。令牌的有效负载将包含tokenId,它将存储在数据库中并引用为其创建令牌的用户。这允许后端找到关联的用户。

注意:此方法称为有状态 JWT,其中令牌引用存储在数据库中的会话。虽然这种方法意味着验证请求需要数据库的一次往返,从而增加了服务请求的处理时间,但它却提供了更高的安全性,因为后端系统能够撤销这些令牌。

将令牌模型添加到 Prisma 架构

您需要将令牌存储在数据库中,以便在发出请求时可以验证它们。在此步骤中,您将向TokenPrisma 架构添加新模型并更新User模型以使某些字段可选。

打开位于prisma/schema.prisma并更新的 Prisma 架构,如下所示:

generator client {
provider = "prisma-client-js"
}

model User {
id Int @id @default(autoincrement())
email String @unique
firstName String
lastName String
firstName String?
lastName String?
social Json?

// Relation fields
courses CourseEnrollment[]
testResults TestResult[] @relation(name: "results")
testsGraded TestResult[] @relation(name: "graded")
tokens Token[]
}

model Token {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
type TokenType
emailToken String? @unique // Only used for short lived email tokens
valid Boolean @default(true)
expiration DateTime

// Relation fields
user User @relation(fields: [userId], references: [id])
userId Int
}

enum TokenType {
EMAIL // used as a short-lived token sent to the user's email
API
}

让我们回顾一下引入的更改:

  • 启用connectOrCreatetransactionApi预览功能。这些将在接下来的步骤中使用。
  • 删除aggregateApi预览功能,该功能从Prisma 2.5.0开始稳定。
  • User模型中,firstNamelastName现在是可选的。这允许用户仅使用电子邮件登录/注册。
  • Token添加了新模型。每个用户可以拥有许多令牌,使关系成为 1-n。该Token模型包含了与过期相关的字段、两种令牌类型(通过枚举TokenType来区分)以及用于存储电子邮件令牌的信息。

要迁移数据库架构,请按如下方式创建并运行迁移:

npx prisma migrate dev --preview-feature --name "add-token"

检查点:您应该在输出中看到类似以下内容:

Prisma Migrate created and applied the following migration(s) from new schema changes:

migrations/
└─ 20201202094612_add_token/
└─ migration.sql

✔ Generated Prisma Client to ./node_modules/@prisma/client in 96ms

Everything is now in sync.

注意:运行该prisma migrate dev命令也会默认生成 Prisma 客户端。

添加邮件发送功能

由于后端将在用户登录时发送电子邮件,因此您将创建一个插件,将电子邮件发送功能公开给应用程序的其余部分。 Hapi 插件将遵循与 Prisma 插件类似的约定。

本文将使用 SendGrid 和@sendgrid/mail npm 包来轻松与 SendGrid API 集成。

添加依赖项

npm install --save @sendgrid/mail

创建电子邮件插件

email.ts 文件夹中创建一个新文件,命名为src/plugins/

touch src/plugins/email.ts

并将以下内容添加到文件中:

import Hapi from '@hapi/hapi'
import Joi from '@hapi/joi'
import Boom from '@hapi/boom'
import sendgrid from '@sendgrid/mail'

// Module augmentation to add shared application state
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/33809#issuecomment-472103564
declare module '@hapi/hapi' {
interface ServerApplicationState {
sendEmailToken(email: string, token: string): Promise<void>
}
}

const emailPlugin = {
name: 'app/email',
register: async function(server: Hapi.Server) {
if (!process.env.SENDGRID_API_KEY) {
console.log(
`The SENDGRID_API_KEY env var must be set, otherwise the API won't be able to send emails.`,
`Using debug mode which logs the email tokens instead.`,
)
server.app.sendEmailToken = debugSendEmailToken
} else {
sendgrid.setApiKey(process.env.SENDGRID_API_KEY)
server.app.sendEmailToken = sendEmailToken
}
},
}

export default emailPlugin

async function sendEmailToken(email: string, token: string) {
const msg = {
to: email,
from: 'EMAIL_ADDRESS_CONFIGURED_IN_SEND_GRID@email.com',
subject: 'Login token for the modern backend API',
text: `The login token for the API is: ${token}`,
}

await sendgrid.send(msg)
}

async function debugSendEmailToken(email: string, token: string) {
console.log(`email token for ${email}: ${token} `)
}

该插件将公开对象sendEmailToken上的函数server.app,可以在整个路由处理程序中访问该函数。它将使用SENDGRID_API_KEY环境变量,您将使用 SendGrid 控制台中的密钥在生产中设置该环境变量。在开发阶段,您可以先不设置这个字段,并让系统记录令牌而不是通过电子邮件发送。

最后,在以下位置注册插件:

import emailPlugin from './plugins/email'

await server.register([
// ... existing plugins
emailPlugin,
])

使用 Hapi 添加身份验证

要实现身份验证,您首先需要定义/login/register路由,这将处理在数据库中创建用户和令牌、发送电子邮件令牌、验证电子邮件并生成 JWT 身份验证令牌。值得注意的是,两个端点将处理身份验证过程,但它们不会保护 API。

在定义了两个路由之后,为了保护API,您将实现一个身份验证策略,该策略使用jwt库提供的方案,并基于hapi-auth-jwt2来实现。

注意: Hapi中的身份验证基于方案策略的概念。方案是一种处理身份验证的方法,而策略是方案的预配置实例。在本文中,您只需要定义基于jwt身份验证方案的策略。

您将把所有这些逻辑封装在一个auth插件中。

添加依赖项

首先将以下依赖项添加到您的项目中:

npm install --save hapi-auth-jwt2@10.1.0 jsonwebtoken@8.5.1
npm install --save-dev @types/jsonwebtoken@8.5.0

创建 auth 插件

接下来,您将创建一个身份验证插件来封装身份验证逻辑。

auth.ts在文件夹中创建一个新文件,命名为src/plugins/

touch src/plugins/auth.ts

并将以下内容添加到文件中:

import Hapi from '@hapi/hapi'
import { TokenType, UserRole } from '@prisma/client'

const authPlugin: Hapi.Plugin<null> = {
name: 'app/auth',
dependencies: ['prisma', 'hapi-auth-jwt2', 'app/email'],
register: async function(server: Hapi.Server) {
// TODO: Add the authentication strategy
},
}

export default plugin

注意: auth 插件定义了对prismahapi-auth-jwt2app/email插件的依赖关系。 prisma 插件在本系列的第 2 部分中定义,将用于访问 Prisma 客户端。hapi-auth-jwt2插件定义了一个JWT身份验证方案,您将会利用这个方案来设定您的身份验证策略。最后,这个app/email将确保您可以访问该sendEmailToken功能。

定义登录端点

register函数中定义新的authPlugin登录路由如下:

server.route([
// Endpoint to login or register and to send the short-lived token
{
method: 'POST',
path: '/login',
handler: loginHandler,
options: {
auth: false,
validate: {
payload: Joi.object({
email: Joi.string()
.email()
.required(),
}),
},
},
},
])

注意: options.auth设置为 false 以便一旦您设置默认身份验证策略,端点将保持打开状态,默认情况下,该策略会要求对所有未明确禁用它的路由执行身份验证操作。

在插件的注册函数之外,添加以下内容:

const EMAIL_TOKEN_EXPIRATION_MINUTES = 10

interface LoginInput {
email: string
}

async function loginHandler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
// 👇 get prisma and the sendEmailToken from shared application state
const { prisma, sendEmailToken } = request.server.app
// 👇 get the email from the request payload
const { email } = request.payload as LoginInput
// 👇 generate an alphanumeric token
const emailToken = generateEmailToken()
// 👇 create a date object for the email token expiration
const tokenExpiration = add(new Date(), {
minutes: EMAIL_TOKEN_EXPIRATION_MINUTES,
})

try {
// 👇 create a short lived token and update user or create if they don't exist
const createdToken = await prisma.token.create({
data: {
emailToken,
type: TokenType.EMAIL,
expiration: tokenExpiration,
user: {
connectOrCreate: {
create: {
email,
},
where: {
email,
},
},
},
},
})

// 👇 send the email token
await sendEmailToken(email, emailToken)
return h.response().code(200)
} catch (error) {
return Boom.badImplementation(error.message)
}
}

// Generate a random 8 digit number as the email token
function generateEmailToken(): string {
return Math.floor(10000000 + Math.random() * 90000000).toString()
}

loginHandler执行以下操作:

  • 电子邮件取自请求负载
  • 生成token,然后保存到数据库
  • 在使用connectOrCreate时,如果根据提供的电子邮件地址在数据库中找不到对应的用户,则会新建一个用户。反之,则会为现有用户创建相应的关联关系。
  • SENDGRID_API_KEY令牌将发送到有效负载中的电子邮件地址(如果未设置,则记录到控制台)

最后,在以下位置注册server.ts插件:

import hapiAuthJWT from 'hapi-auth-jwt2'
import authPlugin from './plugins/auth'

await server.register([
// ... existing plugins
hapiAuthJWT,
authPlugin,
])

检查点:

  1. 启动服务器npm run dev
  2. /login使用curl:对端点进行POST 调用curl --header "Content-Type: application/json" --request POST --data '{"email":"test@test.io"}' localhost:3000/login。您应该看到从后端记录的令牌:email token for test@test.io: 27948216

定义身份验证端点

在这个环节,后端能够创建用户,并生成电子邮件令牌,然后通过电子邮件发送出去。不过,需要注意的是,此时生成的令牌还不能发挥其应有的作用。现在,您将通过创建/authenticate端点、根据数据库验证电子邮件令牌以及在标头中向用户返回长期存在的 JWT 身份验证令牌来实现身份验证的第二步。

首先将以下路由声明添加到:

server.route({
method: 'POST',
path: '/authenticate',
handler: authenticateHandler,
options: {
auth: false,
validate: {
payload: Joi.object({
email: Joi.string()
.email()
.required(),
emailToken: Joi.string().required(),
}),
},
},
})

该路线需要emailemailToken。由于仅当合法用户尝试登录时才会知晓这两个,因此,想要猜测出电子邮件和电子邮件令牌就变得更为困难,这也在一定程度上降低了遭受暴力攻击的风险。

接下来,将以下内容添加到:

// Load the JWT secret from environment variables or default
const JWT_SECRET = process.env.JWT_SECRET || 'SUPER_SECRET_JWT_SECRET'

const JWT_ALGORITHM = 'HS256'

const AUTHENTICATION_TOKEN_EXPIRATION_HOURS = 12

interface AuthenticateInput {
email: string
emailToken: string
}

async function authenticateHandler(
request: Hapi.Request,
h: Hapi.ResponseToolkit,
) {
// 👇 get prisma from shared application state
const { prisma } = request.server.app
// 👇 get the email and emailToken from the request payload
const { email, emailToken } = request.payload as AuthenticateInput

try {
// Get short lived email token
const fetchedEmailToken = await prisma.token.findUnique({
where: {
emailToken: emailToken,
},
include: {
user: true,
},
})

if (!fetchedEmailToken?.valid) {
// If the token doesn't exist or is not valid, return 401 unauthorized
return Boom.unauthorized()
}

if (fetchedEmailToken.expiration < new Date()) {
// If the token has expired, return 401 unauthorized
return Boom.unauthorized('Token expired')
}

// If token matches the user email passed in the payload, generate long lived API token
if (fetchedEmailToken?.user?.email === email) {
const tokenExpiration = add(new Date(), {
hours: AUTHENTICATION_TOKEN_EXPIRATION_HOURS,
})
// Persist token in DB so it's stateful
const createdToken = await prisma.token.create({
data: {
type: TokenType.API,
expiration: tokenExpiration,
user: {
connect: {
email,
},
},
},
})

// Invalidate the email token after it's been used
await prisma.token.update({
where: {
id: fetchedEmailToken.id,
},
data: {
valid: false,
},
})

const authToken = generateAuthToken(createdToken.id)
return h.response().code(200).header('Authorization', authToken)
} else {
return Boom.unauthorized()
}
} catch (error) {
return Boom.badImplementation(error.message)
}
}

// Generate a signed JWT token with the tokenId in the payload
function generateAuthToken(tokenId: number): string {
const jwtPayload = { tokenId }

return jwt.sign(jwtPayload, JWT_SECRET, {
algorithm: JWT_ALGORITHM,
noTimestamp: true,
})
}

注:环境变量JWT_SECRET可以通过运行以下命令生成:node -e "console.log(require('crypto').randomBytes(256).toString('base64'));"。这应该始终在生产环境中设置。

该处理程序从数据库中获取电子邮件令牌,确保其有效,在数据库中新建一个API令牌,根据这个数据库中的令牌生成一个JWT令牌,将之前的电子邮件令牌设置为无效状态,最后,将这个JWT令牌通过Authorization标头返回给用户。

检查点:

  1. 启动服务器npm run dev
  2. /login使用curl对端点进行POST调用:curl --header "Content-Type: application/json" --request POST --data '{"email":"test@test.io"}' localhost:3000/login您应该看到从后端记录的令牌:email token for test@test.io: 13080740
  3. 获取该/authenticate令牌并使用curl: 调用端点curl -v --header "Content-Type: application/json" --request POST --data '{"email":"hello@prisma.io", "emailToken": "13080740"}' localhost:3000/authenticate
  4. 响应应该具有200状态并包含类似于以下内容的标头:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbklkIjoyOH0.gJYk3f1RJVKvPh75FdElCHwMoe_ZCMZftTE1Em5PpMg

定义身份验证策略

身份验证策略将定义 Hapi 如何验证对需要身份验证的端点的请求。

要定义身份验证策略,请将以下内容添加到:

// This strategy will be used across the application to secure routes
export const API_AUTH_STATEGY = 'API'

authPlugin.register函数内部添加以下内容:

// Define the authentication strategy which uses the `jwt` authentication scheme
server.auth.strategy(API_AUTH_STATEGY, 'jwt', {
key: JWT_SECRET,
verifyOptions: { algorithms: [JWT_ALGORITHM] },
validate: validateAPIToken,
})

// Set the default authentication strategy for API routes, unless explicitly disabled
server.auth.default(API_AUTH_STATEGY)

最后添加validateAPIToken函数:

const apiTokenSchema = Joi.object({
tokenId: Joi.number().integer().required(),
})

// Function will be called on every request using the auth strategy
const validateAPIToken = async (
decoded: APITokenPayload,
request: Hapi.Request,
h: Hapi.ResponseToolkit,
) => {
const { prisma } = request.server.app
const { tokenId } = decoded
// Validate the token payload adheres to the schema
const { error } = apiTokenSchema.validate(decoded)

if (error) {
request.log(['error', 'auth'], `API token error: ${error.message}`)
return { isValid: false }
}

try {
// Fetch the token from DB to verify it's valid
const fetchedToken = await prisma.token.findUnique({
where: {
id: tokenId,
},
include: {
user: true,
},
})

// Check if token could be found in database and is valid
if (!fetchedToken || !fetchedToken?.valid) {
return { isValid: false, errorMessage: 'Invalid Token' }
}

// Check token expiration
if (fetchedToken.expiration < new Date()) {
return { isValid: false, errorMessage: 'Token expired' }
}

// Get all the courses that the user is the teacher of
const teacherOf = await prisma.courseEnrollment.findMany({
where: {
userId: fetchedToken.userId,
role: UserRole.TEACHER,
},
select: {
courseId: true,
},
})

// The token is valid. Make the `userId`, `isAdmin`, and `teacherOf` to `credentials` which is available in route handlers via `request.auth.credentials`
return {
isValid: true,
credentials: {
tokenId: decoded.tokenId,
userId: fetchedToken.userId,
isAdmin: fetchedToken.user.isAdmin,
// convert teacherOf from an array of objects to an array of numbers
teacherOf: teacherOf.map(({ courseId }) => courseId),
},
}
} catch (error) {
request.log(['error', 'auth', 'db'], error)
return { isValid: false }
}
}

validateAPIToken函数将在使用(您在上一步中设置为默认值)的每个API_AUTH_STATEGY路由之前被调用。

validateAPIToken函数的目的是确定是否允许请求继续进行。这是通过返回对象完成的,该对象包含isValidcredentials

  • isValid:判断token是否验证成功。
  • credentials可用于将有关用户的信息传递给请求对象。传递给的对象credentials可以在路由处理程序中通过request.auth.credentials访问。

在这种情况下,我们首先会检查数据库中是否存在该令牌,并且确认它仍然有效且未过期。如果条件满足,我们将会获取该用户作为教师所教授的课程信息(这些信息将用于后续的授权判断),然后将这些信息连同tokenIduserId一起,封装到credentials的isAdmin对象中返回。

大多数端点需要身份验证(因为默认的身份验证策略),但仍然没有授权规则。这意味着,如果您想要访问GET /courses端点,现在需要在Authorization标头中携带一个有效的JWT令牌。

检查点:

  1. 启动服务器npm run dev
  2. /courses使用curl:对端点进行 GET 调用curl -v localhost:3000/courses。您应该收到带有以下响应的 401 状态代码:{"statusCode":401,"error":"Unauthorized","message":"Missing authentication"}
  3. 使用来自最后一个检查点的令牌对标头进行另一次调用,如下所示:curl -H "Authorization:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbklkIjoyOH0.gJYk3f1RJVKvPh75FdElCHwMoe_ZCMZftTE1Em5PpMg" localhost:3000/courses并且请求应该成功

恭喜,您已成功实施基于电子邮件的无密码身份验证并保护端点。接下来,您将定义授权规则。

添加授权

后端的授权模型将定义允许用户执行的操作。换句话说,他们允许对哪些实体执行操作。

授予用户权限的主要属性是:

  • 用户是否是管理员(如isAdmin在用户模型中的字段所示)?如果是,那么他们将被允许执行所有操作。
  • 用户是否是某门课程的老师?如果是的话,那么该用户将被允许对该课程的所有特定资源(例如测试、测试结果以及注册信息)执行CRUD操作。

如果用户既不是课程的管理员也不是教师,他们仍然应该能够执行以下操作:创建新课程、作为学生在现有课程上注册、获取自己的测试结果、以及获取和更新自己的用户个人资料。

注意:此方法混合了两种授权方法,即基于角色的授权和基于资源的授权。从课程注册中获取权限是一种基于资源的授权形式。这意味着操作是根据特定资源进行授权的,即以教师身份注册课程允许用户创建相关测试并提交测试结果。另一方面,为管理员用户授权操作(即将isAdmin设置为true)属于基于角色的授权方式,其中用户被赋予了“admin”这一角色。

端点的授权规则

为了实施建议的授权规则,我们将首先重新访问具有建议的授权规则的端点列表:

HTTP方法路线描述授权规则
POST/login开始登录/注册并发送电子邮件令牌打开
POST/authenticate验证用户身份并创建 JWT 令牌打开(需要电子邮件令牌)
GET/profile获取经过身份验证的用户个人资料任何经过身份验证的用户
POST/users创建用户仅管理员
GET/users/{userId}获取用户仅管理员或经过身份验证的用户
PUT/users/{userId}更新用户仅管理员或经过身份验证的用户
DELETE/users/{userId}删除用户仅管理员或经过身份验证的用户
GET/users获取用户仅管理员
GET/users/{userId}/courses获取用户的课程注册信息仅管理员或经过身份验证的用户
POST/users/{userId}/courses将用户注册到课程(作为学生或教师)仅管理员或经过身份验证的用户
DELETE/users/{userId}/courses/{courseId}删除用户的课程注册仅管理员或经过身份验证的用户
POST/courses创建课程任何经过身份验证的用户
GET/courses获取课程任何经过身份验证的用户
GET/courses/{courseId}获取课程任何经过身份验证的用户
PUT/courses/{courseId}更新课程当然只有管理员或老师
DELETE/courses/{courseId}删除课程当然只有管理员或老师
POST/courses/{courseId}/tests为课程创建测试当然只有管理员或老师
GET/courses/tests/{testId}进行测试任何经过身份验证的用户
PUT/courses/tests/{testId}更新一个测试当然只有管理员或老师
DELETE/courses/tests/{testId}删除测试当然只有管理员或老师
GET/users/{userId}/test-results获取用户的测试结果仅管理员或经过身份验证的用户
POST/courses/tests/{testId}/test-results为与用户关联的测试创建测试结果当然只有管理员或老师
GET/courses/tests/{testId}/test-results获取一次测试的多个测试结果当然只有管理员或老师
PUT/courses/tests/test-results/{testResultId}更新测试结果(与用户和测试关联)仅测试管理员或评分者
DELETE/courses/tests/test-results/{testResultId}删除测试结果仅测试管理员或评分者

注意:包含在{}中的参数的路径,例如{userId}表示在 URL 中插入的变量。

Hapi 授权

Hapi 路由具有函数的概念pre,允许将处理程序逻辑分解为较小且可重用的函数。pre函数会在处理程序执行之前被调用,它允许我们提前接管响应,并在必要时返回未授权的错误信息。在授权的场景中,这一点特别有用,因为上表中提出的许多授权规则对于多个路由/端点来说都是相同的,可以通过pre函数进行统一的权限检查。例如,检查用户是否是管理员对于路由POST /usersGET /users路由都是相同的。这允许您重用单个isAdmin预置函数并分配给两个端点。

添加对用户端点的授权

在这一部分中,您将定义pre函数来实现不同的授权规则。您将从三个/users/{userId}端点(GETPOSTDELETE)开始,如果发出请求的用户是管理员或者用户正在请求自己的管理员,则应该对这三个端点进行授权。

注意: Hapi 还提供了一种使用scope以声明方式实现基于角色的身份验证的方法。然而,对于那些基于资源的授权方法(即用户的权限取决于他们所请求的特定资源)来说,我们需要实现更精细的控制,而这仅仅通过作用域是无法实现的。因此,我们采用了pre函数来实现这一需求。

要添加前置函数来验证GET /users/{userId}路由中的授权规则,请在src/plugins/user.ts中声明以下函数:

// Pre-function to check if the authenticated user matches the requested user
export async function isRequestedUserOrAdmin(request: Hapi.Request, h: Hapi.ResponseToolkit) {
// 👇 userId and isAdmin are populated by the `validateAPIToken` function
const { userId, isAdmin } = request.auth.credentials

if (isAdmin) {
// If the user is an admin allow
return h.continue
}

const requestedUserId = parseInt(request.params.userId, 10)

// 👇 Check that the requested userId matches the authenticated userId
if (requestedUserId === userId) {
return h.continue
}

// The authenticated user is not authorized
throw Boom.forbidden()
}

然后将 pre 选项添加到src/plugins/user.ts路由定义中,如下所示:

{
method: 'GET',
path: '/users/{userId}',
handler: getUserHandler,
options: {
pre: [isRequestedUserOrAdmin],
auth: {
mode: 'required',
strategy: API_AUTH_STATEGY,
},
validate: {
params: Joi.object({
userId: Joi.number().integer(),
}),
},
},
}

现在,前置函数getUserHandler将在之前调用,并且仅向管理员或请求自己的 userId 的用户授予访问权限。

注意:在上一部分中,您已经定义了默认身份验证策略,因此options.auth并不严格要求进行定义。但是,为每个路由明确设定身份验证要求是一种非常推荐的做法。

检查点:为了验证授权逻辑是否已正确实现,您将创建一个测试用户和测试管理员并调用/users/{userId}端点:

  1. 启动服务器npm run dev
  2. 运行seed-users脚本创建测试用户并测试管理员。您应该得到与此类似的结果:
Created test user	id: 1 | email: test@prisma.io
Created test admin id: 2 | email: test-admin@prisma.io
  1. 通过调用test@prisma.io端点来登录POST /login,如下所示:
curl --header "Content-Type: application/json" --request POST --data '{"email":"test@prisma.io"}' localhost:3000/login
  1. 获取记录的令牌并使用curl调用端点:
curl -v --header "Content-Type: application/json" --request POST --data '{"email":"test@prisma.io", "emailToken": "TOKEN_FROM_CONSOLE"}' localhost:3000/authenticate
  1. 响应应该具有200状态并包含Authorization类似于以下内容的标头:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbklkIjoyOH0.gJYk3f1RJVKvPh75FdElCHwMoe_ZCMZftTE1Em5PpMg
  2. 使用包含来自最后一个检查点的令牌的标头进行 GET 调用/users/1(其中号码是在检查点第一步中创建的测试用户),如下所示:curl -H "Authorization:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbklkIjoyOH0.gJYk3f1RJVKvPh75FdElCHwMoe_ZCMZftTE1Em5PpMg" localhost:3000/users/1请求应该成功,并且您应该看到用户配置文件。
  3. /users/2使用相同的授权标头进行另一个 GET 调用: curl -H "Authorization:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbklkIjoyOH0.gJYk3f1RJVKvPh75FdElCHwMoe_ZCMZftTE1Em5PpMg" localhost:3000/users/2。此操作应该会失败并出现 403 禁止错误。

如果所有步骤均成功,前置功能将正确授权用户访问自己的用户配置文件。若要测试管理功能,请从第三步开始重新操作,但此次请使用电子邮件test-admin@prisma.io以管理员身份进行登录。管理员应该能够获取两个用户配置文件。

将授权前置功能移至单独的模块

到目前为止,您已经定义了isRequestedUserOrAdmin授权前置功能并将其添加到GET /users/{userId}路线中。要在不同的路线中使用此功能,请将该功能从src/plugins/users.ts移至单独的模块:src/auth-helpers.ts。该模块将允许您将授权逻辑组织在一个地方,并将其重用于不同插件中定义的路由,例如:user-enrollment.ts.

isRequestedUserOrAdmin函数移至auth-helpers.ts后,将其作为前置函数添加到以下路由,这些路由具有相同的授权逻辑:

模块路线
src/plugins/users.tsDELETE /users/{userId}
src/plugins/users.tsPUT /users/{userId}
src/plugins/users-enrollment.tsGET /users/{userId}/courses
src/plugins/users-enrollment.tsPOST /users/{userId}/courses
src/plugins/users-enrollment.tsDELETE /users/{userId}/courses
src/plugins/test-results.tsGET /users/{userId}/test-results

添加对课程特定端点的授权

教师应该具有更新课程以及为其所教授并担任管理员的课程创建测试的能力。在此步骤中,您将创建另一个前置函数来验证这一点。

auth-helpers.ts中定义以下预置函数:

export async function isTeacherOfCourseOrAdmin(
request: Hapi.Request,
h: Hapi.ResponseToolkit,
) {
// 👇 isAdmin and teacherOf are populated by the `validateAPIToken` function
const { isAdmin, teacherOf } = request.auth.credentials

if (isAdmin) {
// If the user is an admin allow
return h.continue
}

const courseId = parseInt(request.params.courseId, 10)

// Verify that the authenticated user is a teacher of the requested course
if (teacherOf?.includes(courseId)) {
return h.continue
}
// If the user is not a teacher of the course, deny access
throw Boom.forbidden()
}

前置teacherOf函数使用获取的数组validateAPIToken来检查用户是否是所请求课程的教师。

将其作为预置功能添加到以下路由中:

模块路线
src/plugins/courses.tsPUT /courses/{courseId}
src/plugins/courses.tsDELETE /courses/{courseId}
src/plugins/tests.tsPOST /courses/{courseId}/tests

通过添加以下内容来更新表中的路由:

options: {
pre: [isTeacherOfCourseOrAdmin],
// ... other route options
}

您现在已经实现了两个不同的授权规则,并将其作为前置功能添加到后端的十个不同路由中。

更新测试

在 REST API 中实现身份验证和授权后,测试将失败,因为路由现在要求对用户进行身份验证。在此步骤中,您将调整测试以考虑身份验证。

例如,GET /users/{userId}端点有以下测试:

test('get user returns user', async () => {
const response = await server.inject({
method: 'GET',
url: `/users/${userId}`,
})
expect(response.statusCode).toEqual(200)
const user = JSON.parse(response.payload)

expect(user.id).toBe(userId)
})

如果您现在运行此测试,则npm run test -- -t="get user returns user"测试将失败。这是因为测试当请求到达端点时它不满足其身份验证要求。通过使用Hapi的server.inject方法(该方法用于模拟对服务器的HTTP请求),您可以添加一个auth对象,该对象包含了有关已通过身份验证的用户的信息。该对象设置凭证对象,就像在中的函数auth中一样,例如:

test('get user returns user', async () => {
const response = await server.inject({
method: 'GET',
url: `/users/${testUser.id}`,
auth: {
strategy: API_AUTH_STATEGY,
credentials: {
userId: testUser.id,
tokenId: // TODO: create the token and pass it here
isAdmin: // TODO: set this based on the test user
teacherOf: // TODO: set this based on the test user,
},
},
})
expect(response.statusCode).toEqual(200)
const user = JSON.parse(response.payload)

expect(user.id).toBe(testUserCredentials.userId)
})

传递的对象与定义的接口credentials匹配:

interface AuthCredentials {
userId: number
tokenId: number
isAdmin: boolean
teacherOf: number[]
}

注意: TypeScript 中的接口与类型非常相似,但有一些细微的差别。

为了让测试通过,您将在测试中直接使用 Prisma 创建用户并构造AuthCredentials对象,如下所示:

test('get user returns user', async () => {
const testUser = await server.app.prisma.user.create({
data: {
email: `test-${Date.now()}@test.com`,
isAdmin: false,
tokens: {
create: {
expiration: add(new Date(), { days: 7 }),
type: TokenType.API,
},
},
},
include: {
tokens: true,
},
})

const testUserCredentials = {
userId: testUser.id,
tokenId: testUser.tokens[0].id,
isAdmin: testUser.isAdmin,
teacherOf: [], // empty array because no courses were created for the user
}

const response = await server.inject({
method: 'GET',
url: `/users/${testUserCredentials.userId}`,
auth: {
strategy: API_AUTH_STATEGY,
credentials: testUserCredentials,
},
})
expect(response.statusCode).toEqual(200)
const user = JSON.parse(response.payload)

expect(user.id).toBe(testUserCredentials.userId)
})

检查点:运行npm run test -- -t="get user returns user"以验证测试是否通过。

至此,您已经修复了一项测试,但是其他测试呢?由于大多数测试都需要创建凭据对象,因此您可以将其抽象为单独的test-helpers.ts模块:

// Helper function to create a test user and return the credentials object the same way that the auth plugin does
export const createUserCredentials = async (
prisma: PrismaClient,
isAdmin: boolean,
): Promise<AuthCredentials> => {
const testUser = await prisma.user.create({
data: {
email: `test-${Date.now()}@test.com`,
isAdmin,
tokens: {
create: {
expiration: add(new Date(), { days: 7 }),
type: TokenType.API,
},
},
},
include: {
tokens: true,
courses: {
where: {
role: UserRole.TEACHER,
},
select: {
courseId: true,
},
},
},
})

return {
userId: testUser.id,
tokenId: testUser.tokens[0].id,
isAdmin: testUser.isAdmin,
teacherOf: testUser.courses?.map(({ courseId }) => courseId),
}
}

下一步,编写一个测试来验证授权规则,允许管理员通过GET /users/{userId}端点获取不同的用户帐户。

摘要和后续步骤

恭喜您成功到达这一步!本文广泛涉及了多个概念,从身份验证和授权的基本原理出发,进而探讨了如何使用Prisma、Hapi以及JWT来实现基于电子邮件的无密码身份验证方案。最后,您使用 Hapi 的预置函数实现了授权规则。您还创建了一个电子邮件插件,为后端提供使用 SendGrid 的 API 发送电子邮件的功能。

auth插件封装了认证流程的两条路由,并使用jwt认证方案定义认证策略。在身份验证策略的验证函数中,您根据数据库检查了令牌,并使用与授权规则相关的信息填充凭据对象。

您还执行了数据库迁移,并引入了一个与Prisma Migrate的表具有n-1关系的新Token表。

ypeScript 确实在自动完成代码和验证类型正确使用方面提供了很大的帮助,而且它还能确保这些类型与数据库模式保持同步。

您广泛地使用了Prisma客户端来从数据库中检索数据并持有这些数据。

本文介绍了对所有端点的子集的授权。作为后续步骤,您可以执行以下操作:

  • 其余路由添加授权的原则相同。
  • 将凭证对象添加到所有测试中。
  • 生成并设置JWT_SECRET环境变量。
  • 设置SENDGRID_API_KEY环境变量并测试电子邮件功能。

您可以在GitHub上找到完整的源代码,其中包含已实施的所有端点的授权规则以及改编的测试。

尽管Prisma的设计初衷是为了简化关系数据库的使用,但深入理解底层数据库以及身份验证的基本原理仍然是非常有价值的。

原文链接:https://www.prisma.io/blog/backend-prisma-typescript-orm-with-postgresql-auth-mngp1ps7kip4

#你可能也喜欢这些API文章!