重构认证:从 URL 密钥到 NextAuth.js

2025-09-18

背景

项目初期,为方便个人使用,我采用了一种基于 URL 参数的简易访问控制机制。近期,随着项目有了对外分享的需求,该方案的安全性不足问题暴露出来。特别是考虑到互联网上普遍存在的自动化漏洞扫描工具,即使是个人项目,其 API 和后端数据也面临泄露风险。因此,我决定废弃此方案,转而采用 NextAuth.js 实现一个标准的、更健壮的用户认证系统。


第一阶段:基于 URL 的访问控制

起初,为了快速实现一个内外有别的访问机制,我采用了一种轻量级的“URL 参数”方案

需求:

  • 访客访问 /log,看到演示数据
  • 我本人访问 /log?key=pswd,看到真实数据

实施方案

  1. 创建认证工具 (lib/auth-utils.ts):
    • 定义 SECRET_KEY
    • 创建客户端函数 checkAuthFromUrl(),检查 URL 是否含正确密钥
    • 创建 MOCK_... 常量,硬编码一系列演示数据
  2. 改造前端页面 (app/log/page.tsx):
    • useEffect 中调用 checkAuthFromUrl(),结果存入 isAuthenticated 状态
    • 根据 isAuthenticated 状态,决定是调用 API 获取真实数据,还是使用 MOCK_... 数据填充
    • 所有 API 请求均带上 key=pswd 参数
    • 所有数据修改函数(如 handleAddToTimer)前增加 isAuthenticated 判断
    • 增加一个视觉提示,未认证时显示“演示模式”
  3. 保护所有 API 路由: 在每个 API 路由(如 /api/timer-tasks/route.ts)的处理函数开头加入 checkAuth 检查
    • GET 请求未通过认证,返回 Mock 数据
    • POST, PUT, DELETE 等修改操作未通过认证,返回 401 Unauthorized

第二阶段:安全升级 - 实现标准用户认证

URL 密码方案虽然简单,但安全性低,密钥暴露在 URL 中,容易泄露。因此,我决定升级到 NextAuth.js

1. 技术实施

  1. 安装依赖:
    npm install next-auth @auth/prisma-adapter bcryptjs
    
  2. 更新数据库 Schema (prisma/schema.prisma): 为支持 NextAuth.js,添加 User, Account, Session, VerificationToken 等模型。同时,在 User 模型中增加 password 字段,用于支持“凭证(Credentials)”登录 执行 npx prisma migrate dev --name add-auth 更新数据库

  3. 创建 NextAuth.js 配置 (lib/auth.ts): 配置 CredentialsProvider,使用邮箱和密码登录。其核心是 authorize 函数,负责从数据库查找用户,并使用 bcrypt.compare 校验密码哈希
    // lib/auth.ts
    export const authOptions: NextAuthOptions = {
      adapter: PrismaAdapter(prisma),
      providers: [
        CredentialsProvider({
          // ...
          async authorize(credentials) {
            const user = await prisma.user.findUnique({ where: { email: credentials.email } });
            // ...
            const isPasswordValid = await bcrypt.compare(
              credentials.password,
              user.password
            );
            // ...
            return user;
          }
        })
      ],
      session: { strategy: "jwt" },
      // ...
    }
    
  4. 创建前端组件与 API: 包括全局 AuthProvider.tsx、登录/注册表单 LoginForm.tsx / RegisterForm.tsx,以及对应的 API 路由 /api/auth/register/route.ts

  5. 保护页面 (app/log/page.tsx): 在页面组件开头,使用 useSession 钩子获取登录状态
    const { data: session, status } = useSession();
    
    if (status === "loading") { /* ... */ }
    if (status === "unauthenticated") { /* ... */ }
    
    // 已登录,渲染页面内容...
    

2. 迁移与调试

  • 问题1:点击登录按钮无反应 点击登录后,页面仅刷新。检查终端日志,发现一条警告:[next-auth][warn][NO_SECRET]。原因是缺少 NEXTAUTH_SECRET 环境变量 解决方案:在 .env.local 文件中添加 NEXTAUTH_SECRETNEXTAUTH_URL

  • 问题2:登录成功后不跳转 登录请求成功,但页面依旧只刷新。排查发现 LoginForm.tsx 调用的是 router.refresh(),该方法仅重载数据,不会跳转 解决方案:将 router.refresh() 修改为 router.push('/log'),解决重定向问题

  • 问题3:登录后历史数据丢失 新注册账户登录后,/log 页面为空。原因是,之前所有数据都硬编码关联到 userId: 'user-1',而新用户 ID 是 Prisma 生成的 CUID 解决方案:通过一个迁移脚本,将 user-1 记录的凭证更新为新创建的主账户 master@example.com,从而继承所有历史数据

    // scripts/migrate-master-to-user1-v2.js
    const updatedUser = await prisma.user.update({
      where: { id: 'user-1' },
      data: {
        email: 'master@example.com',
        // ...
      }
    });
    

第三阶段:安全加固与部署

在新的认证系统基本可用后,部署上线前,进行一次全面的安全审查

1. 安全评估与强化

  • 强化主账户密码: 编写脚本 (scripts/update-master-password.js),直接将数据库中 master@example.com 账户的密码更新为一个强密码的 bcrypt 哈希值

  • 禁用注册功能: 在 LoginForm.tsx 中直接注释掉注册页面的链接。这是一种 UI 层面的禁用,虽然 API 依然存在,但能阻止大部分尝试

  • 添加 API 速率限制: 为避免引入 Redis 等外部依赖,在 middleware.ts 中实现了一个基于内存的 SimpleRateLimit 类。它使用 Map 存储 IP 和请求时间戳,通过滑动窗口算法限制同一 IP 在 1 分钟内最多请求 10 次,并对 /api/auth/ 路径进行拦截

    // middleware.ts
    const rateLimit = new SimpleRateLimit();
    export async function middleware(request: NextRequest) {
      const ip = request.ip ?? '127.0.0.1';
      const { allowed } = rateLimit.isAllowed(ip);
      if (!allowed) {
        return new NextResponse('Too Many Requests', { status: 429 });
      }
      // ...
    }
    

2. 编译与 Lint 修复

处理 npm run build 抛出的所有错误,包括 TypeScript 类型错误和 ESLint 警告,确保项目可以稳定构建。最终,next build 成功通过,项目准备好部署

总结

  • 安全始于意识:从简单的 URL 密码到完整的认证系统,这次升级的根本驱动力是对数据安全风险的重新评估。即使是个人项目,暴露在公网也应采取必要的安全措施
  • 开发日志的重要性:没有这些记录,很多问题的排查思路和解决方案可能就遗忘了。这篇博客本身就是这个过程的产物