接着上一篇教程的代码,我们来实现邮箱密码登录,这里背后依然是使用 NextAuth 的身份签发,只不过我们要自定义认证逻辑,ok 开始吧!
新增密码字段
既然是密码登录,我们就需要存储用户的密码
- 新增密码字段
/prisma/schema.prisma
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime? @map("email_verified")
image String?
password String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at")
accounts Account[]
sessions Session[]
Post Post[]
@@map("users")
}
在 User(数据库表为 users) 下新增 password
字段,我们这里使用的是 psql 数据库,类型直接使用 String
,如果是 MySQL 的话,需要使用 @db.Text
类型
- 更新数据库
npx prisma migrate dev --name password
可以看到已经有 password
字段了
- 生成 Prisma Client Typescript 类型
npx prisma generate
如果你使用 vscode 之后开发依然没有 User.password
提示的话,可以打开 vscode 控制命令 Shift + Command + p
打开,输入 Reload webviews
回车,即可重启当前视图
密码编码与解码
加密方式这里选择 bcrypt
这不需要保存 salt
更安全些,编码处理库选择 bcrypt.js 这是一个 js 实现的处理库,没有选择 c++ 实现的 node bcrypt 是因为它在安装的时候以来容易报错 🫣(环境问题)
- 安装依赖
pnpm add bcryptjs
pnpm add -D @types/bcryptjs
- 加密和解密
/lib/utils.ts
import bcrypt from "bcryptjs";
// 将明文处理为 hash
export function hashPassword(password: string) {
return bcrypt.hashSync(password, 10);
}
// 对比明文和 hash 是否一致
export function comparePassword(password: string, hashPassword: string) {
return bcrypt.compareSync(password, hashPassword);
}
这两个函数是同步的,具体了解可以参考文档 bcrypt.js - Usage Sync
密码登录逻辑
基础工作好了,我们来整理下密码登录的逻辑
- 在用户已经设置了密码的情况下,才可使用密码登录。
- 未设置密码的用户登录(Github 或 Email)后,会弹出密码设置提示,完成密码设置后才可进行(强制 🤔 当然这也可不强制,取决于你)其他操作。
- 已设置密码的用户,可以使用任意方式登录,且登录后不会有密码设置提示。
密码验证
实现 NextAuth 中自定义逻辑验证
/pages/api/auth/[...nextauth].ts
import { NextApiHandler } from "next";
import { NextAuthOptions } from "next-auth";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import GitHubProvider from "next-auth/providers/github";
import EmailProvider from "next-auth/providers/email";
import CredentialsProvider from "next-auth/providers/credentials";
import NextAuth from "next-auth/next";
import { db } from "~/lib/db/prisma";
import { comparePassword } from "~/lib/utils";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(db),
secret: process.env.SECRET,
session: {
// Set to jwt in order to CredentialsProvider works properly
strategy: "jwt",
},
providers: [
GitHubProvider({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
EmailProvider({
server: {
host: process.env.EMAIL_SERVER_HOST,
port: process.env.EMAIL_SERVER_PORT,
auth: {
user: process.env.EMAIL_SERVER_USER,
pass: process.env.EMAIL_SERVER_PASSWORD,
},
},
from: process.env.EMAIL_FROM,
}),
CredentialsProvider({
id: "emailPassword",
name: "Password",
credentials: {
email: { label: "Email", type: "email", placeholder: "Email" },
password: {
label: "Password",
type: "password",
placeholder: "Password",
},
},
async authorize(credentials) {
try {
const { email, password } = credentials;
if (!email || !password) {
throw new Error("email and password are required");
}
const user = await db.user.findUnique({
where: { email },
});
// 用户不存在
if (!user) {
throw new Error("Incorrect email or password");
}
// 用户未设置密码
if (!user.password) {
throw new Error(
"Account not init password, Please use email verification"
);
}
// 密码不正确
if (!comparePassword(password, user.password)) {
throw new Error("Incorrect email or password");
}
return {
id: user.id,
name: user.name,
email: user.email,
};
} catch (err) {
console.log(err?.message);
return null;
}
},
}),
],
};
const authHandler: NextApiHandler = (req, res) =>
NextAuth(req, res, authOptions);
export default authHandler;
CredentialsProvider
这是 NextAuth 提供的自定义认证包装器id
和name
是不可少的,id
是包装器的唯一标识,name
是包装器显示的名称credentials
用来设置认证项,这里我们需要用户输入email
和password
,NextAuth 将生成两个输入框来输入相应的信息authorize
认证的核心,是一个异步函数,稍后详细讲解session.strategy
可以看到这里也改变了 session 的存储策略,这是因为在使用了数据库认证的情况下,CredentialsProvider
认证的信息未保存在数据库中,就只能使用 jwt 的方式做签发,但这里也就需要这一个配置就好,无需其他调整(参考)
authorize
authorize: (
credentials: Record<keyof C, string> | undefined,
req: Pick<RequestInternal, "body" | "query" | "headers" | "method">
) => Awaitable<User | null>;
这是 authorize
的类型
credentials
认证信息,这里也就是上面credentials
表单里的email
和password
req
请求Awaitable<User | null>
接受异步的响应,当返回null
则认证失败,返回User
对象认证成功,这里如果中途执行有Error
未被处理,则会跳转到 NextAuth 的 Error 页面
这里有点要注意的就是,虽然上面我写了 throw new Error(...)
但这些信息被捕获后,仅仅是打印出来了,不需要抛到 NextAuth 层面,原因是在 NextAuth 自带的页面中,自定义判断逻辑错误不会被认为是某些验证错误,而是直接认为是程序错误,就跑到 Error 页面了 😓,这里错误的话,仅可以返回 null
,如果要更全面的自定义错误信息,我的建议是自定义页面吧(下一篇文章准备 🤪)
来看看设置完的效果
可以看到,这里 Sign in with Password
就是我们新加的了 Password
这几个文字就是 CredentialsProvider
里的 name
设置的。这里的顺序 Github, Email, Email+Password 也是我们在 NextAuth Providers 中的配置位置(NextAuth 实际这里用了循环直接获取 Providers 展示)
输入 Email,密码输入任意值
可以看到 url 携带了错误信息(实际上 NextAuth 内部使用的重定向,这里表单也清空了)提示信息也是默认的登录失败信息(下一篇自定义页面来优化吧)
这里很明显是我们的这个邮箱没有设置过密码,那么是无法使用密码登录。
提示用户设置密码
先使用其他方式登录账号,之后再来设置密码
- 实现密码设置的接口
/pages/api/user/init-password.ts
import { NextApiRequest, NextApiResponse } from "next";
import { unstable_getServerSession } from "next-auth";
import { db } from "~/lib/db/prisma";
import { hashPassword } from "~/lib/utils";
import { authOptions } from "../auth/[...nextauth]";
export default async function (req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
try {
// 当前登录用户
const { user } = await unstable_getServerSession(req, res, authOptions);
if (!user) {
throw new Error("Need Login");
}
const { password } = req.body;
// 参数校验
if (!password) {
throw new Error("Invalid Password");
}
// 设置密码
await db.user.update({
where: { email: user.email },
data: {
password: hashPassword(password),
},
});
return res.status(200).end();
} catch (err) {
console.log(err.message);
return res.status(500).end();
}
} else {
throw new Error(`The HTTP ${req.method} method is not supported`);
}
}
unstable_getServerSession(req, res, authOptions)
这是 NextAuth 提供的服务端获取 Session 的方法,目前还未发布标准,但也仅是后续可能会修改接口名称的事情,不影响我们使用, 这里authOptions
是必须的,这里直接使用nextauth
配置文件中的options
就好。
- 获取是否已设置密码 如何知道用户是否设置了密码?难道需要一个接口去查询嘛?太麻烦了吧!可以直接用 session 取到的话,多方便啊!
/pages/api/auth/[...nextauth].ts
//...
export const authOptions: NextAuthOptions = {
// ...
callbacks: {
async session({ session }) {
const user = await db.user.findUnique({
where: { email: session.user.email },
});
if (user) {
session.user.noPwd = !user.password;
}
return session;
},
},
};
callbacks
是 Provider 认证完的回调,我们这里重新修改了 session 的数据(文档)
🤖 实际这里数据库的查询,更好的写法是在 jwt 中(因为我们上面修改了 session.strategy
为 jwt )原因是 jwt 是有时效的,在过期前,不用每次执行(减少查询数据库次数)
callbacks: {
async session({ token, session }) {
if (token) {
session.user = {
...session.user,
id: user.id,
name: user.name,
email: user.email,
noPwd: user.noPwd,
}
}
return session
},
async jwt({ token, user }) {
const dbUser = await db.user.findUnique({
where: {
email: token.email,
},
})
if (!dbUser) {
token.id = Number(user.id)
return token
}
return {
...token,
id: user.id,
name: user.name,
email: user.email,
picture: dbUser.image,
noPwd: !dbUser.password
}
},
},
访问: http://localhost:3000/api/auth/session
类型错误的问题,NextAuth User
中没有 noPwd
类型!
/types/next-auth.d.ts
import NextAuth, { DefaultSession } from "next-auth";
declare module "next-auth" {
/**
* Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
*/
interface Session {
user: {
/** The user's postal address. */
noPwd?: boolean;
} & DefaultSession["user"];
}
}
- 没有设置密码就展示弹窗
我们希望在除了认证外的页面,当用户登录后,都在没设置密码的情况下显示弹窗,这里最好的方式在放在当前的
Layout
组件里(放在_app
或_document
都没必要,毕竟有些页面不需要这个判断,例如:登录),如果愿意的话,还可以独立封装一个NeedInitPasswordWrapper
的组件
/components/Layouts.tsx
import { useSession } from "next-auth/react";
import React, { ReactNode, useEffect } from "react";
import refreshSession from "~/lib/utils";
import Header from "./Header";
const Layout: React.FC<{ children?: ReactNode }> = (props) => {
const { data, status } = useSession();
const handleSubmitInitPassword = async () => {
let password = "";
// while 循环时强制用户输入密码,不要强制的话,可以去掉
do {
password = window.prompt("Please enter your init password", "");
} while (!password || password.trim() === "");
try {
// 保存密码
const res = await fetch("/api/user/init-password", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
password,
}),
});
if (res.ok) {
window.alert("Init password save successful");
}
} catch (err) {
console.log(err);
} finally {
// 刷新客户端 session
refreshSession();
}
};
// 客户端才执行
useEffect(() => {
// 用户已登录,且没设置密码
if (status === "authenticated" && data?.user?.noPwd) {
handleSubmitInitPassword();
}
// 状态变化时,重新判断
}, [status]);
return (
<div>
<Header />
<div className="px-8">{props.children}</div>
</div>
);
};
export default Layout;
/lib/utils.ts
// only client
export default function refreshSession() {
const event = new Event("visibilitychange");
document.dispatchEvent(event);
}
这里逻辑本身没什么,要注意的是
session stauts
的改变,要重新触发判断,否则处节点挂载时可能状态是loading
之后才变为authenticated
refreshSession
函数是为了强制刷新客户端session
信息,NextAuth 没有提供刷新 Session 的工具,但会监控窗口的显示激活事件,重新请求 session,利用这个特点,我们强制触发该事件即可,否则通常设置成功密码了,但 session 中依然显示没设置密码。
ok,此时我们打开页面,登录状态下刷新
这里有循环为空判断,所以取消没用,乖乖输入吧!
ok, 退出登录,使用密码登录来试试
🤙 成功啦
这是一个教程案例,更多的验证,UI 等优化大家根据需要自己处理吧。