April 26, 2022

使用 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 启动开发服务器,并导航到 http://localhost:3000/login,您应该会看到路由被渲染出来。

这可行,但看起来还不太美观... 接下来您将通过添加实际的登录表单来稍作美化。

创建可重用的布局组件

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

组合

组合是一种模式,您通过组件的 props 提供一组子元素。children prop 代表父组件开标签和闭标签之间定义的元素。例如,考虑以下名为 Parent 的组件用法

在这种情况下,<p> 标签是 Parent 组件的子元素,并将被渲染到您决定渲染 children prop 值的位置,即 Parent 组件内部。

为了看到实际效果,在 app 文件夹内创建一个新文件夹,命名为 components。在该文件夹内创建一个新文件,命名为 layout.tsx

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

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

注意 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

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

接下来您需要一种方式来切换您想查看的状态。在“欢迎来到 Kudos”消息上方,添加以下按钮

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

此页面上有几个静态文本位置,您需要根据您正在查看的表单进行调整。特别是“登录以点赞!”副标题以及表单内部的“登录”按钮。

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

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

这个新按钮有一个 name 和一个 value 属性。其值被设置为状态的 action 值。当您的表单提交时,该值将与表单数据一起作为 _action 传递。

注意:此技巧仅在 <button> 标签上有效,前提是 name 属性以一个下划线开头。

根据您选择的表单,您现在应该会看到更新的消息。尝试点击几次“注册”和“登录”按钮。

添加可切换字段

此页面上的文本看起来不错,但两个表单都显示相同的输入字段。您最后需要做的是在显示注册表单时添加几个额外的字段。

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

这里做了两个更改

  1. 您向 formData 状态添加了两个新键。
  2. 您添加了两个字段,这些字段根据您是查看登录表单还是注册表单而条件渲染

您的登录和注册表单现在视觉上已经完成!是时候进入下一部分:让表单具备功能。

认证流程

这部分是有趣的部分,您将让您一直设计和构建的一切真正运作起来!

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

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

认证将基于会话,遵循 Remix 的笑话应用教程中认证所使用的模式。

为了更好地可视化您的应用程序认证流程将是什么样子,请查看下面的图表。

为了认证用户,将有一系列步骤,包含两个潜在的路径(登录注册

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

为了开始,在 app 目录内创建一个名为 utils 的文件夹。您将在此存储任何辅助函数、服务和配置文件。

在该新文件夹内,创建一个名为 auth.server.ts 的文件,您将在此编写与认证和会话相关的方法。

注意:Remix 不会将文件名中带有 .server 后缀的文件与发送到浏览器的代码一起打包。

构建注册函数

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

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

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

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

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

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

创建 PrismaClient 实例

您将使用 PrismaClient 执行数据库查询,但您的应用程序尚无法获得其实例。

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

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

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

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

这里使用 count 函数是因为它返回一个数值。如果没有匹配查询的记录,它将返回 0,其求值结果为 false。否则,将返回大于 0 的值,其求值结果为 true

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

更新您的数据模型

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

您将把这些数据存储在 User 模型中名为 profile 的字段中,该字段包含一个嵌入式文档

打开您的 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 函数将接受一个 email 和一个 password,因此要开始此函数,请在 app/utils/types.server.ts 中创建一个新的 LoginForm 类型来描述该数据

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

上面的代码...

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

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

添加会话管理

您现在需要一种方式,以便在用户登录或注册账户时为其生成一个 cookie 会话。Remix 提供了一种简单的方式来存储这些 cookie 会话,即使用其 createCookieSessionStorage 函数。

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

上面的代码创建了一个具有以下几个设置的会话存储

  • name:cookie 的名称。
  • secure:如果为 true,则只允许 cookie 通过 HTTPS 发送。
  • 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 header 时提交会话。

createUserSession 函数现在可以在用户成功注册或登录时用于 registerlogin 函数中。

处理登录和注册表单提交

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

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

注意:Remix 会寻找一个名为 action 的导出函数,以便为您定义的路由设置 POST 请求。

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

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

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

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

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

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

为了实际触发此 action,表单需要 post 到此路由。幸运的是,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 参数。

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

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

添加表单验证

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

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

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

此组件现在将接收错误消息。当用户开始在该字段中输入时,如果显示任何错误消息,则会将其清除。

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

此代码添加了以下内容

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

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

有了这些,您最终可以让您的表单和字段知道要显示哪些错误了。

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

总结与后续

为您坚持到本节结束点赞 (😉)!有很多内容需要介绍,但希望您能够从中理解到

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

在本系列的下一节中,您将构建 Kudos 的主页和点赞分享功能。您还将为点赞动态添加搜索和过滤功能。

不要错过下一篇文章!

订阅 Prisma 新闻通讯