背景
项目初期,为方便个人使用,我采用了一种基于 URL 参数的简易访问控制机制。近期,随着项目有了对外分享的需求,该方案的安全性不足问题暴露出来。特别是考虑到互联网上普遍存在的自动化漏洞扫描工具,即使是个人项目,其 API 和后端数据也面临泄露风险。因此,我决定废弃此方案,转而采用 NextAuth.js 实现一个标准的、更健壮的用户认证系统。
第一阶段:基于 URL 的访问控制
起初,为了快速实现一个内外有别的访问机制,我采用了一种轻量级的“URL 参数”方案
需求:
- 访客访问
/log
,看到演示数据 - 我本人访问
/log?key=pswd
,看到真实数据
实施方案
- 创建认证工具 (
lib/auth-utils.ts
):- 定义
SECRET_KEY
- 创建客户端函数
checkAuthFromUrl()
,检查 URL 是否含正确密钥 - 创建
MOCK_...
常量,硬编码一系列演示数据
- 定义
- 改造前端页面 (
app/log/page.tsx
):- 在
useEffect
中调用checkAuthFromUrl()
,结果存入isAuthenticated
状态 - 根据
isAuthenticated
状态,决定是调用 API 获取真实数据,还是使用MOCK_...
数据填充 - 所有 API 请求均带上
key=pswd
参数 - 所有数据修改函数(如
handleAddToTimer
)前增加isAuthenticated
判断 - 增加一个视觉提示,未认证时显示“演示模式”
- 在
- 保护所有 API 路由:
在每个 API 路由(如
/api/timer-tasks/route.ts
)的处理函数开头加入checkAuth
检查GET
请求未通过认证,返回 Mock 数据POST
,PUT
,DELETE
等修改操作未通过认证,返回401 Unauthorized
第二阶段:安全升级 - 实现标准用户认证
URL 密码方案虽然简单,但安全性低,密钥暴露在 URL 中,容易泄露。因此,我决定升级到 NextAuth.js
1. 技术实施
- 安装依赖:
npm install next-auth @auth/prisma-adapter bcryptjs
-
更新数据库 Schema (
prisma/schema.prisma
): 为支持 NextAuth.js,添加User
,Account
,Session
,VerificationToken
等模型。同时,在User
模型中增加password
字段,用于支持“凭证(Credentials)”登录 执行npx prisma migrate dev --name add-auth
更新数据库 - 创建 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" }, // ... }
-
创建前端组件与 API: 包括全局
AuthProvider.tsx
、登录/注册表单LoginForm.tsx
/RegisterForm.tsx
,以及对应的 API 路由/api/auth/register/route.ts
- 保护页面 (
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_SECRET
和NEXTAUTH_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 密码到完整的认证系统,这次升级的根本驱动力是对数据安全风险的重新评估。即使是个人项目,暴露在公网也应采取必要的安全措施
- 开发日志的重要性:没有这些记录,很多问题的排查思路和解决方案可能就遗忘了。这篇博客本身就是这个过程的产物