从 Clerk 迁移到 Better Auth

从 Clerk 迁移到 Better Auth 的分步指南。

在本指南中,我们将演示如何将项目从 Clerk 迁移到 Better Auth——包括带有正确哈希处理的邮箱/密码登录、社交及外部账户、手机号、两步验证数据等等。

此迁移将使所有活动会话失效。此指南目前未展示如何迁移组织(Organization),但可以通过额外步骤和 组织 插件实现。

开始之前

在开始迁移过程之前,请在您的项目中设置 Better Auth。按照 安装指南 进行操作。然后前往

连接到您的数据库

您需要连接到数据库以迁移用户和账户。可以使用任意数据库,但本示例使用 PostgreSQL。

npm install pg

然后可以使用以下代码连接到数据库:

auth.ts
import { Pool } from "pg";

export const auth = betterAuth({
    database: new Pool({ 
        connectionString: process.env.DATABASE_URL 
    }),
})

启用邮箱/密码(可选)

在您的认证配置中启用邮箱和密码,并自行实现发送验证邮件、重置密码邮件等逻辑。

对 Clerk 迁移很重要:Clerk 使用 bcrypt 对密码进行哈希,而 Better Auth 默认使用 scrypt。为确保迁移后的用户可以使用其现有密码登录,您需要配置 Better Auth 使用 bcrypt 进行密码验证。

首先安装 bcrypt:

npm install pnpm add bcrypt
pnpm add -D @types/bcrypt

然后配置 Better Auth 使用 bcrypt:

auth.ts
import { betterAuth } from "better-auth";
import bcrypt from "bcrypt";

export const auth = betterAuth({
    emailAndPassword: { 
        enabled: true,
        password: { 
            hash: async (password) => { 
                return await bcrypt.hash(password, 10); 
            }, 
            verify: async ({ hash, password }) => { 
                return await bcrypt.compare(password, hash); 
            }, 
        }, 
    }, 
})

有关更多配置选项,请参见 邮箱/密码。

设置社交登录提供程序(可选)

在您的认证配置中添加在 Clerk 项目中启用的社交登录提供程序。

auth.ts
import { betterAuth } from "better-auth";

export const auth = betterAuth({
    database: new Pool({ 
        connectionString: process.env.DATABASE_URL 
    }),
    emailAndPassword: { 
        enabled: true,
    },
    socialProviders: { 
        github: { 
            clientId: process.env.GITHUB_CLIENT_ID, 
            clientSecret: process.env.GITHUB_CLIENT_SECRET, 
        } 
    } 
})

添加插件(可选)

您可以根据需要为以下认证配置添加插件。

管理员 插件可让您管理用户、用户模拟以及应用级别的角色和权限。

两步验证 插件可为您的应用添加两步验证功能。

手机号 插件可为您的应用添加手机号认证功能。

用户名 插件可为您的应用添加用户名认证功能。

auth.ts
import { Pool } from "pg";
import { betterAuth } from "better-auth";
import { admin, twoFactor, phoneNumber, username } from "better-auth/plugins";
import bcrypt from "bcrypt";

export const auth = betterAuth({
    database: new Pool({ 
        connectionString: process.env.DATABASE_URL 
    }),
    emailAndPassword: { 
        enabled: true,
        password: {
            hash: async (password) => {
                return await bcrypt.hash(password, 10);
            },
            verify: async ({ hash, password }) => {
                return await bcrypt.compare(password, hash);
            },
        },
    },
    socialProviders: {
        github: {
            clientId: process.env.GITHUB_CLIENT_ID!,
            clientSecret: process.env.GITHUB_CLIENT_SECRET!,
        }
    },
    plugins: [admin(), twoFactor(), phoneNumber(), username()], 
})

生成数据库架构

如果您使用的是自定义数据库适配器,请生成架构:

npx auth generate

或者,如果您使用的是默认适配器,可以使用以下命令:

npx auth migrate

导出 Clerk 用户

前往 Clerk 控制台并 导出用户。这将下载一个包含用户数据的 CSV 文件。您需要将其保存为 exported_users.csv 并放到项目根目录中。

创建迁移脚本

在 scripts 文件夹中创建一个名为 migrate-clerk.ts 的新文件,并添加以下代码:

scripts/migrate-clerk.ts
import { generateRandomString, symmetricEncrypt } from "better-auth/crypto";

import { auth } from "@/lib/auth"; // 导入您的认证实例

function getCSVData(csv: string) {
  const lines = csv.split('\n').filter(line => line.trim());
  const headers = lines[0]?.split(',').map(header => header.trim()) || [];
  const jsonData = lines.slice(1).map(line => {
      const values = line.split(',').map(value => value.trim());
      return headers.reduce((obj, header, index) => {
          obj[header] = values[index] || '';
          return obj;
      }, {} as Record<string, string>);
  });

  return jsonData as Array<{
      id: string;
      first_name: string;
      last_name: string;
      username: string;
      primary_email_address: string;
      primary_phone_number: string;
      verified_email_addresses: string;
      unverified_email_addresses: string;
      verified_phone_numbers: string;
      unverified_phone_numbers: string;
      totp_secret: string;
      password_digest: string;
      password_hasher: string;
  }>;
}

const exportedUserCSV = await Bun.file("exported_users.csv").text(); // 这是您从 Clerk 下载的文件

async function getClerkUsers(totalUsers: number) {
  const clerkUsers: {
      id: string;
      first_name: string;
      last_name: string;
      username: string;
      image_url: string;
      password_enabled: boolean;
      two_factor_enabled: boolean;
      totp_enabled: boolean;
      backup_code_enabled: boolean;
      banned: boolean;
      locked: boolean;
      lockout_expires_in_seconds: number;
      created_at: number;
      updated_at: number;
      external_accounts: {
          id: string;
          provider: string;
          identification_id: string;
          provider_user_id: string;
          approved_scopes: string;
          email_address: string;
          first_name: string;
          last_name: string;
          image_url: string;
          created_at: number;
          updated_at: number;
      }[]
  }[] = [];
  for (let i = 0; i < totalUsers; i += 500) {
      const response = await fetch(`https://api.clerk.com/v1/users?offset=${i}&limit=${500}`, {
          headers: {
              'Authorization': `Bearer ${process.env.CLERK_SECRET_KEY}`
          }
      });
      if (!response.ok) {
          throw new Error(`Failed to fetch users: ${response.statusText}`);
      }
      const clerkUsersData = await response.json();
      // biome-ignore lint/suspicious/noExplicitAny: <explanation>
      clerkUsers.push(...clerkUsersData as any);
  }
  return clerkUsers;
}


export async function generateBackupCodes(
  secret: string,
) {
  const key = secret;
  const backupCodes = Array.from({ length: 10 })
      .fill(null)
      .map(() => generateRandomString(10, "a-z", "0-9", "A-Z"))
      .map((code) => `${code.slice(0, 5)}-${code.slice(5)}`);
  const encCodes = await symmetricEncrypt({
      data: JSON.stringify(backupCodes),
      key: key,
  });
  return encCodes
}

// 辅助函数:将时间戳安全转换为 Date
function safeDateConversion(timestamp?: number): Date {
  if (!timestamp) return new Date();

  const date = new Date(timestamp);

  // 检查日期是否有效
  if (isNaN(date.getTime())) {
      console.warn(`Invalid timestamp: ${timestamp}, falling back to current date`);
      return new Date();
  }

  // 检查不合理的日期(早于 2000 年或晚于 2100 年)
  const year = date.getFullYear();
  if (year < 2000 || year > 2100) {
      console.warn(`Suspicious date year: ${year}, falling back to current date`);
      return new Date();
  }

  return date;
}

async function migrateFromClerk() {
  const jsonData = getCSVData(exportedUserCSV);
  const clerkUsers = await getClerkUsers(jsonData.length);
  const ctx = await auth.$context
  const isAdminEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "admin");
  const isTwoFactorEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "two-factor");
  const isUsernameEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "username");
  const isPhoneNumberEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "phone-number");
  for (const user of jsonData) {
      const { id, first_name, last_name, username, primary_email_address, primary_phone_number, verified_email_addresses, unverified_email_addresses, verified_phone_numbers, unverified_phone_numbers, totp_secret, password_digest, password_hasher } = user;
      const clerkUser = clerkUsers.find(clerkUser => clerkUser?.id === id);

      // 创建用户
      const createdUser = await ctx.adapter.create<{
          id: string;
      }>({
          model: "user",
          data: {
              id,
              email: primary_email_address,
              emailVerified: verified_email_addresses.length > 0,
              name: `${first_name} ${last_name}`,
              image: clerkUser?.image_url,
              createdAt: safeDateConversion(clerkUser?.created_at),
              updatedAt: safeDateConversion(clerkUser?.updated_at),
              // # 二步验证(如果已启用二步验证插件)
              ...(isTwoFactorEnabled ? {
                  twoFactorEnabled: clerkUser?.two_factor_enabled
              } : {}),
              // # 管理员(如果已启用管理员插件)
              ...(isAdminEnabled ? {
                  banned: clerkUser?.banned,
                  banExpires: clerkUser?.lockout_expires_in_seconds
                     ? new Date(Date.now() + clerkUser.lockout_expires_in_seconds * 1000)
                     : undefined,
                  role: "user"
              } : {}),
              // # 用户名(如果已启用用户名插件)
              ...(isUsernameEnabled ? {
                  username: username,
              } : {}),
              // # 手机号(如果已启用了手机号插件)  
              ...(isPhoneNumberEnabled ? {
                  phoneNumber: primary_phone_number,
                  phoneNumberVerified: verified_phone_numbers.length > 0,
              } : {}),
          },
          forceAllowId: true
      }).catch(async e => {
          return await ctx.adapter.findOne<{
              id: string;
          }>({
              model: "user",
              where: [{
                  field: "id",
                  value: id
              }]
          })
      })
      if (!createdUser) {
          throw new Error(`Unable to create or find user ${id}`);
      }
      // create external account
      const externalAccounts = clerkUser?.external_accounts;
      if (externalAccounts) {
          for (const externalAccount of externalAccounts) {
              const { id, provider, identification_id, provider_user_id, approved_scopes, email_address, first_name, last_name, image_url, created_at, updated_at } = externalAccount;
              if (externalAccount.provider === "credential") {
                  await ctx.adapter.create({
                      model: "account",
                      data: {
                          id,
                          providerId: provider,
                          accountId: createdUser.id,
                          scope: approved_scopes,
                          userId: createdUser.id,
                          createdAt: safeDateConversion(created_at),
                          updatedAt: safeDateConversion(updated_at),
                      }
                  })
              } else {
                  const providerId = provider.replace("oauth_", "");
                  await ctx.adapter.create({
                      model: "account",
                      data: {
                          id,
                          providerId,
                          accountId: externalAccount.provider_user_id,
                          scope: approved_scopes,
                          userId: createdUser.id,
                          createdAt: safeDateConversion(created_at),
                          updatedAt: safeDateConversion(updated_at),
                      },
                      forceAllowId: true
                  })
              }
          }
      }

      // 两步验证
      if (isTwoFactorEnabled) {
          await ctx.adapter.create({
              model: "twoFactor",
              data: {
                  userId: createdUser.id,
                  secret: totp_secret,
                  backupCodes: await generateBackupCodes(totp_secret)
              }
          })
      }
  }
}

migrateFromClerk()
  .then(() => {
      console.log('迁移完成');
      process.exit(0);
  })
  .catch((error) => {
      console.error('迁移失败:', error);
      process.exit(1);
  });

请务必将 process.env.CLERK_SECRET_KEY 替换为您自己的 Clerk 密钥。您可以根据需要自定义此脚本。

运行迁移

运行迁移命令:

bun run script/migrate-clerk.ts # 您也可以使用任意方式运行该脚本

请确保:

  1. 首先在开发环境中测试迁移
  2. 监控迁移过程中的任何错误
  3. 在继续操作之前验证 Better Auth 中的迁移数据
  4. 在迁移完成前保持 Clerk 已安装并配置

验证迁移

迁移完成后,请检查数据库以验证所有用户是否已正确迁移。

更新您的组件

现在数据已迁移,您可以开始更新组件以使用 Better Auth。以下是一个登录组件的示例:

components/auth/sign-in.tsx
import { authClient } from "@/lib/auth-client";

export const SignIn = () => {
  const handleSignIn = async () => {
    const { data, error } = await authClient.signIn.email({
      email: "[email protected]",
      password: "password",
    });
    
    if (error) {
      console.error(error);
      return;
    }
    // 处理成功登录
  };

  return (
    <form onSubmit={handleSignIn}>
      <button type="submit">Sign in</button>
    </form>
  );
};

更新中间件

将您的 Clerk 中间件替换为 Better Auth 的中间件:

middleware.ts

import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
export async function middleware(request: NextRequest) {
  const sessionCookie = getSessionCookie(request);
  const { pathname } = request.nextUrl;
  if (sessionCookie && ["/login", "/signup"].includes(pathname)) {
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }
  if (!sessionCookie && pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard", "/login", "/signup"],
};

移除 Clerk 依赖

确认一切在 Better Auth 中正常工作后,您可以移除 Clerk 依赖:

移除 Clerk
pnpm remove @clerk/nextjs @clerk/themes @clerk/types

额外资源

告别 Clerk,迎接 Better Auth – 全面迁移指南!

总结

恭喜!您已成功从 Clerk 迁移到 Better Auth。

Better Auth 提供了更大的灵活性和更多功能——务必浏览文档以充分发挥其潜力。