Next + NextAuth + Password 认证
标签:
Next
NextAuth
Prisma
实践
2022-12-11
12 分钟

接着上一篇教程的代码,我们来实现邮箱密码登录,这里背后依然是使用 NextAuth 的身份签发,只不过我们要自定义认证逻辑,ok 开始吧!

新增密码字段

既然是密码登录,我们就需要存储用户的密码

  1. 新增密码字段 /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 类型

  1. 更新数据库
npx prisma migrate dev --name password

Next NextAuth Password

可以看到已经有 password 字段了

  1. 生成 Prisma Client Typescript 类型
npx prisma generate

如果你使用 vscode 之后开发依然没有 User.password 提示的话,可以打开 vscode 控制命令 Shift + Command + p 打开,输入 Reload webviews 回车,即可重启当前视图

密码编码与解码

加密方式这里选择 bcrypt 这不需要保存 salt 更安全些,编码处理库选择 bcrypt.js 这是一个 js 实现的处理库,没有选择 c++ 实现的 node bcrypt 是因为它在安装的时候以来容易报错 🫣(环境问题)

  1. 安装依赖
pnpm add bcryptjs
pnpm add -D @types/bcryptjs
  1. 加密和解密 /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

密码登录逻辑

基础工作好了,我们来整理下密码登录的逻辑

  1. 在用户已经设置了密码的情况下,才可使用密码登录。
  2. 未设置密码的用户登录(Github 或 Email)后,会弹出密码设置提示,完成密码设置后才可进行(强制 🤔 当然这也可不强制,取决于你)其他操作。
  3. 已设置密码的用户,可以使用任意方式登录,且登录后不会有密码设置提示。

密码验证

实现 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 提供的自定义认证包装器
  • idname 是不可少的,id 是包装器的唯一标识,name 是包装器显示的名称
  • credentials 用来设置认证项,这里我们需要用户输入 emailpassword,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 表单里的 emailpassword
  • req 请求
  • Awaitable<User | null> 接受异步的响应,当返回 null 则认证失败,返回 User 对象认证成功,这里如果中途执行有 Error 未被处理,则会跳转到 NextAuth 的 Error 页面

这里有点要注意的就是,虽然上面我写了 throw new Error(...) 但这些信息被捕获后,仅仅是打印出来了,不需要抛到 NextAuth 层面,原因是在 NextAuth 自带的页面中,自定义判断逻辑错误不会被认为是某些验证错误,而是直接认为是程序错误,就跑到 Error 页面了 😓,这里错误的话,仅可以返回 null,如果要更全面的自定义错误信息,我的建议是自定义页面吧(下一篇文章准备 🤪)

来看看设置完的效果

Next NextAuth Password Next NextAuth Password

可以看到,这里 Sign in with Password 就是我们新加的了 Password 这几个文字就是 CredentialsProvider 里的 name 设置的。这里的顺序 Github, Email, Email+Password 也是我们在 NextAuth Providers 中的配置位置(NextAuth 实际这里用了循环直接获取 Providers 展示

输入 Email,密码输入任意值

Next NextAuth Password Next NextAuth Password

可以看到 url 携带了错误信息(实际上 NextAuth 内部使用的重定向,这里表单也清空了)提示信息也是默认的登录失败信息(下一篇自定义页面来优化吧)

这里很明显是我们的这个邮箱没有设置过密码,那么是无法使用密码登录。

Next NextAuth Password

提示用户设置密码

先使用其他方式登录账号,之后再来设置密码

  1. 实现密码设置的接口 /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 就好。
  1. 获取是否已设置密码 如何知道用户是否设置了密码?难道需要一个接口去查询嘛?太麻烦了吧!可以直接用 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

Next NextAuth Password

类型错误的问题,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"];
  }
}
  1. 没有设置密码就展示弹窗 我们希望在除了认证外的页面,当用户登录后,都在没设置密码的情况下显示弹窗,这里最好的方式在放在当前的 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,此时我们打开页面,登录状态下刷新

Next NextAuth Password

这里有循环为空判断,所以取消没用,乖乖输入吧!

Next NextAuth Password Next NextAuth Password Next NextAuth Password

ok, 退出登录,使用密码登录来试试

Next NextAuth Password Next NextAuth Password

🤙 成功啦

这是一个教程案例,更多的验证,UI 等优化大家根据需要自己处理吧。

© 2019 - 2024, Hehehai 晋ICP备2024032508号-1