本文将带你从零开始,使用 Next.js 15、NextAuth v4、Drizzle ORM 和 PostgreSQL 构建一个功能完备的用户认证系统,包括注册、登录、会话管理和用户信息显示。

一、项目初始化与技术栈安装

首先,创建一个新的 Next.js 15 项目并安装必要的依赖。

创建项目

npx create-next-app@latest my-auth-app
cd my-auth-app
# 选择 TypeScript, Tailwind CSS, App Router 等选项

安装核心依赖

# 安装 NextAuth, Drizzle, Postgres.js, bcryptjs
npm install next-auth@4 drizzle-orm postgres bcryptjs
npm install -D @types/bcryptjs drizzle-kit
二、数据库设计与Drizzle配置

1. 定义数据库Schema
src/db/schema.ts中定义用户表。确保包含emailpassword字段。

// src/db/schema.ts
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: uuid('id').defaultRandom().primaryKey(),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  password: text('password').notNull(), // 用于存储加密后的密码
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

export type User = typeof users.$inferSelect;

2. 配置数据库连接单例
src/db/index.ts中创建数据库连接。这能避免在 Next.js 开发环境中因热更新导致的连接数耗尽问题。

// src/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';

const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString);

export const db = drizzle(client, { schema });

3. 初始化数据库
配置好.env文件中的DATABASE_URL后,使用 Drizzle Kit 生成并执行迁移,在 Postgres 中创建users表。

npx drizzle-kit generate
npx drizzle-kit migrate
三、实现注册功能

NextAuth 只负责认证(登录),不负责注册。我们需要自己创建一个 API 路由来处理用户注册和密码加密。

创建注册API路由
src/app/api/auth/register/route.ts中:

// src/app/api/auth/register/route.ts
import { NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import { db } from '@/db';
import { users } from '@/db/schema';
import { eq } from 'drizzle-orm';

export async function POST(request: Request) {
  try {
    const { name, email, password } = await request.json();

    // 1. 基础验证
    if (!name || !email || !password) {
      return NextResponse.json({ error: '所有字段均为必填' }, { status: 400 });
    }

    // 2. 检查用户是否已存在
    const existingUser = await db.select().from(users).where(eq(users.email, email));
    if (existingUser.length > 0) {
      return NextResponse.json({ error: '邮箱已被注册' }, { status: 400 });
    }

    // 3. 加密密码
    const hashedPassword = await bcrypt.hash(password, 10);

    // 4. 将用户信息存入数据库
    await db.insert(users).values({
      name,
      email,
      password: hashedPassword,
    });

    return NextResponse.json({ message: '注册成功' }, { status: 201 });
  } catch (error) {
    console.error('注册错误:', error);
    return NextResponse.json({ error: '服务器内部错误' }, { status: 500 });
  }
}
四、配置NextAuth进行登录认证

1. 配置NextAuth选项
src/lib/auth.ts中配置 NextAuth,使用CredentialsProviderDrizzle来验证用户。

// src/lib/auth.ts
import { NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { db } from '@/db';
import { users } from '@/db/schema';
import { eq } from 'drizzle-orm';
import bcrypt from 'bcryptjs';

export const authOptions: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      name: 'credentials',
      credentials: {
        email: { label: '邮箱', type: 'email' },
        password: { label: '密码', type: 'password' },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          return null;
        }

        // 1. 从数据库查询用户
        const user = await db.select().from(users).where(eq(users.email, credentials.email));

        if (user.length === 0) {
          return null; // 用户不存在
        }

        const dbUser = user[0];

        // 2. 验证密码
        const isPasswordValid = await bcrypt.compare(
          credentials.password,
          dbUser.password
        );

        if (!isPasswordValid) {
          return null; // 密码错误
        }

        // 3. 返回用户信息(不含密码)
        return {
          id: dbUser.id,
          email: dbUser.email,
          name: dbUser.name,
        };
      },
    }),
  ],
  session: {
    strategy: 'jwt', // 使用 JWT 策略
  },
  pages: {
    signIn: '/login', // 指定登录页面路径
  },
  callbacks: {
    async jwt({ token, user }) {
      // 将用户ID存入JWT
      if (user) {
        token.id = user.id;
      }
      return token;
    },
    async session({ session, token }) {
      // 将JWT中的ID放入session
      if (session.user) {
        session.user.id = token.id as string;
      }
      return session;
    },
  },
};

2. 创建NextAuth API路由
src/app/api/auth/[...nextauth]/route.ts中:

// src/app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST };
五、构建登录注册页面

1. 创建登录/注册合一页面
src/app/login/page.tsx中创建一个包含登录和注册表单的页面。

// src/app/login/page.tsx
'use client';

import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';

export default function LoginPage() {
  const [isLogin, setIsLogin] = useState(true);
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [name, setName] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setLoading(true);

    if (isLogin) {
      // 登录逻辑
      const result = await signIn('credentials', {
        email,
        password,
        redirect: false,
      });

      if (result?.error) {
        setError('邮箱或密码错误');
      } else {
        router.push('/dashboard'); // 登录成功后跳转
        router.refresh();
      }
    } else {
      // 注册逻辑
      const res = await fetch('/api/auth/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name, email, password }),
      });

      const data = await res.json();
      if (!res.ok) {
        setError(data.error);
      } else {
        alert('注册成功,请登录!');
        setIsLogin(true);
      }
    }
    setLoading(false);
  };

  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-100">
      <div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
        <h1 className="mb-6 text-center text-2xl font-bold">
          {isLogin ? '登录' : '注册'}
        </h1>
        <form onSubmit={handleSubmit} className="space-y-4">
          {!isLogin && (
            <div>
              <label className="block text-sm font-medium text-gray-700">姓名</label>
              <input
                type="text"
                value={name}
                onChange={(e) => setName(e.target.value)}
                required
                className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm"
              />
            </div>
          )}
          <div>
            <label className="block text-sm font-medium text-gray-700">邮箱</label>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm"
            />
          </div>
          <div>
            <label className="block text-sm font-medium text-gray-700">密码</label>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm"
            />
          </div>
          {error && <p className="text-sm text-red-600">{error}</p>}
          <button
            type="submit"
            disabled={loading}
            className="w-full rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:bg-blue-400"
          >
            {loading ? '处理中...' : isLogin ? '登录' : '注册'}
          </button>
        </form>
        <p className="mt-4 text-center text-sm">
          {isLogin ? '还没有账号?' : '已有账号?'}
          <button
            onClick={() => setIsLogin(!isLogin)}
            className="ml-1 text-blue-600 hover:underline"
          >
            {isLogin ? '注册' : '登录'}
          </button>
        </p>
      </div>
    </div>
  );
}
六、显示用户信息与保护路由

1. 创建SessionProvider
src/components/Providers.tsx中创建SessionProvider组件,用于包裹整个应用,使useSession钩子可用。

// src/components/Providers.tsx
'use client';

import { SessionProvider } from 'next-auth/react';

export function Providers({ children }: { children: React.ReactNode }) {
  return <SessionProvider>{children}</SessionProvider>;
}

2. 在根布局中包裹Provider
src/app/layout.tsx中使用Providers组件。

// src/app/layout.tsx
import type { Metadata } from 'next';
import { Providers } from '@/components/Providers';
import './globals.css';

export const metadata: Metadata = {
  title: 'NextAuth + Drizzle App',
  description: '用户认证系统',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="zh">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

3. 创建受保护的仪表盘页面
src/app/dashboard/page.tsx中创建一个页面,只有登录用户才能访问,并显示用户信息。

// src/app/dashboard/page.tsx
import { getServerSession } from 'next-auth';
import { redirect } from 'next/navigation';
import { authOptions } from '@/lib/auth';

export default async function DashboardPage() {
  const session = await getServerSession(authOptions);

  // 如果用户未登录,重定向到登录页
  if (!session) {
    redirect('/login');
  }

  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-100">
      <div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
        <h1 className="mb-6 text-center text-2xl font-bold">欢迎回来!</h1>
        <div className="space-y-2">
          <p><strong>姓名:</strong> {session.user?.name}</p>
          <p><strong>邮箱:</strong> {session.user?.email}</p>
          <p><strong>用户ID:</strong> {session.user?.id}</p>
        </div>
      </div>
    </div>
  );
}
七、环境变量配置

在项目根目录创建.env文件,并添加以下内容:

# 数据库连接字符串
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"

# NextAuth 密钥,用于加密 JWT
# 可以通过 `openssl rand -base64 32` 命令生成
NEXTAUTH_SECRET="your-super-secret-key-here"

# NextAuth URL,开发环境通常是 localhost
NEXTAUTH_URL="http://localhost:3000"
八、await signIn
const result = await signIn('credentials', {
        email,
        password,
        redirect: false, // 阻止自动跳转,我们自己控制
      });

登录逻辑好像就只有这一段代码,是怎么关联api/auth/[…nextauth]/route.ts中的接口调用的?
这确实是 NextAuth 最“魔法”的地方!明明只写了一行 signIn,它背后却自动帮你跑通了整个流程。

其实,signIn 这个函数内部封装了**“自动寻址”**的逻辑。虽然你只传了 'credentials' 这个字符串,但 NextAuth 早就通过“约定”知道该去哪里找谁了。

我们可以把这个过程拆解为三步“自动导航”

1. 它是如何找到 /api/auth/... 这个地址的?

当你初始化 NextAuth 时(在 route.ts 里),你实际上是在告诉 NextAuth:“嘿,我的认证服务就部署在 /api/auth/[...nextauth] 这个路径下”。

signIn 函数默认就认准了这个标准路径。

  • 当你调用 signIn('credentials', ...) 时,它内部会立刻拼接出一个目标 URL:
    /api/auth/callback/credentials

注意:这里的 callbackcredentials 不是随便写的,而是 NextAuth 内部规定的标准动作路径。

2. 它是如何知道要调用 route.ts 的?

这就回到了我们刚才讨论的 [...nextauth]

  • 前端发出的请求POST /api/auth/callback/credentials
  • Next.js 的路由匹配
    • 它看到 api/auth/ 匹配上了。
    • 它看到后面跟着 callback/credentials(这是一串路径)。
    • 它发现只有 [...nextauth] 这个通配符文件夹能吞下这串路径。
  • 结果:Next.js 自动把请求转交给 app/api/auth/[...nextauth]/route.ts 里的 handler 处理。

3. 它是如何触发 authorize 函数的?

这是最关键的一步。当请求到达 route.ts 后:

  1. NextAuth 的 handler 接收到请求。
  2. 它分析 URL 路径,发现是 /callback/credentials
  3. 它立刻明白:“哦,这是用户提交账号密码来验证了”。
  4. 于是,它去你配置的 providers 列表里找名为 credentials 的那个配置。
  5. 找到后,它自动执行你写在那个配置里的 authorize 函数。

🔗 完整的连线图

你可以这样理解它们之间的“隐形连线”:

[前端代码]
   ↓ 调用 signIn('credentials', ...)
   ↓
[NextAuth 客户端 SDK]
   ↓ 自动拼接 URL -> /api/auth/callback/credentials
   ↓ 自动带上 CSRF Token (刚才悄悄拿到的)
   ↓ 发起 POST 请求
   ↓
[Next.js 路由系统]
   ↓ 发现路径匹配 [...nextauth]
   ↓ 转发给 app/api/auth/[...nextauth]/route.ts
   ↓
[NextAuth 服务端 Handler]
   ↓ 识别路径是 /callback/credentials
   ↓ 找到 CredentialsProvider 配置
   ↓ 执行你写的 authorize(credentials) 函数
   ↓
[数据库]
   ↓ 验证成功/失败

之所以你觉得“代码很少”,是因为 NextAuth 帮你把“拼接 URL”、“发起请求”、“路由匹配”、“调用函数”这些繁琐的步骤都封装在 signIn 这个黑盒子里了

你只需要告诉它:“我要用 credentials 方式登录”,它就知道该去敲哪扇门。

九、动态路由

这两个问题问得非常深入!这说明你已经开始透过现象看本质了。能关注到这些细节,说明你离精通 NextAuth 不远了。

我来逐一为你揭开这层神秘的面纱。


1. [...nextauth] 是什么?

这个写法在 Next.js 中叫做 动态路由(Catch-all Segments)

通俗解释

想象你的 app/api/auth/ 文件夹是一个公司的前台。

  • 如果文件夹叫 signin,那只有 /api/auth/signin 这一个房间。
  • 如果文件夹叫 [...nextauth],那它就是一个万能总管。它告诉 Next.js:“嘿,不管谁访问 /api/auth/ 后面的什么路径(不管是 signincallbacksession 还是 signout),统统都交给我这个文件来处理!”
为什么要这么写?

NextAuth 需要处理很多种请求,比如:

  • POST /api/auth/signin (用户提交登录)
  • GET /api/auth/signout (用户退出)
  • GET /api/auth/session (前端检查用户是否登录)
  • GET /api/auth/providers (获取支持的登录方式列表)

如果不用 [...nextauth],你就得在 app/api/auth/ 下面建无数个文件夹(signin, signout, session…),每个里面放一个 route.ts。那样太乱了!

结论[...nextauth] 就是一个统一的入口,NextAuth 内部会根据 URL 的路径(比如 /callback/credentials)来自动判断该执行登录逻辑还是登出逻辑。你不需要改这个文件名,它是 NextAuth 的标准写法。


2. 点击登录时,那三个接口是怎么回事?

这三个接口是 NextAuth 客户端(signIn 函数) 自动触发的,目的是为了保证安全和流程正确。

我们可以把这个过程看作是一次**“特工接头”**:

第一阶段:侦察与拿令牌

接口 1:/api/auth/providers

  • 什么时候触发:页面加载时,或者调用 signIn 之前。
  • 作用:前端问后端:“嘿,你支持哪些登录方式?”(GitHub?Google?还是 Credentials?)
  • 联系:NextAuth 客户端拿到这个列表,确认“哦,原来有 Credentials 模式”,然后才继续下一步。

接口 2:/api/auth/csrf

  • 什么时候触发:在真正提交登录表单之前
  • 作用这是为了安全(防跨站攻击 CSRF)
    • 浏览器先去拿一个“一次性暗号”(CSRF Token)。
    • 这个暗号会存在 Cookie 里,也会返回给前端。
  • 联系:如果没有这个暗号,黑客就可以伪造一个假页面,冒充你的用户去提交登录。NextAuth 强制要求:“没有暗号,我不处理登录请求”。
第二阶段:正式提交

接口 3:/api/auth/callback/credentials

  • 什么时候触发:当你点击登录按钮,且拿到了 CSRF Token 之后。
  • 作用这是真正的“战场”
    • 前端把 { email, password, csrfToken } 打包发给这个接口。
    • 这就是我们在 route.ts 里配置的 authorize 函数被执行的地方。
    • 验证通过后,服务器会生成 Session(或者 JWT),并写入 Cookie。

📌 总结一下它们的联系

当你点击“登录”按钮的那一刻,NextAuth 帮你自动执行了以下剧本:

  1. 先看一眼(providers):确认后端支持 Credentials 登录。
  2. 拿通行证(csrf):先去 /api/auth/csrf 拿一个安全令牌,防止被攻击。
  3. 正式闯关(callback/credentials):带着你的账号密码 + 安全令牌,去 /api/auth/callback/credentials 进行验证。
    • 如果验证成功 -> 后端返回 Session -> 页面跳转/刷新。
    • 如果验证失败 -> 后端返回错误 -> 前端显示“密码错误”。

3. 写成[nextaut]可以吗?

哈哈,千万别! 除非你想给自己挖个坑跳进去。

虽然从 Next.js 的语法层面来说,你把文件夹写成 [nextauth] 确实能跑通(它依然是个动态路由),但如果你这么做,NextAuth 这个库本身会直接罢工

原因很简单:NextAuth 的代码里写死了路径规则

🛑 为什么不能改?

NextAuth 的客户端代码(就是你调用的 signIn 函数)内部默认会去请求以下路径:

  • /api/auth/signin
  • /api/auth/session
  • /api/auth/providers

当你把文件夹命名为 [...nextauth] 时,Next.js 会完美匹配这些路径。

但如果你改成 [nextauth](少那三个点):

  • 它变成了一个单层动态路由。
  • 它只能匹配 /api/auth/signin 这一层。
  • 但是,NextAuth 的回调流程(比如 callback/credentials)是两层路径。
  • 结果就是:/api/auth/signin 可能还能访问,但涉及到登录跳转、回调验证的时候,路由匹配会直接报错(404 或 500),因为 [nextauth] 处理不了 callback/credentials 这种带斜杠的子路径。

💡 简单总结

  • [...nextauth]:是 Catch-all(通配) 路由。它能吞下 /api/auth/ 后面所有的路径(不管多深)。这是 NextAuth 官方规定的标准写法。
  • [nextauth]:是 普通动态 路由。它只能吞下一级路径。

结论:保留那三个点**。它是 NextAuth 能够正常工作的“暗号”。

十、总结

通过以上步骤,你已经成功构建了一个完整的认证系统:

  • 数据库:使用 Drizzle 定义了用户表并处理了连接。
  • 注册:创建了独立的 API 路由来处理新用户注册和密码加密。
  • 登录:使用 NextAuth 的 CredentialsProvider 配合 Drizzle 查询数据库并验证密码。
  • 前端:构建了一个登录注册合一的页面,并使用 signIn 函数处理登录。
  • 会话:通过 SessionProvidergetServerSession 实现了会话管理和受保护路由。
  • 用户信息:在仪表盘页面成功获取并显示了当前登录用户的信息。
Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐