diff --git a/src/app/api/auth/send-otp/route.ts b/src/app/api/auth/send-otp/route.ts index 8a8cff3..6041e61 100644 --- a/src/app/api/auth/send-otp/route.ts +++ b/src/app/api/auth/send-otp/route.ts @@ -2,7 +2,7 @@ import type { NextRequest } from "next/server"; import { z } from "zod"; import { rateLimit } from "@/lib/rate-limit"; import { getClientIp } from "@/lib/current-user"; -import { getRedis } from "@/lib/redis"; +import { storeOtp } from "@/lib/otp-store"; import { sendOtpSms } from "@/lib/sms"; import { ok, ERR } from "@/lib/api-response"; @@ -35,13 +35,13 @@ export async function POST(req: NextRequest) { if (!ipRl.allowed) return ERR.RATE_LIMITED(); } - // 生成 6 位验证码 + // 生成 6 位验证码 + 存储 (Redis 可用走 Redis, 否则走进程内 Map; 始终 5min TTL) const code = String(Math.floor(100000 + Math.random() * 900000)); + await storeOtp(phone, code); - // 缓存到 Redis(5 分钟过期)。Redis 未配置时 dev 仍能通过万能码 123456 走完整流程 - const redis = getRedis(); - if (redis) { - await redis.set(`sms:otp:${phone}`, code, "EX", 300); + // 开发环境下把验证码打在终端, 方便本地 QA / 排错; 生产不打 + if (process.env.NODE_ENV !== "production") { + console.log(`[dev-otp] phone=${phone} code=${code} (dev 环境也接受 123456)`); } // 调阿里云短信发送 diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 9d249c1..e1589c8 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,7 +1,7 @@ import NextAuth, { type NextAuthConfig } from "next-auth"; import Credentials from "next-auth/providers/credentials"; import { prisma } from "./prisma"; -import { getRedis } from "./redis"; +import { consumeOtp } from "./otp-store"; import { z } from "zod"; /** @@ -103,27 +103,14 @@ export const { handlers, signIn, signOut, auth } = NextAuth(authConfig); /** * 校验 OTP 验证码。 - * - 开发态万能码 "123456" 始终通过(仅 NODE_ENV !== "production") - * - 否则从 Redis 读取并比对,校验通过后立即 del 避免重放 - * - Redis 未配置时无法核对,无论环境一律拒绝(不允许"任意 6 位"放行漏洞) + * - 开发态万能码 "123456" 始终通过(仅 NODE_ENV !== "production",不走 SMS) + * - 真实码通过 consumeOtp 核对(Redis 或进程内 Map,由 otp-store 决定); + * 校验通过后立即销毁该码防止重放 */ async function verifyOtp(phone: string, code: string): Promise { if (process.env.NODE_ENV !== "production" && code === "123456") { console.log(`[dev-otp] 手机号 ${phone} 使用万能码 123456 通过`); return true; } - - const redis = getRedis(); - if (!redis) { - console.error( - `[auth] Redis 未配置,无法核对 OTP,拒绝 phone=${phone}(dev 联调请用 123456)`, - ); - return false; - } - - const key = `sms:otp:${phone}`; - const stored = await redis.get(key); - if (!stored || stored !== code) return false; - await redis.del(key); - return true; + return await consumeOtp(phone, code); } diff --git a/src/lib/otp-store.ts b/src/lib/otp-store.ts new file mode 100644 index 0000000..8bb51be --- /dev/null +++ b/src/lib/otp-store.ts @@ -0,0 +1,65 @@ +/** + * OTP 验证码存储抽象层。 + * + * 路由策略: + * - Redis 可用 → 走 Redis (生产, 多实例, 跨重启) + * - Redis 缺失 → 降级到进程内 Map (dev 联调; 单实例; 重启丢失但 5min TTL 无所谓) + * + * 不能"Redis 缺失就直接通过校验" —— 那会复活之前的安全漏洞。 + * 必须真实生成 / 真实存储 / 真实核对。 + */ +import { getRedis } from "./redis"; + +const TTL_SECONDS = 300; +const redisKey = (phone: string) => `sms:otp:${phone}`; + +// 通过 globalThis 防止 Next dev HMR 重置模块导致 Map 丢失 +declare global { + // eslint-disable-next-line no-var + var __otpMemStore: Map | undefined; +} +const mem: Map = + globalThis.__otpMemStore ?? (globalThis.__otpMemStore = new Map()); + +/** 清理 mem 里过期的条目, 防止长跑后内存膨胀 */ +function sweep() { + const now = Date.now(); + for (const [phone, entry] of mem) { + if (entry.expiresAt <= now) mem.delete(phone); + } +} + +/** 存验证码 (覆盖该手机号的旧码) */ +export async function storeOtp(phone: string, code: string): Promise { + const redis = getRedis(); + if (redis) { + await redis.set(redisKey(phone), code, "EX", TTL_SECONDS); + return; + } + sweep(); + mem.set(phone, { code, expiresAt: Date.now() + TTL_SECONDS * 1000 }); +} + +/** + * 核对验证码; + * 通过则立即销毁该码 (防止重放), 返回 true; + * 失败 (码不存在 / 已过期 / 不匹配) 返回 false。 + */ +export async function consumeOtp(phone: string, code: string): Promise { + const redis = getRedis(); + if (redis) { + const stored = await redis.get(redisKey(phone)); + if (!stored || stored !== code) return false; + await redis.del(redisKey(phone)); + return true; + } + const entry = mem.get(phone); + if (!entry) return false; + if (Date.now() > entry.expiresAt) { + mem.delete(phone); + return false; + } + if (entry.code !== code) return false; + mem.delete(phone); + return true; +} diff --git a/tools/test-otp-store.mjs b/tools/test-otp-store.mjs new file mode 100644 index 0000000..edf59ff --- /dev/null +++ b/tools/test-otp-store.mjs @@ -0,0 +1,26 @@ +// 真实跑 otp-store 的 store/consume 逻辑 +import { storeOtp, consumeOtp } from "../src/lib/otp-store.ts"; + +const phone = "13900000000"; +const cases = []; + +await storeOtp(phone, "654321"); +cases.push(["wrong code 111111 → false", await consumeOtp(phone, "111111"), false]); +cases.push(["right code 654321 → true", await consumeOtp(phone, "654321"), true]); +cases.push(["replay 654321 → false", await consumeOtp(phone, "654321"), false]); +cases.push(["never-stored phone → false", await consumeOtp("13800000001", "654321"), false]); + +// 过期测试 +await storeOtp(phone, "999000"); +// 直接改 globalThis 里的 expiresAt 模拟过期 +globalThis.__otpMemStore.set(phone, { code: "999000", expiresAt: Date.now() - 1 }); +cases.push(["expired code → false", await consumeOtp(phone, "999000"), false]); + +let pass = 0, fail = 0; +for (const [desc, got, expect] of cases) { + const ok = got === expect; + console.log(`${ok ? "✓" : "✗"} ${desc.padEnd(35)} got=${got}`); + ok ? pass++ : fail++; +} +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail === 0 ? 0 : 1);