现在我们将把凭据认证流程添加到我们的项目中。所谓凭据认证,就是传统的通过电子邮件和密码登录的方法。NextAuth 将其称为 "CredentialsProvider",而 Strapi 称之为本地认证。以下是我们需要的内容概述:

  1. 注册页面:创建未验证用户并发送验证邮件
  2. 验证页面
  3. 请求新的验证邮件
  4. 登录页面
  5. 请求重置密码页面(忘记密码)
  6. 重置密码页面
  7. 更改密码页面
  8. 更新用户信息(个人资料)页面

在本章节中,我们将把凭据认证流程添加到我们自定义的登录页面。

本章节的所有代码都可以在 GitHub 上找到(分支:credentialssignin)。

https://github.com/peterlidee/NNAS/tree/credentialssignin

设置

默认情况下,电子邮件提供程序在 Strapi 中是启用的,因此我们这里不需要做任何操作。我们需要在 Strapi 中创建一个用户,以便测试我们将要构建的凭据登录功能。在 Strapi 管理后台:

导航到 Content Manager > Collection Types > User > Create a new entry

这里有两个注意事项:

  1. 我们正在创建的是前端用户,而不是后台用户(即无法访问 Strapi 管理后台的用户)。
  2. 如果你在跟着代码编写,请确保使用你自己的电子邮件,因为稍后我们会向该地址发送邮件。

创建一个用户:

  • 用户名:Bob
  • 电子邮件:bob@example.com
  • 密码:123456
  • 已确认:true
  • 已阻止:false
  • 角色:authenticated

保存并完成。

在 NextAuth 中注册 CredentialsProvider

在我们的 authOptions.providers 中,我们已经有了 GoogleProvider。现在我们需要导入并将 CredentialsProvider 添加到 providers 数组中。这是我们的初始设置:

// frontend/src/app/api/auth/[...nextauth]/authOptions.ts
{
  providers: [
    //...
    CredentialsProvider({
      name: 'email and password',
      credentials: {
        identifier: {
          label: 'Email or username *',
          type: 'text',
        },
        password: { label: 'Password *', type: 'password' },
      },
      async authorize(credentials, req) {
        console.log('calling authorize');
        return null;
      },
    }),
  ],
}

我们有 namecredentialsauthorize 属性。namecredentials 属性主要用于填充 NextAuth 自动生成的默认登录页面。由于我们使用自定义的登录页面,这些属性(如 name 和标签)实际上没有使用。

让我们快速回到这个默认登录页面,看看我们得到了什么。在 authOptions.pages 中,注释掉 signin,启动应用程序并导航到 http://localhost:3000/api/auth/signin

image.png

我们看到了所有我们预期的内容:Google 登录和凭据登录,带有电子邮件/用户名、密码输入框和提交按钮。注意:Strapi 允许你使用电子邮件或用户名登录。我们通过使用 identifier 字段来处理这一点,该字段可以是用户名或电子邮件。我们不会使用默认的登录页面,但它清楚地说明了凭据设置的作用。但还有更多内容。

NextAuth 还使用这些设置来推断类型。在我们添加的异步 authorize(credentials, req) 函数中,credentials 参数的类型是 CredentialsProvider.credentials,即 { identifier: string, password: string }。这意味着我们必须确保我们自定义登录页面中的表单名称和 ID 与这些键匹配。

最后,authorize 函数是我们处理提交表单的地方,但我们稍后再讨论。现在我们将 authOptions.pages.signin 恢复为我们的自定义页面,并继续前进。

创建登录表单

我们需要一个表单,这是我们的下一步。创建一个新的组件 <SignInForm />

// frontend/src/components/auth/signin/SignInForm.tsx

export default function SignInForm() {
  return (
    <form method='post' className='my-8'>
      <div className='mb-3'>
        <label htmlFor='identifier' className='block mb-1'>
          Email or username *
        </label>
        <input
          type='text'
          id='identifier'
          name='identifier'
          required
          className='bg-white border border-zinc-300 w-full rounded-sm p-2'
        />
      </div>
      <div className='mb-3'>
        <label htmlFor='password' className='block mb-1'>
          Password *
        </label>
        <input
          type='password'
          id='password'
          name='password'
          required
          className='bg-white border border-zinc-300 w-full rounded-sm p-2'
        />
      </div>
      <div className='mb-3'>
        <button
          type='submit'
          className='bg-blue-400 px-4 py-2 rounded-md disabled:bg-sky-200 disabled:text-gray-500'
        >
          sign in
        </button>
      </div>
    </form>
  );
}

我们刚刚添加了两个输入框(identifierpassword)以及一个按钮。然后,我们将此表单加载到 <SignIn /> 组件中,看起来是这样的:

image.png

调用 NextAuth signIn 函数

我们知道接下来该做什么,因为我们之前已经在 Google 登录按钮上做过类似的操作。我们需要使用一些参数调用 NextAuth 的 signIn 函数:

signIn('credentials', {
  identifier: '...',
  password: '...',
});

这里有一个小提示:你可以有多个凭据提供程序。在这种情况下,你可以为每个 CredentialsProvider 添加一个 id 属性,并使用这个 id 来调用 signIn

此时,你可能会考虑使用服务器操作来处理表单提交。这是不可能的,因为 signIn 是一个客户端函数,无法从服务器端调用。因此,我们必须将输入字段存储到状态中。我们更新组件:

// 添加初始状态
const initialState = {
  identifier: '',
  password: '',
};

// 设置状态
const [data, setData] = useState(initialState);

// 创建事件处理器
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
  setData({
    ...data,
    [e.target.name]: e.target.value,
  });
}

最后,我们更新输入框,使用 value={data.identifier}onChange={handleChange}。这样,我们使输入框变成了受控组件。这应该很清楚了。

接下来,创建一个 onsubmit 处理器并调用 signIn 函数:

function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();
  signIn('credentials', {
    identifier: data.identifier,
    password: data.password,
  });
}

此时,请求会离开我们的表单组件,authorize 和回调函数开始发挥作用。我们的表单还没有完成。我们需要错误处理、输入验证、加载状态等,但我们会在后续章节中处理。

编写 NextAuth 的 authorize 函数以用于 CredentialsProvider

我们之前在 CredentialsProvider 中创建的 authorize 函数是凭据认证流程的主要工作引擎。让我们考虑一下我们现在所处的位置。用户提交了一个 identifier(电子邮件/用户名)和密码。我们需要对这些数据做些什么?我们必须通过 API 调用询问 Strapi 这些数据是否正确。

成功后,Strapi 会返回用户数据和一个 Strapi JWT 令牌。然后我们会使用 NextAuth 的回调函数将此令牌放入 NextAuth 的 JWT 令牌中。如果我们的 API 调用返回错误(例如,密码不正确),我们必须以某种方式处理这个错误。

最好将 authorize 视为 NextAuth 回调函数的一部分,因为它实际上就是一个回调函数。authorize 的返回值会作为 user 参数传递给其他回调函数。jwt 回调中的 user 参数就是 authorize 函数的返回值。

这很合理。当我们使用 GoogleProvider 时,Google OAuth 会返回数据。然后 NextAuth 使用这些数据来填充 accountprofileuser。当使用 CredentialsProvider 时,没有这样的数据。你需要自己从 Strapi 中获取这些数据。

authorize 有两个参数:credentialsidentifierpassword)以及实际的请求对象。我们将使用这些凭据并将它们发送到 Strapi。

Strapi API

我们需要的 Strapi API 端点是 /api/auth/local。我们需要发起一个 POST 请求,并将凭据作为 JSON 发送:

const strapiResponse = await fetch(
  `${process.env.STRAPI_BACKEND_URL}/api/auth/local`,
  {
    method: 'POST',
    headers: {
      'Content-type': 'application/json',
    },
    body: JSON.stringify({
      identifier: credentials.identifier,


      password: credentials.password,
    }),
  }
);

从这个 strapiResponse 中,我们可以返回用户数据:

async authorize(credentials, req) {
  const strapiResponse = await fetch(
    `${process.env.STRAPI_BACKEND_URL}/api/auth/local`,
    {
      method: 'POST',
      headers: {
        'Content-type': 'application/json',
      },
      body: JSON.stringify({
        identifier: credentials!.identifier,
        password: credentials!.password,
      }),
    }
  );
  const data: StrapiLoginResponseT = await strapiResponse.json();
  return {
    name: data.user.username,
    email: data.user.email,
    id: data.user.id.toString(),
    strapiUserId: data.user.id,
    blocked: data.user.blocked,
    strapiToken: data.jwt,
  };
},

在 NextAuth 流程中,一旦我们从 authorize 返回数据,回调函数就会被调用。signIn 回调只会返回 true,并不相关。接下来将调用 jwt 回调。

为 CredentialsProvider 自定义 NextAuth 的 jwt 回调

通过使用 GoogleProvider 的经验,我们知道当用户登录时,jwt 回调的参数 tokentriggeraccountusersession 都会被填充。当用户已经登录时,除了 token 之外,所有这些参数都会是 undefined

使用 CredentialsProvider 登录时,我们得到类似的情况。tokentriggeraccountuser 会被填充。user 是我们刚刚从 authorize 返回的数据,而 account 是这样的:

account: {
  providerAccountId: undefined,
  type: 'credentials',
  provider: 'credentials'
},

同样,类似于我们之前使用 GoogleProvider 时的做法,我们在 jwt 回调中监听 account.provider。为什么?当 account 被定义并且 account.provider === 'credentials' 时,我们知道用户刚刚使用凭据登录,我们需要使用这些数据更新令牌。这是我们更新后的 jwt 回调:

async jwt({ token, trigger, account, user, session }) {
  if (account) {
    if (account.provider === 'google') {
      // 我们现在知道我们正在使用 GoogleProvider 登录
      try {
        const strapiResponse = await fetch(
          `${process.env.STRAPI_BACKEND_URL}/api/auth/${account.provider}/callback?access_token=${account.access_token}`,
          { cache: 'no-cache' }
        );
        if (!strapiResponse.ok) {
          const strapiError: StrapiErrorT = await strapiResponse.json();
          throw new Error(strapiError.error.message);
        }
        const strapiLoginResponse: StrapiLoginResponseT =
          await strapiResponse.json();
        // 自定义 token
        // 姓名和电子邮件已经在这里了
        token.strapiToken = strapiLoginResponse.jwt;
        token.strapiUserId = strapiLoginResponse.user.id;
        token.provider = account.provider;
        token.blocked = strapiLoginResponse.user.blocked;
      } catch (error) {
        throw error;
      }
    }
    if (account.provider === 'credentials') {
      // 对于凭据而非 GoogleProvider
      // 姓名和电子邮件由 NextAuth 或 authorize 处理
      token.strapiToken = user.strapiToken;
      token.strapiUserId = user.strapiUserId;
      token.provider = account.provider;
      token.blocked = user.blocked;
    }
  }
  return token;
},

默认情况下,NextAuth 将使用名称和电子邮件属性填充 token。然后我们手动设置其他属性。

为 CredentialsProvider 自定义 NextAuth 的 session 回调

正如上面所示,由 jwt 回调返回的 token 对象对于 Google 和 CredentialsProvider 是相同的属性。我们精心设计了这一点。这意味着 session 回调不需要更新:

async session({ token, session }) {
  session.strapiToken = token.strapiToken;
  session.provider = token.provider;
  session.user.strapiUserId = token.strapiUserId;
  session.user.blocked = token.blocked;
  return session;
},

形状和类型

我刚才提到,我精心设计了所有回调参数和 authorize 函数。这是一个混乱的过程。基本原则是我们在凭据和 GoogleProvider 之间镜像所有这些参数。

在设置 GoogleProvider 时,我们已经遇到了设置类型的问题。添加 CredentialsProvider 使一切变得更加复杂。这里有几个我必须做的事情。

默认情况下,NextAuth 中的 user 对象有一些属性:姓名和电子邮件(可选),但也有一个 id(必需)。这就是为什么在 authorize 函数中,我返回了一个 id 属性:id: data.user.id.toString()。这是我们的 Strapi 用户 ID(数字)。NextAuth 的 user ID 是一个字符串,所以我们进行了转换。我们实际上并不使用这个 ID,但如果我们不添加 id 属性,它会抛出 TypeScript 错误。这是我的解决方案。正如我所说,这很混乱。

我遇到的第二个问题是 user 类型。当在 jwt 回调中处理 GoogleProvider 时,我们从 strapiResponse 中抓取 strapiTokenstrapiUserId。但在使用 CredentialsProvider 时,我们在 authorize 函数中发起 API 调用,并将数据作为 user 对象返回。这意味着我们必须使用我们的 user 对象来传递 strapiTokenstrapiUserId。为了让 TypeScript 高兴,我们必须更新我们的 user 类型:

// frontend/src/types/nextauth/next-auth.d.ts

interface User extends DefaultSession['user'] {
  // 如果不设置此属性将在 authorize 函数中抛出 ts 错误
  strapiUserId?: number;
  strapiToken?: string;
  blocked?: boolean;
}

这意味着现在我们的 UserJWT 接口都有一个可选的 strapiUserIdstrapiToken 属性。对此没有办法(TypeScript 一直在抱怨),这很混乱。如果你不完全理解这一点也没关系。一旦你开始自己编写这些代码,你就会明白。

总结

我们首先编写了一个具有受控输入字段的表单。在提交时,我们调用 signIn 函数并传递凭据。这导致调用在 CredentialsProvider 中定义的 authorize 函数。

authorize 是使用凭据时 NextAuth 回调函数的重要组成部分。在 authorize 中,我们从 Strapi 获取我们的用户数据,然后返回这些(已编辑的)数据。此返回值等于回调函数中的 user 参数。为了完成流程,我们更新了 jwt 回调。

我们目前的应用程序可以使用凭据进行工作。当我们运行它并使用凭据登录时,记录 useSessiongetServerSession,我们得到预期的结果:

{
  user: {
    name: 'Bob',
    email: 'bob@example.com',
    image: undefined,
    strapiUserId: 2,
    blocked: false
  },
  strapiToken: 'longtokenhere',
  provider: 'credentials'
}

但我们忽略了许多事情。在 authorize 函数中,我们没有对 Strapi API 调用进行错误处理。此外,我们的客户端代码(表单)也没有完成:我们需要错误和成功处理、输入验证和加载状态。我们将在下一章中处理这些内容。

首发于公众号 大迁世界,欢迎关注。📝 每周一篇实用的前端文章 🛠️ 分享值得关注的开发工具 ❓ 有疑问?我来回答

本文 GitHub https://github.com/qq449245884/xiaozhi 已收录,有一线大厂面试完整考点、资料以及我的系列文章。


王大冶
68.2k 声望105k 粉丝