现在我们将把凭据认证流程添加到我们的项目中。所谓凭据认证,就是传统的通过电子邮件和密码登录的方法。NextAuth 将其称为 "CredentialsProvider",而 Strapi 称之为本地认证。以下是我们需要的内容概述:
- 注册页面:创建未验证用户并发送验证邮件
- 验证页面
- 请求新的验证邮件
- 登录页面
- 请求重置密码页面(忘记密码)
- 重置密码页面
- 更改密码页面
- 更新用户信息(个人资料)页面
在本章节中,我们将把凭据认证流程添加到我们自定义的登录页面。
本章节的所有代码都可以在 GitHub 上找到(分支:credentialssignin
)。
https://github.com/peterlidee/NNAS/tree/credentialssignin
设置
默认情况下,电子邮件提供程序在 Strapi 中是启用的,因此我们这里不需要做任何操作。我们需要在 Strapi 中创建一个用户,以便测试我们将要构建的凭据登录功能。在 Strapi 管理后台:
导航到 Content Manager > Collection Types > User > Create a new entry
这里有两个注意事项:
- 我们正在创建的是前端用户,而不是后台用户(即无法访问 Strapi 管理后台的用户)。
- 如果你在跟着代码编写,请确保使用你自己的电子邮件,因为稍后我们会向该地址发送邮件。
创建一个用户:
- 用户名: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;
},
}),
],
}
我们有 name
、credentials
和 authorize
属性。name
和 credentials
属性主要用于填充 NextAuth 自动生成的默认登录页面。由于我们使用自定义的登录页面,这些属性(如 name
和标签)实际上没有使用。
让我们快速回到这个默认登录页面,看看我们得到了什么。在 authOptions.pages
中,注释掉 signin
,启动应用程序并导航到 http://localhost:3000/api/auth/signin
:
我们看到了所有我们预期的内容: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>
);
}
我们刚刚添加了两个输入框(identifier
和 password
)以及一个按钮。然后,我们将此表单加载到 <SignIn />
组件中,看起来是这样的:
调用 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 使用这些数据来填充 account
、profile
和 user
。当使用 CredentialsProvider
时,没有这样的数据。你需要自己从 Strapi 中获取这些数据。
authorize
有两个参数:credentials
(identifier
和 password
)以及实际的请求对象。我们将使用这些凭据并将它们发送到 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
回调的参数 token
、trigger
、account
、user
和 session
都会被填充。当用户已经登录时,除了 token
之外,所有这些参数都会是 undefined
。
使用 CredentialsProvider
登录时,我们得到类似的情况。token
、trigger
、account
和 user
会被填充。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
中抓取 strapiToken
和 strapiUserId
。但在使用 CredentialsProvider 时,我们在 authorize
函数中发起 API 调用,并将数据作为 user
对象返回。这意味着我们必须使用我们的 user
对象来传递 strapiToken
和 strapiUserId
。为了让 TypeScript 高兴,我们必须更新我们的 user
类型:
// frontend/src/types/nextauth/next-auth.d.ts
interface User extends DefaultSession['user'] {
// 如果不设置此属性将在 authorize 函数中抛出 ts 错误
strapiUserId?: number;
strapiToken?: string;
blocked?: boolean;
}
这意味着现在我们的 User
和 JWT
接口都有一个可选的 strapiUserId
和 strapiToken
属性。对此没有办法(TypeScript 一直在抱怨),这很混乱。如果你不完全理解这一点也没关系。一旦你开始自己编写这些代码,你就会明白。
总结
我们首先编写了一个具有受控输入字段的表单。在提交时,我们调用 signIn
函数并传递凭据。这导致调用在 CredentialsProvider 中定义的 authorize
函数。
authorize
是使用凭据时 NextAuth 回调函数的重要组成部分。在 authorize
中,我们从 Strapi 获取我们的用户数据,然后返回这些(已编辑的)数据。此返回值等于回调函数中的 user
参数。为了完成流程,我们更新了 jwt
回调。
我们目前的应用程序可以使用凭据进行工作。当我们运行它并使用凭据登录时,记录 useSession
或 getServerSession
,我们得到预期的结果:
{
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 已收录,有一线大厂面试完整考点、资料以及我的系列文章。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。