2022 年 4 月 26 日

使用 Remix、Prisma 和 MongoDB 构建全栈应用程序:身份验证

15 分钟阅读

欢迎来到本系列的第二篇文章,您将在此学习如何从头开始使用 MongoDB、Prisma 和 Remix 构建全栈应用程序!在这一部分中,您将为您的 Remix 应用程序设置基于会话的身份验证。

Build A Fullstack App with Remix, Prisma & MongoDB: Authentication

目录

简介

在本系列的上一部分中,您设置了 Remix 项目并启动并运行了 MongoDB 数据库。您还配置了 TailwindCSS 和 Prisma,并开始在您的 schema.prisma 文件中建模 User 集合。

在这一部分中,您将在应用程序中实现身份验证,允许用户创建帐户并通过登录和注册表单登录。

注意:此项目的起点可在 GitHub 存储库的 part-1 分支中找到。如果您想查看这一部分的最终结果,请前往 part-2 分支。

开发环境

为了跟随提供的示例,您需要...

注意:可选扩展为 Tailwind 和 Prisma 添加了一些非常好的智能感知和语法高亮显示。

设置登录路由

您需要做的第一件事是设置一个 /login 路由,您的登录和注册表单将位于此处。

要在 Remix 框架中创建路由,请在 app/routes 文件夹中添加一个文件。该文件的名称将用作路由的名称。有关 Remix 中路由如何工作的更多信息,请查看他们的文档

app/routes 中创建一个名为 login.tsx 的新文件,内容如下

路由文件的默认导出是 Remix 渲染到浏览器中的组件。

使用 npm run dev 启动开发服务器并导航到 https://127.0.0.1:3000/login,您应该看到路由已渲染。

这可以工作,但看起来还不太好... 接下来,您将通过添加实际的登录表单来使其更美观。

创建一个可重用的布局组件

首先,创建一个组件,您将在其中包装您的路由,以提供一些共享的格式和样式。您将使用组合模式来创建此 Layout 组件。

组合

组合是一种模式,您通过组件的 props 为其提供一组子元素。children prop 表示在父组件的开始和结束标记之间定义的元素。例如,考虑一下名为 Parent 的组件的用法

在这种情况下,<p> 标记是 Parent 组件的子组件,并将被渲染到 Parent 组件中,无论您决定在哪里渲染 children prop 值。

要查看实际效果,请在 app 文件夹内创建一个名为 components 的新文件夹。在该文件夹内,创建一个名为 layout.tsx 的新文件。

在该文件中,导出以下 函数组件

此组件使用 Tailwind 类来指定您希望包装在组件中的任何内容占据屏幕的完整宽度和高度,使用 mono 字体,并显示中等深蓝色作为背景。

请注意,children prop 在 <div> 内渲染。要查看将其投入使用时将如何渲染,请查看以下代码段

创建登录表单

现在您可以将该组件导入到 app/routes/login.tsx 文件中,并将您的 <h2> 标记包装在新 Layout 组件内,而不是它当前所在的 <div>

构建表单

接下来,添加一个登录表单,该表单接受 emailpassword 输入,并显示一个提交按钮。在顶部添加一条友好的欢迎消息,以便在用户进入您的站点时问候他们,并使用Tailwind 的 flex 类将整个表单居中显示在屏幕上。

此时,您无需担心 <form> 的 action 指向何处,只需确保它具有 method 值为 "post" 即可。稍后,您将查看一些很棒的 Remix 魔法,它将为我们设置 action!

创建表单字段组件

当您添加更多表单时,输入字段及其标签将在整个应用程序中被重写很多次,因此将这些分解为名为 FormField受控组件,以避免代码重复。

app/components 中创建一个名为 form-field.tsx 的新文件,您将在其中构建 FormField 组件。然后添加以下代码以开始

这将定义和导出与您之前在登录表单中拥有的完全相同的标签和输入组合,除了此组件将具有可配置的选项

  • htmlFor:用于输入字段的 idname 属性以及标签的 htmlFor 属性的值。
  • label:标签中显示的文本。
  • value:输入字段的当前受控值。
  • type可选 允许您设置输入字段的 type 属性,但默认为 'text' 值。
  • onChange可选 允许您提供一个函数,以便在输入字段的值更改时运行。默认为空函数调用。

您现在可以使用此组件替换现有的标签和输入

这将导入新的 FormField 组件,其状态将由父级(在本例中为登录表单)管理。使用 handleInputChange 函数跟踪对值的任何更新。

稍后您将回到 FormField 组件以添加错误消息处理,但这目前可以满足需求!

添加注册表单

您还需要一种供用户注册帐户的方法,这意味着您需要另一个表单。此表单将接受四个值

  • 电子邮件
  • 密码
  • 名字
  • 姓氏

为了避免必须创建一个看起来几乎/login 路由相同的新的 /signup 路由,请重新调整登录表单的用途,使其可以在两种不同的操作之间切换:登录和注册。

在状态中存储表单操作

首先,您需要某种方法让用户能够切换表单,并让您的代码能够区分表单。

Login 组件的顶部,在状态中创建另一个变量以保存您的 action

注意:默认状态将是登录屏幕。

接下来,您需要某种方法来切换您想要查看的状态。在“Welcome to Kudos”消息上方,添加以下按钮

此按钮的文本将根据 action 状态而更改。onClick 方法将在 'login' 和 'register' 值之间来回切换状态。

此页面上有一些静态文本,您需要根据您正在查看的表单进行调整。特别是“Log In To Give Some Praise!” 副标题和表单本身内的“Sign In”按钮。

更改表单的副标题以在每个表单上显示不同的消息

完全删除登录按钮,并将其替换为以下 <button>

这个新按钮具有 namevalue 属性。该值设置为状态的 action 是什么。当您的表单提交时,此值将与表单数据一起作为 _action 传递。

注意:如果 name 属性以下划线开头,则此技巧仅在 <button> 上有效。

根据您选择的表单,您现在应该看到更新后的消息。尝试单击“Sign Up”和“Sign In”按钮几次。

添加可切换字段

此页面上的文本看起来很棒,但是两个表单上都显示了相同的输入字段。您需要的最后一件事是在显示注册表单时添加更多字段。

password 字段后添加以下字段,并确保将新字段添加到 formData 对象。

此处进行了两处更改

  1. 您向 formData 状态添加了两个新键。
  2. 您添加了两个有条件渲染的字段,具体取决于您是查看登录表单还是注册表单。

您的登录和注册表单现在在视觉上已完成!现在是时候继续进行下一个部分了:使表单起作用。

身份验证流程

本节是有趣的部分,您将在此部分使您一直在设计和构建的所有内容真正起作用!

但是,在继续之前,您的项目中需要一个新的依赖项。运行以下命令

这将安装 bcryptjs 库及其类型定义。您稍后将使用它来哈希和比较密码。

身份验证将是基于会话的,遵循 Remix 的身份验证中使用的相同模式,用于 Remix 的 Jokes App 教程。

为了更好地可视化您的应用程序的身份验证流程,请查看下图。

为了验证用户身份,将采取一系列步骤,其中包含两条可能的路径(登录注册

  1. 用户将尝试登录或注册。
  2. 表单将被验证。
  3. 将调用 loginregister 函数。
  4. 如果登录,服务器端代码将确保存在具有所提供登录详细信息的用户。如果注册帐户,它将确保使用提供的电子邮件的帐户尚不存在。
  5. 如果以上步骤通过,则将创建一个新的 cookie 会话,并且用户将被重定向到主页。
  6. 如果某个步骤未通过并且出现问题,则用户将被发送回登录或注册屏幕,并且将显示错误。

首先,在 app 目录中创建一个名为 utils 的文件夹。您将在此处存储任何助手、服务和配置文件。

在该新文件夹中,创建一个名为 auth.server.ts 的文件,您将在其中编写身份验证和会话相关的方法。

注意:Remix 不会将文件类型之前带有 .server 的文件与发送到浏览器的代码捆绑在一起。

构建注册函数

您将构建的第一个函数是注册函数,该函数将允许用户创建新帐户。

app/utils/auth.server.ts 导出一个名为 register 的异步函数

创建并导出一个 type,用于定义注册表单将在 app/utils 中名为 types.server.ts 的另一个新文件中提供的字段。

将该 type 导入到 app/utils/auth.server.ts 中,并在 register 函数中使用它来描述 user 参数,该参数将包含注册表单的数据

当调用此 register 函数并提供 user 时,您需要检查的第一件事是是否已存在具有所提供电子邮件的用户。

注意:请记住,email 字段在您的架构中被定义为唯一的。

创建 PrismaClient 的实例

您将使用 PrismaClient 执行数据库查询,但是您的应用程序尚没有可用的实例。

app/utils 文件夹中创建一个名为 prisma.server.ts 的新文件,您将在其中创建并导出 Prisma Client 的实例

注意:上面采取了一些预防措施,以防止实时重新加载在开发过程中使您的数据库连接饱和。

您现在有了一种访问数据库的方法。在 app/utils/auth.server.ts 中,导入实例化的 PrismaClient 并将以下内容添加到 register 函数中

register 函数现在将查询数据库中是否存在具有所提供电子邮件的任何用户。

此处使用了 count 函数,因为它返回一个数值。如果没有与查询匹配的记录,它将返回 0,该值评估为 false。否则,将返回大于 0 的值,该值评估为 true

如果找到用户,该函数将返回 json 响应,状态代码为 400

更新您的数据模型

现在您可以确保当用户尝试注册时,不会已经存在具有所提供电子邮件的另一个用户。接下来,register 函数应创建一个新用户。但是,我们将存储一些字段,这些字段在 Prisma 架构(firstNamelastName)中尚不存在。

您将把此数据存储在包含 嵌入式文档的字段中,该文档在名为 profileUser 模型中。

打开您的 prisma/schema.prisma 文件并添加以下 type

type 关键字用于定义复合类型 – 允许您在文档内定义文档。与 JSON 类型相比,使用复合类型的好处是,在查询文档时,您可以获得类型安全。

非常有帮助,因为它使您能够显式定义数据形状,否则由于 MongoDB 的灵活性,数据形状将是流动的并且能够包含任何内容。

您尚未将此新的复合类型(嵌入式文档的另一个名称)用于描述字段。在您的 User 模型中,添加一个新的 profile 字段,并使用 Profile 类型作为其数据类型

太棒了,您的 User 模型现在将包含一个 profile 嵌入式文档。重新生成 Prisma Client 以考虑这些新更改

注意:您无需运行 prisma db push,因为您尚未添加任何新集合或索引。

添加用户服务

app/utils 中创建另一个名为 user.server.ts 的文件,其中将编写任何特定于用户的函数。在该文件中,添加以下函数和导入

createUser 函数执行以下几项操作

  1. 它哈希注册表单中提供的密码,因为您不应将其存储为纯文本。
  2. 它使用 Prisma 存储新的 User 文档。
  3. 它返回新用户的 idemail

注意:您可以通过传入 JSON 对象直接在此查询中填写 profile 嵌入式文档的详细信息,并且由于 Prisma 生成的类型,您将看到一些不错的自动完成功能。

此函数将在您的 register 函数中使用,以处理用户的实际创建。在 app/utils/auth.server.ts 中,导入新的 createUser 函数并在 register 函数中调用它。

现在,当用户注册时,如果另一个用户尚不存在具有所提供电子邮件的用户,则将创建一个新用户。如果在创建用户期间出现问题,则会将错误以及为 emailpassword 传入的值返回给客户端。

构建登录函数

login 函数将接受 emailpassword,因此要启动此函数,请创建一个新的 LoginForm 类型,以在 app/utils/types.server.ts 中描述该数据

然后通过将以下内容添加到 app/utils/auth.server.ts 来创建 login 函数

上面的代码...

  1. ... 导入新的 typebcryptjs 库。
  2. ... 查询具有匹配电子邮件的用户。
  3. ... 如果未找到用户或提供的密码与数据库中的哈希值不匹配,则返回 null 值。
  4. ... 如果一切顺利,则返回用户的 idemail

这将确保提供了正确的凭据,并将返回创建新 cookie 会话所需的数据。

添加会话管理

您现在需要一种在用户登录或注册帐户时为其生成 cookie 会话的方法。Remix 通过其 createCookieSessionStorage 函数提供了一种轻松存储这些 cookie 会话的方法。

将该函数导入到 app/utils/auth.server.ts 中,并在导入后直接添加新的 cookie 会话存储

上面的代码创建一个具有多个设置的会话存储

  • name:cookie 的名称。
  • secure:如果为 true,则仅允许通过 HTTPS 发送 cookie。
  • secrets:会话的密钥。
  • sameSite:指定是否可以在跨站点请求中发送 cookie。
  • path:URL 中必须存在的路径才能发送 cookie。
  • maxAge:定义 cookie 在被自动删除之前允许存在的时间段。
  • httpOnly:如果为 true,则不允许 JavaScript 访问 cookie。

注意:在此处了解有关不同 cookie 选项的更多信息。

您还需要在 .env 文件中设置会话密钥。添加一个名为 SESSION_SECRET 的变量,并设置一个密钥值。例如

会话存储现在已设置完毕。在 app/utils/auth.server.ts 中创建一个更多函数,该函数将实际创建 cookie 会话

此函数...

  • ... 创建一个新的会话。
  • ... 将该会话的 userId 设置为已登录用户的 id
  • ... 将用户重定向到您可以在调用此函数时指定的路由。
  • ... 在设置 cookie 标头时提交会话。

现在可以在 createUserSessionregister 函数中使用 login 函数,以在用户成功注册或登录时使用。

处理登录和注册表单提交

您已经创建了创建新用户和登录用户所需的所有函数。现在您将在您构建的表单中使用这些函数。

app/routes/login.tsx 中,导出一个 action 函数。

注意:Remix 查找名为 action 的导出函数,以在您定义的路由上设置 POST 请求。

现在在 app/utils 文件夹中创建一个名为 validators.server.ts 的新文件,并在其中创建几个验证器函数,用于验证表单输入。

app/routes/login.tsx 中的 action 函数中,从请求中获取表单数据,并验证其格式是否正确。

上面的代码看起来可能有点吓人,但简而言之,它...

  • ... 从请求对象中提取表单数据。
  • ... 确保提供了 emailpassword
  • ... 确保如果 _action 值是 "register",则提供了 firstNamelastName
  • ... 如果出现任何问题,则返回错误以及表单字段值,以便稍后在任何字段无效时,您可以使用用户的输入和错误消息重新填充表单。

您需要的最后一件事是,如果输入看起来没问题,则实际运行您的 registerlogin 函数。

switch 语句将允许您根据表单中 _action 值的内容有条件地运行 loginregister 函数。

为了实际触发此操作,表单需要发布到此路由。幸运的是,Remix 会处理此事,因为它会在识别到导出的 action 函数时,自动将 POST 请求配置到 /login 路由。

如果您尝试登录或创建帐户,您应该看到之后您被发送到主屏幕。成功!🎉

授权私有路由上的用户

接下来您将要做的事情是,使用户的体验更好,即根据用户是否拥有有效会话,自动将用户重定向到主页或登录页面。

app/utils/auth.server.ts 中,您将需要添加一些辅助函数。

这是很多新功能。以下是上面这些函数的作用

  • requireUserId 检查用户的会话。如果会话存在,则表示成功,并仅返回 userId。但是,如果失败,它会将用户重定向到登录屏幕。
  • getUserSession 根据请求的 cookie 获取当前用户的会话。
  • getUserId 从会话存储中返回当前用户的 id
  • getUser 返回与当前会话关联的整个 user 文档。如果未找到,则用户将注销。
  • logout 销毁当前会话,并将用户重定向到登录屏幕。

有了这些功能,您可以在您的私有路由上实现一些不错的授权。

app/routes/index.tsx 中,如果用户未登录,通过添加以下代码将用户返回到登录屏幕

注意:Remix 在提供页面之前运行 loader 函数。这意味着 loader 中的任何重定向都会在您的页面可以被提供之前触发。

如果您尝试在未登录时导航到应用程序的基本路由 (/),您应该被重定向到登录屏幕,URL 中带有 redirectTo 参数。

注意:如果您已经登录,您可能需要清除您的 cookies。

接下来,基本上做相反的事情。如果已登录的用户尝试访问登录页面,他们应该被重定向到主页,因为他们已经登录。将以下代码添加到 app/routes/login.tsx

添加表单验证

太棒了!您的登录和注册表单正在工作,并且您已经在您的私有路由上设置了授权和重定向。您几乎到达终点线了!

最后一件事是添加表单验证,并显示从 action 函数返回的错误消息。

更新 FormField 组件,使其能够处理错误消息。

现在,此组件将接收一个错误消息。当用户开始在该字段中键入时,如果正在显示任何错误消息,它将被清除。

在登录表单中,您将需要使用 Remix 的 useActionData hook 访问从 action 返回的数据,以便提取错误消息。

此代码添加了以下内容

  1. 挂钩到从 action 函数返回的数据。
  2. 设置一个 errors 变量,它将以对象形式保存特定于字段的错误,例如 “无效邮箱”。它还设置一个 formError 变量,它将保存用于显示表单消息的错误消息,例如 “登录不正确”。
  3. 更新 formData 状态变量,使其默认为 action 函数返回的任何可用值。

如果向用户显示了错误并切换了表单,您将需要清除表单和正在显示的任何错误。使用这些 effects 来实现此目的

完成这些设置后,您终于可以让您的表单和字段知道要显示哪些错误。

现在您应该看到错误消息和表单重置在您的注册和登录表单上正常工作了!

总结 & 接下来是什么

(😉) 您坚持到了本节的结尾!有很多内容要介绍,但希望您能够理解以下内容

  • 如何在 Remix 中设置路由。
  • 如何构建带有验证的登录和注册表单。
  • 基于会话的身份验证如何工作。
  • 如何通过实施授权来保护私有路由。
  • 创建和验证用户身份时,如何使用 Prisma 存储和查询您的数据。

在本系列的下一节中,您将构建 Kudos 的主页和 kudos 共享功能。您还将向 kudos feed 添加搜索和过滤功能。

不要错过下一篇文章!

注册 Prisma 新闻通讯