第 11 章:认证体系(NextAuth.js v5 全解)

从零构建 Next.js 认证系统——NextAuth.js v5 (Auth.js) 配置、邮箱登录、GitHub OAuth、Session 管理、角色权限(RBAC),以及 Lucia / Clerk 方案对比。

本章目标:全面掌握 Next.js 中的认证体系——从 NextAuth.js v5(Auth.js)的安装配置、多种登录方式、Session 管理,到角色权限控制(RBAC),并了解 Lucia / Clerk 等替代方案的选型策略。


11.1 认证方案选型

三大主流方案对比

维度NextAuth.js v5LuciaClerk
类型开源库开源库SaaS 服务
定价免费免费免费层 + 付费
数据库自带 Adapter(Prisma 等)你自己管理Clerk 托管
UI 组件需自建需自建提供完整 UI
OAuth内置 30+ Provider内置支持内置支持
Session 策略JWT / Database仅 Database托管
App Router✅ 原生支持✅ 原生支持✅ 原生支持
Edge Runtime⚠️ 部分支持✅ 良好✅ 良好
自定义程度极高
学习曲线
适合场景通用 Web 应用需要完全控制快速上线 / MVP

本教程选择:NextAuth.js v5

理由:

  1. 生态最成熟:社区最大、文档最全、问题解答最多
  2. 与 Next.js 深度集成:App Router 原生支持
  3. 灵活性高:JWT / Database Session 可选
  4. OAuth 支持广泛:GitHub / Google / 微信 等 30+ Provider

11.2 NextAuth.js v5 安装与配置

安装

npm install next-auth@beta @auth/prisma-adapter
npm install bcryptjs
npm install -D @types/bcryptjs

注意:NextAuth.js v5 目前仍以 next-auth@beta 发布,最终将迁移为 next-auth@5

环境变量

# .env

# NextAuth
AUTH_SECRET="your-random-secret-at-least-32-chars"
# 生成方式:npx auth secret

AUTH_URL="http://localhost:3000"

# OAuth Providers
AUTH_GITHUB_ID="your-github-client-id"
AUTH_GITHUB_SECRET="your-github-client-secret"

AUTH_GOOGLE_ID="your-google-client-id"
AUTH_GOOGLE_SECRET="your-google-client-secret"

核心配置文件

// auth.ts(项目根目录)

import NextAuth from 'next-auth';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/prisma';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import { compare } from 'bcryptjs';
import type { NextAuthConfig } from 'next-auth';

export const authConfig: NextAuthConfig = {
  adapter: PrismaAdapter(prisma),

  // Session 策略:JWT(无状态,适合 Serverless)
  session: {
    strategy: 'jwt',
    maxAge: 60 * 60 * 24 * 7, // 7 天
  },

  // OAuth + 邮箱密码 Provider
  providers: [
    GitHub({
      clientId: process.env.AUTH_GITHUB_ID!,
      clientSecret: process.env.AUTH_GITHUB_SECRET!,
    }),
    Google({
      clientId: process.env.AUTH_GOOGLE_ID!,
      clientSecret: process.env.AUTH_GOOGLE_SECRET!,
    }),
    Credentials({
      name: 'credentials',
      credentials: {
        email: { label: '邮箱', type: 'email' },
        password: { label: '密码', type: 'password' },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          return null;
        }

        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
        });

        if (!user || !user.password) {
          return null;
        }

        const isValid = await compare(
          credentials.password as string,
          user.password
        );

        if (!isValid) {
          return null;
        }

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
        };
      },
    }),
  ],

  // 自定义页面路径
  pages: {
    signIn: '/login',
    signUp: '/register',
    error: '/auth/error',
    verifyRequest: '/auth/verify',
  },

  // 回调函数
  callbacks: {
    // JWT 回调:将用户信息写入 token
    async jwt({ token, user, trigger, session }) {
      // 首次登录时,将用户信息写入 token
      if (user) {
        token.id = user.id;
        token.role = (user as any).role;
      }

      // 支持客户端触发 session 更新
      if (trigger === 'update' && session) {
        token.name = session.name;
        token.picture = session.picture;
      }

      return token;
    },

    // Session 回调:从 token 读取信息到 session
    async session({ session, token }) {
      if (session.user) {
        session.user.id = token.id as string;
        session.user.role = token.role as string;
      }
      return session;
    },

    // 登录成功后重定向
    async redirect({ url, baseUrl }) {
      // 相对路径重定向
      if (url.startsWith('/')) return `${baseUrl}${url}`;
      // 同域重定向
      if (new URL(url).origin === baseUrl) return url;
      // 默认重定向到首页
      return baseUrl;
    },
  },

  // 事件钩子
  events: {
    // 新用户注册时
    async createUser({ user }) {
      console.log('New user created:', user.email);
    },

    // OAuth 登录关联账号时
    async linkAccount({ user, account, profile }) {
      console.log('Account linked:', account.provider, user.email);
    },
  },
};

export const {
  handlers,
  auth,
  signIn,
  signOut,
} = NextAuth(authConfig);

路由挂载

// app/api/auth/[...nextauth]/route.ts

import { handlers } from '@/auth';

export const { GET, POST } = handlers;

类型扩展

// types/next-auth.d.ts

import 'next-auth';
import 'next-auth/jwt';

declare module 'next-auth' {
  interface Session {
    user: {
      id: string;
      email: string;
      name?: string | null;
      image?: string | null;
      role: string;
    };
  }

  interface User {
    id: string;
    email: string;
    name?: string | null;
    role: string;
  }
}

declare module 'next-auth/jwt' {
  interface JWT {
    id: string;
    role: string;
  }
}

11.3 登录 / 注册页面

登录页面

// app/(auth)/login/page.tsx

import { signIn } from '@/auth';
import { LoginForm } from '@/app/components/auth/LoginForm';
import { redirect } from 'next/navigation';
import { auth } from '@/auth';

export default async function LoginPage({
  searchParams,
}: {
  searchParams: Promise<{ callbackUrl?: string }>;
}) {
  // 已登录用户直接重定向
  const session = await auth();
  if (session?.user) {
    redirect('/dashboard');
  }

  const params = await searchParams;
  const callbackUrl = params.callbackUrl || '/dashboard';

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="w-full max-w-md space-y-8 px-4">
        <div className="text-center">
          <h1 className="text-3xl font-bold">欢迎回来</h1>
          <p className="mt-2 text-gray-600">登录你的账号</p>
        </div>

        {/* 登录表单 */}
        <LoginForm callbackUrl={callbackUrl} />

        {/* 分割线 */}
        <div className="relative">
          <div className="absolute inset-0 flex items-center">
            <div className="w-full border-t border-gray-300" />
          </div>
          <div className="relative flex justify-center text-sm">
            <span className="px-2 bg-gray-50 text-gray-500">或使用第三方登录</span>
          </div>
        </div>

        {/* OAuth 按钮 */}
        <div className="space-y-3">
          <form
            action={async () => {
              'use server';
              await signIn('github', { redirectTo: callbackUrl });
            }}
          >
            <button
              type="submit"
              className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
            >
              <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
                <path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
              </svg>
              GitHub 登录
            </button>
          </form>

          <form
            action={async () => {
              'use server';
              await signIn('google', { redirectTo: callbackUrl });
            }}
          >
            <button
              type="submit"
              className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
            >
              <svg className="w-5 h-5" viewBox="0 0 24 24">
                <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
                <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
                <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
                <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
              </svg>
              Google 登录
            </button>
          </form>
        </div>

        <p className="text-center text-sm text-gray-600">
          还没有账号?{' '}
          <a href="/register" className="text-blue-600 hover:underline">
            立即注册
          </a>
        </p>
      </div>
    </div>
  );
}

登录表单组件

// app/components/auth/LoginForm.tsx

'use client';

import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';

type LoginState = {
  error?: string;
};

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button
      type="submit"
      disabled={pending}
      className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors font-medium"
    >
      {pending ? '登录中...' : '登录'}
    </button>
  );
}

export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
  const router = useRouter();

  const [state, formAction] = useActionState<LoginState, FormData>(
    async (prevState, formData) => {
      const email = formData.get('email') as string;
      const password = formData.get('password') as string;

      if (!email || !password) {
        return { error: '请填写邮箱和密码' };
      }

      try {
        const result = await signIn('credentials', {
          email,
          password,
          redirect: false,
          callbackUrl,
        });

        if (result?.error) {
          return { error: '邮箱或密码错误' };
        }

        if (result?.url) {
          router.push(result.url);
          router.refresh();
        }
      } catch {
        return { error: '登录失败,请稍后重试' };
      }

      return {};
    },
    { error: undefined }
  );

  return (
    <form action={formAction} className="space-y-4">
      {state.error && (
        <div className="p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
          {state.error}
        </div>
      )}

      <div>
        <label htmlFor="email" className="block text-sm font-medium mb-1">
          邮箱
        </label>
        <input
          id="email"
          name="email"
          type="email"
          autoComplete="email"
          required
          className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
          placeholder="your@email.com"
        />
      </div>

      <div>
        <label htmlFor="password" className="block text-sm font-medium mb-1">
          密码
        </label>
        <input
          id="password"
          name="password"
          type="password"
          autoComplete="current-password"
          required
          className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
          placeholder="••••••••"
        />
      </div>

      <div className="flex items-center justify-between">
        <label className="flex items-center gap-2 text-sm">
          <input type="checkbox" name="remember" className="rounded" />
          记住我
        </label>
        <a href="/auth/forgot-password" className="text-sm text-blue-600 hover:underline">
          忘记密码?
        </a>
      </div>

      <SubmitButton />
    </form>
  );
}

注册页面

// app/(auth)/register/page.tsx

import { RegisterForm } from '@/app/components/auth/RegisterForm';
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function RegisterPage() {
  const session = await auth();
  if (session?.user) {
    redirect('/dashboard');
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="w-full max-w-md space-y-8 px-4">
        <div className="text-center">
          <h1 className="text-3xl font-bold">创建账号</h1>
          <p className="mt-2 text-gray-600">开始你的创作之旅</p>
        </div>

        <RegisterForm />

        <p className="text-center text-sm text-gray-600">
          已有账号?{' '}
          <a href="/login" className="text-blue-600 hover:underline">
            立即登录
          </a>
        </p>
      </div>
    </div>
  );
}

注册 Server Action

// app/actions/auth.ts
'use server';

import { prisma } from '@/lib/prisma';
import { hash } from 'bcryptjs';
import { z } from 'zod';
import { signIn } from '@/auth';

const registerSchema = z.object({
  name: z.string().min(2, '昵称至少 2 个字符').max(50),
  email: z.string().email('邮箱格式不正确'),
  password: z.string().min(8, '密码至少 8 个字符'),
});

export type RegisterState = {
  error?: string;
  fieldErrors?: Record<string, string>;
};

export async function register(
  prevState: RegisterState,
  formData: FormData
): Promise<RegisterState> {
  const raw = {
    name: formData.get('name'),
    email: formData.get('email'),
    password: formData.get('password'),
  };

  const validation = registerSchema.safeParse(raw);
  if (!validation.success) {
    const fieldErrors: Record<string, string> = {};
    for (const issue of validation.error.issues) {
      const field = issue.path[0]?.toString() ?? 'form';
      fieldErrors[field] = issue.message;
    }
    return { fieldErrors };
  }

  const { name, email, password } = validation.data;

  // 检查邮箱是否已注册
  const existing = await prisma.user.findUnique({ where: { email } });
  if (existing) {
    return { error: '该邮箱已被注册' };
  }

  // 创建用户
  const hashedPassword = await hash(password, 12);
  const user = await prisma.user.create({
    data: {
      name,
      email,
      password: hashedPassword,
      emailVerified: new Date(),
    },
  });

  // 自动登录
  await signIn('credentials', {
    email,
    password,
    redirectTo: '/dashboard',
  });

  return {};
}

11.4 Session 管理

在 Server Component 中获取 Session

// app/dashboard/layout.tsx

import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await auth();

  if (!session?.user) {
    redirect('/login?callbackUrl=/dashboard');
  }

  return (
    <div className="flex min-h-screen">
      <aside className="w-64 bg-gray-900 text-white p-4">
        <div className="flex items-center gap-3 mb-8">
          {session.user.image ? (
            <img
              src={session.user.image}
              alt={session.user.name || ''}
              className="w-10 h-10 rounded-full"
            />
          ) : (
            <div className="w-10 h-10 rounded-full bg-blue-600 flex items-center justify-center text-white font-bold">
              {session.user.name?.[0] || session.user.email[0]}
            </div>
          )}
          <div className="overflow-hidden">
            <p className="font-medium truncate">{session.user.name}</p>
            <p className="text-sm text-gray-400 truncate">{session.user.email}</p>
          </div>
        </div>

        <nav className="space-y-1">
          <a href="/dashboard" className="block px-3 py-2 rounded hover:bg-gray-800">
            概览
          </a>
          <a href="/dashboard/articles" className="block px-3 py-2 rounded hover:bg-gray-800">
            文章管理
          </a>
          <a href="/dashboard/settings" className="block px-3 py-2 rounded hover:bg-gray-800">
            设置
          </a>
        </nav>
      </aside>

      <main className="flex-1 p-8">
        {children}
      </main>
    </div>
  );
}

在 Client Component 中获取 Session

// app/components/UserMenu.tsx
'use client';

import { useSession, signOut } from 'next-auth/react';

export function UserMenu() {
  const { data: session, status } = useSession();

  if (status === 'loading') {
    return <div className="animate-pulse h-10 w-32 bg-gray-200 rounded" />;
  }

  if (!session?.user) {
    return (
      <a href="/login" className="text-sm text-blue-600 hover:underline">
        登录
      </a>
    );
  }

  return (
    <div className="flex items-center gap-3">
      <span className="text-sm">{session.user.name || session.user.email}</span>
      <button
        onClick={() => signOut({ callbackUrl: '/' })}
        className="text-sm text-red-600 hover:underline"
      >
        退出
      </button>
    </div>
  );
}

注意:在 Client Component 中使用 useSession 需要在根布局中包裹 SessionProvider

// app/providers.tsx
'use client';

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

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

// app/layout.tsx
import { Providers } from './providers';

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

在 Server Action 中获取 Session

// app/actions/article.ts
'use server';

import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export async function createArticle(formData: FormData) {
  // 获取当前用户 session
  const session = await auth();

  if (!session?.user?.id) {
    redirect('/login');
  }

  // session.user.id 是类型安全的
  const article = await prisma.article.create({
    data: {
      title: formData.get('title') as string,
      content: formData.get('content') as string,
      authorId: session.user.id,
    },
  });

  return article;
}

在 Route Handler 中获取 Session

// app/api/me/route.ts

import { auth } from '@/auth';
import { NextResponse } from 'next/server';

export async function GET() {
  const session = await auth();

  if (!session?.user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  return NextResponse.json(session.user);
}

11.5 角色权限控制(RBAC)

权限辅助函数

// lib/auth-utils.ts

import { auth } from '@/auth';
import { redirect } from 'next/navigation';

// 获取当前用户(未登录返回 null)
export async function getCurrentUser() {
  const session = await auth();
  return session?.user ?? null;
}

// 要求登录(未登录重定向)
export async function requireAuth() {
  const user = await getCurrentUser();
  if (!user) {
    redirect('/login');
  }
  return user;
}

// 要求特定角色
export async function requireRole(role: string | string[]) {
  const user = await requireAuth();

  const roles = Array.isArray(role) ? role : [role];

  if (!roles.includes(user.role)) {
    redirect('/403');
  }

  return user;
}

// 检查权限(不重定向,返回 boolean)
export async function hasPermission(resource: string, action: string) {
  const user = await getCurrentUser();
  if (!user) return false;

  const permissions: Record<string, string[]> = {
    admin: ['article:*', 'comment:*', 'user:*', 'settings:*'],
    editor: ['article:create', 'article:read', 'article:update', 'comment:*', 'user:read'],
    user: ['article:create', 'article:read', 'comment:create', 'comment:read', 'user:read'],
  };

  const userPermissions = permissions[user.role] || [];
  const requiredPermission = `${resource}:${action}`;

  return userPermissions.some(
    (perm) =>
      perm === requiredPermission ||
      perm === `${resource}:*` ||
      perm === '*:*'
  );
}

在页面中应用权限

// app/admin/page.tsx

import { requireRole } from '@/lib/auth-utils';
import { prisma } from '@/lib/prisma';

export default async function AdminPage() {
  // 只有管理员可以访问
  const user = await requireRole('admin');

  const [userCount, articleCount, commentCount] = await Promise.all([
    prisma.user.count(),
    prisma.article.count(),
    prisma.comment.count(),
  ]);

  return (
    <div>
      <h1 className="text-2xl font-bold mb-6">管理后台</h1>
      <p className="text-gray-600 mb-8">欢迎,{user.name}(管理员)</p>

      <div className="grid grid-cols-3 gap-6">
        <div className="bg-white p-6 rounded-lg shadow">
          <h3 className="text-lg font-semibold">用户</h3>
          <p className="text-3xl font-bold text-blue-600 mt-2">{userCount}</p>
        </div>
        <div className="bg-white p-6 rounded-lg shadow">
          <h3 className="text-lg font-semibold">文章</h3>
          <p className="text-3xl font-bold text-green-600 mt-2">{articleCount}</p>
        </div>
        <div className="bg-white p-6 rounded-lg shadow">
          <h3 className="text-lg font-semibold">评论</h3>
          <p className="text-3xl font-bold text-purple-600 mt-2">{commentCount}</p>
        </div>
      </div>
    </div>
  );
}

条件渲染组件

// app/components/auth/RoleGuard.tsx

import { auth } from '@/auth';

type RoleGuardProps = {
  roles: string | string[];
  children: React.ReactNode;
  fallback?: React.ReactNode;
};

export async function RoleGuard({ roles, children, fallback }: RoleGuardProps) {
  const session = await auth();
  const userRole = session?.user?.role;

  const requiredRoles = Array.isArray(roles) ? roles : [roles];
  const hasAccess = userRole && requiredRoles.includes(userRole);

  if (!hasAccess) {
    return fallback ? <>{fallback}</> : null;
  }

  return <>{children}</>;
}

使用方式:

// app/articles/[slug]/page.tsx

import { RoleGuard } from '@/app/components/auth/RoleGuard';

export default async function ArticlePage({ params }: { params: { slug: string } }) {
  const article = await getArticle(params.slug);

  return (
    <article>
      <h1>{article.title}</h1>

      {/* 只有作者或管理员可以看到编辑按钮 */}
      <RoleGuard roles={['admin', 'editor']}>
        <a href={`/articles/${article.slug}/edit`} className="text-blue-600">
          编辑文章
        </a>
      </RoleGuard>

      {/* 只有管理员可以看到删除按钮 */}
      <RoleGuard roles={['admin']} fallback={<span className="text-gray-400">无权限删除</span>}>
        <button className="text-red-600">删除文章</button>
      </RoleGuard>
    </article>
  );
}

权限保护 API

// app/api/v1/admin/users/route.ts

import { NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth-utils';
import { prisma } from '@/lib/prisma';

export async function GET() {
  try {
    await requireRole('admin');
  } catch {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }

  const users = await prisma.user.findMany({
    select: {
      id: true,
      email: true,
      name: true,
      role: true,
      createdAt: true,
    },
    orderBy: { createdAt: 'desc' },
  });

  return NextResponse.json(users);
}

11.6 OAuth 集成详解

GitHub OAuth 配置

  1. 前往 GitHub Developer Settings
  2. 创建 OAuth App:
    • Homepage URL: http://localhost:3000
    • Authorization callback URL: http://localhost:3000/api/auth/callback/github
  3. 复制 Client ID 和 Client Secret 到 .env

自定义 OAuth Callback

// auth.ts 中扩展 callbacks

callbacks: {
  // OAuth 登录时的账号关联逻辑
  async signIn({ user, account, profile }) {
    if (account?.provider === 'github') {
      // 检查是否已有同邮箱的账号
      const existingUser = await prisma.user.findUnique({
        where: { email: user.email! },
        include: { accounts: true },
      });

      if (existingUser && existingUser.accounts.length === 0) {
        // 关联到已有账号
        await prisma.account.create({
          data: {
            userId: existingUser.id,
            type: account.type,
            provider: account.provider,
            providerAccountId: account.providerAccountId,
            access_token: account.access_token,
            token_type: account.token_type,
          },
        });
        user.id = existingUser.id;
      }
    }

    return true;
  },
}

多账号关联

// app/dashboard/settings/accounts/page.tsx

import { auth } from '@/auth';
import { prisma } from '@/lib/prisma';
import { LinkAccountButton } from '@/app/components/auth/LinkAccountButton';

export default async function AccountsSettingsPage() {
  const session = await auth();
  if (!session?.user?.id) return null;

  const accounts = await prisma.account.findMany({
    where: { userId: session.user.id },
    select: { provider: true, providerAccountId: true },
  });

  const linkedProviders = accounts.map((a) => a.provider);

  const providers = [
    { id: 'github', name: 'GitHub', icon: '🐙' },
    { id: 'google', name: 'Google', icon: '🔍' },
  ];

  return (
    <div>
      <h2 className="text-xl font-bold mb-4">关联账号</h2>

      <div className="space-y-3">
        {providers.map((provider) => {
          const isLinked = linkedProviders.includes(provider.id);

          return (
            <div
              key={provider.id}
              className="flex items-center justify-between p-4 border rounded-lg"
            >
              <div className="flex items-center gap-3">
                <span className="text-2xl">{provider.icon}</span>
                <div>
                  <p className="font-medium">{provider.name}</p>
                  <p className="text-sm text-gray-500">
                    {isLinked ? '已关联' : '未关联'}
                  </p>
                </div>
              </div>

              {isLinked ? (
                <span className="text-sm text-green-600"> 已连接</span>
              ) : (
                <LinkAccountButton provider={provider.id} />
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

11.7 Middleware 路由保护

// middleware.ts(项目根目录)

import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

// 公开路由(不需要登录)
const publicRoutes = [
  '/',
  '/login',
  '/register',
  '/auth/error',
  '/auth/verify',
  '/articles',
  '/about',
];

// 管理员路由
const adminRoutes = ['/admin'];

// API 公开路由
const publicApiRoutes = ['/api/auth', '/api/v1/articles'];

export default async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // 跳过静态资源和 Next.js 内部路由
  if (
    pathname.startsWith('/_next') ||
    pathname.startsWith('/api/auth') ||
    pathname.includes('.')
  ) {
    return NextResponse.next();
  }

  const session = await auth();

  // API 路由保护
  if (pathname.startsWith('/api/')) {
    if (publicApiRoutes.some((route) => pathname.startsWith(route))) {
      return NextResponse.next();
    }

    if (!session) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    return NextResponse.next();
  }

  // 公开路由直接放行
  if (publicRoutes.some((route) => pathname === route || pathname.startsWith(route + '/'))) {
    return NextResponse.next();
  }

  // 需要登录的路由
  if (!session) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('callbackUrl', pathname);
    return NextResponse.redirect(loginUrl);
  }

  // 管理员路由检查
  if (adminRoutes.some((route) => pathname.startsWith(route))) {
    if (session.user.role !== 'admin') {
      return NextResponse.redirect(new URL('/403', request.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: [
    // 匹配所有路由,排除静态文件
    '/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)',
  ],
};

11.8 安全最佳实践

密码安全

// lib/password.ts

import { hash, compare } from 'bcryptjs';

const SALT_ROUNDS = 12;

export async function hashPassword(password: string): Promise<string> {
  return hash(password, SALT_ROUNDS);
}

export async function verifyPassword(
  password: string,
  hashedPassword: string
): Promise<boolean> {
  return compare(password, hashedPassword);
}

// 密码强度验证
export function validatePasswordStrength(password: string): {
  valid: boolean;
  errors: string[];
} {
  const errors: string[] = [];

  if (password.length < 8) {
    errors.push('密码至少 8 个字符');
  }
  if (!/[A-Z]/.test(password)) {
    errors.push('至少包含一个大写字母');
  }
  if (!/[a-z]/.test(password)) {
    errors.push('至少包含一个小写字母');
  }
  if (!/[0-9]/.test(password)) {
    errors.push('至少包含一个数字');
  }
  if (!/[^A-Za-z0-9]/.test(password)) {
    errors.push('至少包含一个特殊字符');
  }

  return { valid: errors.length === 0, errors };
}

Session 安全

// auth.ts 中的安全配置

export const authConfig: NextAuthConfig = {
  session: {
    strategy: 'jwt',
    maxAge: 60 * 60 * 24 * 7,     // 7 天
    updateAge: 60 * 60 * 24,       // 每 24 小时更新一次
  },

  // Cookie 安全设置
  cookies: {
    sessionToken: {
      name: 'auth-session',
      options: {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: process.env.NODE_ENV === 'production',
      },
    },
  },
};

登出保护

// app/actions/auth.ts

export async function logout() {
  'use server';
  await signOut({ redirectTo: '/' });
}
// app/components/LogoutButton.tsx
'use client';

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

export function LogoutButton() {
  return (
    <button
      onClick={() => signOut({ callbackUrl: '/' })}
      className="text-sm text-red-600 hover:underline"
    >
      退出登录
    </button>
  );
}

本章小结

Key Takeaways

  1. NextAuth.js v5 是 Next.js 认证的首选方案:与 App Router 深度集成,支持 JWT / Database Session
  2. JWT Session 适合 Serverless:无状态,不需要查库验证
  3. auth() 函数是获取 Session 的统一方式:Server Component / Server Action / Route Handler / Middleware 均可使用
  4. RBAC 需要在每一层做检查:页面级(redirect)、API 级(401/403)、Server Action 级(验证)
  5. Middleware 做路由守卫:统一拦截,避免在每个页面重复认证逻辑
  6. OAuth 集成注意账号关联:同一邮箱的多个 OAuth Provider 应该关联到同一用户

下一步

下一章我们将深入 Middleware 中间件——不仅用于认证,还可以实现国际化、A/B 测试、请求重写、日志监控等高级功能。


参考资料

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页