所有文章 > 日积月累 > 使用 TypeScript、PostgreSQL 和 Prisma 构建后端:认证与授权

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

在本系列的第三篇章中,我们将探讨如何利用Prisma进行令牌存储,借助无密码身份验证机制来保护REST API,并实现授权功能。

使用 TypeScript、PostgreSQL 和 Prisma 的后端:身份验证和身份验证

介绍

本系列文章旨在通过解决一个具体问题——在线课程评分系统,来深入探索和展示现代后端开发中的不同模式、挑战及架构。该系统之所以成为一个很好的示例,是因为它包含了多种类型的关系,并且足够复杂,能够代表实际应用场景。

本系列将涵盖的内容

本系列将着重探讨数据库在后端开发中所扮演的各个角色,具体涵盖以下方面:

主题部分
数据建模第 1 部分
CRUD 系列第 1 部分
聚合第 1 部分
REST API 层第 2 部分
验证第 2 部分
测试第 3 部分
无密码身份验证第 3 部分 (current)
授权第 3 部分 (current)
与外部 API 集成第 3 部分 (current)
部署即将开始

您今天将学到的内容

在第一篇文章中,您为问题域精心设计了数据模型,并编写了一个利用Prisma Client将数据持久化到数据库的种子脚本。

本系列的第二篇文章中,您基于第一篇文章中的数据模型和Prisma架构,构建了一个REST API。您采用happy框架来创建这个API,它支持通过HTTP请求对资源进行CRUD(创建、读取、更新、删除)操作。

而本系列的第三篇文章,将深入探讨身份验证与授权的核心概念及它们之间的区别。您还将学习如何利用JSON Web Token(JWT)和happy框架,来实现基于电子邮件的无密码身份验证与授权,从而保护您的REST API。

具体来说,您将发展以下几个方面:

  1. 无密码身份验证:添加通过发送具有唯一令牌的电子邮件来登录和注册的功能。用户通过将通过电子邮件收到的令牌发送到 API 并取回长期 JWT 令牌来完成身份验证过程,从而授予对需要身份验证的 API 终端节点的访问权限。
  2. 授权:添加授权逻辑以限制用户可以访问和操作的资源。

在本文结束时,REST API将通过身份验证机制保护,确保仅授权用户可访问REST端点。此外,将采用happy’s pre-route选项,根据特定用户的权限来授予访问权限。一个包含所有端点授权规则的API将在本GitHub存储库中提供。

注意:在整个指南中,您将找到各种检查点,这些检查点使您能够验证是否正确执行了这些步骤。

先决条件

假定知识

本系列教程假定您已经掌握了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 码。

由于Web应用程序难以验证用户是否真正拥有某张卡,因此通常会在用户名/密码的基础上,通过身份验证器应用程序(安装在智能手机上的程序或专门用于生成密码的设备)生成的一次性令牌来增强安全性。

在本文中,您将实现一种基于电子邮件的无密码身份验证方法——这是一种旨在提升用户体验和安全性的两步验证流程。其运作机制是在用户尝试登录时,向其电子邮件账户发送一个密钥令牌。用户打开电子邮件,将令牌输入到应用程序中,应用程序随后对用户进行身份验证,确认其为电子邮件账户的所有者。

此方法依赖于用户的电子邮件服务提供商,我们假定该服务已经对用户进行了身份验证。用户体验因此得到提升,因为用户无需设置和记忆密码。同时,随着应用程序不再承担密码管理的责任,安全性也得到了增强,这原本可能是一个易受攻击的环节。

将身份验证任务交给用户的电子邮件账户意味着应用程序将继承用户电子邮件账户安全性的优点和不足。但值得庆幸的是,如今大多数电子邮件服务都提供了第二因素身份验证和其他安全措施的选项。

尽管如此,这种方法仍能有效避免用户选择弱密码或在多个网站上重复使用同一密码的问题。完全摒弃密码意味着这些用户将更加安全,因为不再存在可能被猜测、暴力破解或盗取的密码。

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

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

身份验证流程将如下所示:

  1. 用户通过向API的/login终端节点发送包含电子邮件的负载来启动身份验证流程。
  2. 如果电子邮件是新的,则会在 User 表中创建用户。
  3. 电子邮件令牌由后端生成并保存在 Token 表中
  4. 电子邮件令牌将发送到用户的电子邮件
  5. 用户需要将通过电子邮件接收到的电子邮件令牌和电子邮件地址再次发送到/authenticate终端节点。
  6. 后端验证用户发送的电子邮件令牌。如果有效且令牌尚未过期,则会生成 JWT 令牌并将其保存在 Token 表中。
  7. 然后,JWT令牌会通过Authorization标头返回给用户。

有两种令牌类型:

  1. 电子邮件令牌:一个 8 位数的数字令牌,有效期较短,例如 10 分钟,并发送到用户的电子邮件。该令牌的唯一用途是验证用户是否与电子邮件关联,这意味着它不会授予对任何与评分应用程序相关的端点的访问权限。
  2. 身份验证令牌:负载中包含的JWT令牌。此令牌可用于访问受保护的终端节点,用户只需在向API发出请求时,将其添加到请求标头中即可。该令牌的有效期较长,可达12小时。

采用此身份验证策略,登录和注册流程可以通过单个终端节点来处理。这是因为登录和注册之间的唯一区别在于是否需要在User表中创建新记录(即用户是否已存在)。

JSON Web 令牌

JSON Web 令牌(JWT)是一种开放且标准的方法,用于在双方之间安全地传递声明。该标准定义了一种紧凑且自包含的方式,允许将信息作为JSON对象在各方之间安全地传输。这些信息是经过数字签名的,因此可以验证其真实性并建立信任。

JWT令牌由使用Base64编码的三个部分组成:header(头部)、payload(负载)和signature(签名),它们之间用点(.)分隔。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ0b2tlbklkIjo5fQ.
FkKMzLobPl_MaQHB7hRG3nZQZ-ME4lRaanGJVnLMa84

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

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

  • 页眉:{"alg":"HS256","typ":"JWT"}
  • 有效载荷:{"tokenId":9}

令牌的签名部分是通过应用签名算法(本例为HS256)并结合标头、有效负载及一个后端专有的密钥来生成的。这个密钥对于验证令牌的真实性至关重要,且仅后端知晓。

在本文中,JWT被用作长期身份验证令牌。令牌的有效负载中包含一个tokenId,该tokenId会存储在数据库中,并与为其生成令牌的用户相关联。这样,后端就能通过tokenId找到对应的用户。

注意:这种方法称为有状态 JWT,其中令牌引用存储在数据库中的会话。虽然这意味着对请求进行身份验证需要往返数据库,这会增加处理请求所需的时间,但这种方法更安全,因为后端可以撤销令牌。

将令牌模型添加到 Prisma 架构

为了将令牌存储在数据库中以便进行验证,您需要将令牌模型添加到Prisma架构中。在此步骤中,您还需更新Prisma架构中的其他模型,以使某些字段成为可选字段。接下来,请打开位于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
}

让我们回顾一下引入的变化:

  • 启用connectOrCreate和transactionApi预览功能。这些将在后续步骤中使用。
  • 移除了自Prisma 2.5.0版本起已稳定的aggregateApi预览功能。
  • User模型中,firstNamelastName字段现在被设置为可选,这允许用户仅使用电子邮件进行登录或注册。
  • 添加了一个新的Token模型。每个用户可以与多个Token相关联,形成1对多的关系。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 Client。prisma migrate dev

添加电子邮件发送功能

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

在本文中,我们将利用SendGrid服务和其npm包来轻松实现与SendGrid API的集成。

添加依赖项

npm install --save @sendgrid/mail

创建电子邮件插件

在文件夹中创建一个名为 的新文件:email.tssrc/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} `)
}

该插件将在对象上公开一个函数,该函数在整个路由处理程序中均可访问。它会使用环境变量(您在生产环境中需通过SendGrid控制台中的密钥来设置该变量)来工作。在开发阶段,您可以不设置该变量,此时令牌将被记录,而不是通过电子邮件发送。这个环境变量是SENDGRID_API_KEY,并且与sendEmailToken功能相关联,该功能是在server.app上公开的。

最后一步是在server.ts文件中注册这个插件。

import emailPlugin from './plugins/email'

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

使用 Happy 添加身份验证

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

随后,为了保护API,您将定义一个身份验证策略,该策略将利用hapi-auth-jwt2库。

请注意,happy框架中的身份验证机制基于方案和策略的概念。方案是处理身份验证的一种手段,而策略则是方案的预配置实例。在本文中,您只需根据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的插件,以封装身份验证相关的逻辑。

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

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-jwt2以及email插件。其中,prisma插件是在本系列第2部分中定义的,用于访问Prisma客户端;hapi-auth-jwt2插件定义了身份验证方案,您将基于它来实现自定义的身份验证策略;而email插件则确保您能够发送电子邮件。此外,sendEmailToken功能也将在身份验证过程中被调用。

定义登录端点

接下来,在registerauthPlugin函数中,您需要定义一个新的登录路由。

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,这样在您设定了默认的身份验证策略之后,终端节点会保持开放状态。默认情况下,该策略会要求对所有未明确禁用身份验证的路由进行验证。

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

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执行以下操作:

  • 电子邮件取自请求有效负载
  • 生成令牌,然后将其保存到数据库中
  • 利用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. 通过curl/loginemail终端节点进行POST调用。您应该能在后端日志中看到记录的令牌,例如:curl --header "Content-Type: application/json" --request POST --data '{"email":"test@test.io"}' localhost:3000/loginemail,输出可能是token for test@test.io: 27948216

定义身份验证端点

到目前为止,后端已经能够创建用户、生成电子邮件令牌并通过电子邮件发送给用户。但是,这些生成的令牌还不能用于身份验证。接下来,您将通过创建/authenticateauthorization终端节点来实施身份验证的第二步。这个端点将根据数据库中的记录验证电子邮件令牌,并在验证成功后,在响应头中向用户返回一个长期的JWT身份验证令牌。

首先,您需要在authPlugin中添加以下路由声明。

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这两个参数。由于仅合法尝试登录的用户才知道这两个值,因此这增加了猜测emailemailToken的难度,从而降低了遭受暴力攻击(如猜测八位数令牌)的风险。

接下来,请将以下内容添加到auth.ts文件中。

//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_SECRETnode -e "console.log(require('crypto').randomBytes(256).toString('base64'));"

处理程序从数据库中获取电子邮件令牌,确保其有效,在数据库中创建新的 API 令牌,生成 JWT 令牌(引用数据库中的令牌),使电子邮件令牌失效,并在Authorization标头中返回令牌。

检查站:

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

定义身份验证策略

身份验证策略将定义 happy 如何验证对需要身份验证的端点的请求。在此步骤中,您将通过使用 JWT 令牌中的从数据库中获取有关用户的信息,定义tokenId使用 JWT 令牌验证请求的逻辑。

要定义身份验证策略,请将以下内容添加到auth.ts文件中:

// 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_STRATEGY来确定是否允许请求继续执行。它通过返回一个对象来完成这一决定,该对象包含isValidcredentials两个属性。

  • isValid:用于指示令牌是否已成功验证。
  • credentials:可用于将用户相关信息传递给Request对象。这些传递给credentials的信息可以通过request.auth.credentials在路由处理程序中访问。

在此场景中,我们确定如果令牌存在于数据库中且未过期,则视为有效。若令牌有效,我们会获取该令牌对应的用户所教授的课程(这些信息将用于后续的授权判断),并将其与用户ID(userId)、令牌ID(tokenId)以及用户是否为管理员(isAdmin)的信息一起传递给credentials对象。

由于默认的身份验证策略,大多数终端节点都需要进行身份验证。这意味着,要访问这些终端节点,您现在需要在请求的标头中提供有效的JWT令牌。例如,要访问GET /courses端点,您就需要在Authorization标头中包含有效的JWT令牌。

检查站:

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

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

添加授权

后端的授权模型旨在明确用户可执行的操作及其针对的实体。主要基于以下属性授予用户权限:

  • 首先,用户是否拥有管理员身份(这在用户模型的相应字段中有所体现)。若具备管理员身份,用户将被赋予执行所有操作的权限。
  • 用户是课程的教师吗?如果是这样,将允许用户对所有课程特定的资源(如测试、测试结果和注册)执行 CRUD 操作。

如果用户不是课程的管理员或教师,他们仍应能够创建新课程、以学生身份注册现有课程、获取测试结果以及获取和更新其用户个人资料。

注意:这种方法混合了两种授权方法,即基于角色的授权和基于资源的授权。其中,从课程注册中获取权限属于基于资源的授权,即根据特定资源来授予操作权限,例如以教师身份注册课程后,用户有权创建相关测试并提交测试结果。而赋予admin用户操作权限(将其设置为true)则属于基于角色的授权,即用户因具备“admin”角色而获得相应权限。

终端节点的授权规则

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

HTTP 方法路线描述授权规则
POST/login开始登录/注册并发送电子邮件令牌打开
POST/authenticate对用户进行身份验证并创建 JWT 令牌Open (需要 email token)
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}Update a testOnly admin or teacher of course
DELETE/courses/tests/{testId}Delete a testOnly admin or teacher of course
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中的变量插值。例如,在www.myapi.com/users/13中,13就是{userId}的插值。

与 Happy 一起授权

Happy routes具有函数的概念,这使得Handler logic可以被分解为更小且可重用的函数。这些函数在处理程序之前被调用,并允许接管响应,以便在需要时返回未经授权的错误。这在授权上下文中非常有用,因为对于多个路由/终端节点,上表中建议的许多授权规则都是相同的。例如,对于POST /usersGET /users路由,检查用户是否是管理员的规则是相同的。因此,您可以定义一个预函数(pre-function),并将其分配给这两个端点,以实现重用。

向 users 端点添加授权

在这一部分,您将定义函数来实现不同的授权规则。您将从三个端点(GET /users/{userId}POST /usersDELETE /users/{userId})开始。如果发出请求的用户是管理员,或者用户正在请求自己的资源(即,用户的ID与请求中指定的ID相匹配),则这些端点应被授权。

注意:Happy还提供了一种使用Scopes以声明方式实现基于角色的身份验证的方法。然而,对于拟议的基于资源的授权方法(其中用户的权限取决于请求的特定资源),需要更精细的控制,而Scopes无法满足这一需求。因此,在这种情况下,我们选择了使用函数来实现授权规则。

要添加 pre-function 来验证路由中的授权规则,请在src/plugins/user.ts文件中为GET /users/{userId}路由添加相应的预函数。

// 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()
}

然后,请按照以下方式在src/plugins/user.ts文件中为路由定义添加pre选项。

{
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之前调用该预函数(pre-function),并且仅向管理员或请求自己userId的用户授予访问权限。

注意:在上一部分中,您已经定义了默认的身份验证策略,因此严格来说,显式定义每个路由的身份验证要求并不是必需的。但出于清晰性和安全性的考虑,最好还是显式地定义每个路由的身份验证要求。

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

  1. 使用 启动 服务器npm run dev
  2. 运行脚本以创建测试用户和测试管理员: 。您应该得到类似于这样的结果:seed-usersnpm run seed-users

请注意,这里的“结果”部分仅作为示例,实际运行脚本时得到的输出可能会有所不同。

Created test user	id: 1 | email: test@prisma.io
Created test admin id: 2 | email: test-admin@prisma.io
  1. 通过向/login终端节点发送请求来登录,示例邮箱为test@prisma.io,请求方法为POST
curl --header "Content-Type: application/json" --request POST --data '{"email":"test@prisma.io"}' localhost:3000/login
  1. 获取记录的令牌使用curl命令并携带该令牌来调用/authenticate终端节点。
curl -v --header "Content-Type: application/json" --request POST --data '{"email":"test@prisma.io", "emailToken": "TOKEN_FROM_CONSOLE"}' localhost:3000/authenticate
  1. 响应应具有 status 并包含一个类似于以下内容的标头:200AuthorizationeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbklkIjoyOH0.gJYk3f1RJVKvPh75FdElCHwMoe_ZCMZftTE1Em5PpMg
  2. 使用包含最后一个检查点的令牌的标头进行 GET 调用(其中数字是在检查点的第一步中创建的测试用户),如下所示:请求应该成功,您应该会看到用户配置文件。/users/1Authorizationcurl -H "Authorization:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbklkIjoyOH0.gJYk3f1RJVKvPh75FdElCHwMoe_ZCMZftTE1Em5PpMg" localhost:3000/users/1
  3. 使用相同的授权标头再次进行 GET 调用: 。这个应该会失败,并出现 403 forbidden 错误。/users/2curl -H "Authorization:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbklkIjoyOH0.gJYk3f1RJVKvPh75FdElCHwMoe_ZCMZftTE1Em5PpMg" localhost:3000/users/2

若所有步骤均顺利完成,预函数将正确地为用户授权,使其能够访问自己的用户配置文件。为了测试管理员功能,请从第三步开始重新操作,但此次需使用测试管理员的电子邮件地址(例如test-admin@prisma.io)进行登录。成功登录后,管理员应该能够访问这两个用户的配置文件,这得益于isRequestedUserOrAdmin函数的验证。

将授权前置函数移至单独的模块

到目前为止,您已经在路由中定义了authorization前置函数isRequestedUserOrAdmin,并将其应用到了特定的路由上。为了在不同路由中复用该授权逻辑,建议您将这个函数从src/plugins/users.ts文件中移动到一个独立的模块,命名为src/auth-helpers.ts。这样做的好处是,您可以将授权相关的逻辑集中管理,并且方便在多个插件中定义的路由(例如user-enrollment.ts)中重用。

完成函数迁移后,您需要在具有相同授权需求的路由中,将isRequestedUserOrAdmin作为前置函数进行添加,这些路由可能包括GET /users/{userId}以及GET /users/{userId}/courses等。

模块路线
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

向课程特定端点添加授权

教师应该能够更新课程并为他们担任教师和管理员的课程创建测试。在此步骤中,您将创建另一个 pre-function 来验证这一点。

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

请将isTeacherOfCourseOrAdmin作为前置条件添加到相关路由配置中。

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

为更新路由表,您需要在路由的options.pre字段中添加这个前置条件。

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

如果现在运行此测试,测试将会失败。这是因为当请求到达终端节点时,测试请求不满足其身份验证要求。为了使用Happy服务器的测试功能(npm run test -- -t="get user returns user"),并模拟对服务器的HTTP请求,您可以利用server.inject方法。该方法允许您添加一个包含已验证用户信息的对象,该对象会设置凭据,就像它们在src/plugins/auth.ts文件中的validateAPITokens函数中所做的那样。

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

传递的对象需与src/plugins/auth.ts中定义的AuthCredentials接口相匹配,这里的接口指的是credentials

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

注意:TypeScript 中的接口与类型非常相似,但有一些细微的差异。要了解更多信息,请查看 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"

此时,您已经成功修复了一个测试。但是,考虑到其他测试也可能需要创建credentials对象,为了提高代码的可重用性和可维护性,您可以将创建credentials对象的逻辑抽象到一个单独的模块中,命名为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、happy框架以及JWT技术来实现基于电子邮件的无密码身份验证机制。最终,您借助happy框架的前置函数功能,成功实施了授权规则。此外,您还开发了一个电子邮件插件,该插件通过集成SendGrid API,为后端服务提供了邮件发送功能。

auth插件整合了两个关键路由,以形成完整的身份验证流程,并借助身份验证方案来明确身份验证策略。在身份验证策略的validate函数中,您会查询数据库以验证令牌的有效性,并填充credentials对象,该对象包含了与授权规则紧密相关的信息,这些信息是基于JWT生成的。

您已经执行了数据库迁移操作,并利用Prisma Migrate功能引入了一张新表,该表与另一张表存在n-1的关系,即Token表与用户(User)表之间的关系。

在整个开发过程中,TypeScript发挥了至关重要的作用,它不仅提供了自动补全功能,还确保了类型的正确使用,从而有效保障了代码与数据库架构之间的一致性。

您广泛使用 Prisma Client 来获取和保存数据库中的数据。

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

  • 按照已介绍的原则,向剩余的路由添加授权逻辑。
  • 在所有测试中添加credential对象,以确保测试能够通过身份验证。
  • 生成并设置JWT_SECRET环境变量。
  • 设置SENDGRID_API_KEY环境变量,并测试电子邮件功能。

您可以在GitHub上找到完整的源代码。该代码包含了所有已实施端点的授权规则,并对测试进行了相应调整。

虽然Prisma旨在简化关系数据库的使用,但了解底层数据库和身份验证的基本原则也同样重要。

如果您有任何疑问,请随时在Twitter上与我们联系。

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

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