fix(auth): persist generated OTP so real codes can verify (with in-memory fallback)
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
之前 send-otp 生成的码只在 Redis 可用时才存. dev 没配 Redis → 码生成即丢, 用户拿到 真实 SMS 验证码也登不进去 (除了万能码 123456). 这是上次"修任意 6 位绕过"留下的回归. 新增 src/lib/otp-store.ts: - storeOtp / consumeOtp 双方法, 内部按 Redis 可用性自动路由 - Redis 可用 → 走 Redis (生产) - Redis 缺失 → 走进程内 Map (dev / 联调), 通过 globalThis 抗 HMR - consumeOtp 校验通过即 del, 防重放 send-otp 与 verifyOtp 改走 otp-store, 不再直接读写 Redis 句柄。 E2E (curl + NextAuth callback): 发码 → dev 日志拿 code=209988 错码 000000 → 拒绝, session=null 真码 209988 → 通过, session=粉丝_0099 重放 209988 → 拒绝 (一次性消费) 并在 NODE_ENV !== production 时把生成的 code 打到 dev 终端, 方便 QA。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8597957af4
commit
9d003a3b6f
@ -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)`);
|
||||
}
|
||||
|
||||
// 调阿里云短信发送
|
||||
|
||||
@ -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<boolean> {
|
||||
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);
|
||||
}
|
||||
|
||||
65
src/lib/otp-store.ts
Normal file
65
src/lib/otp-store.ts
Normal file
@ -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<string, { code: string; expiresAt: number }> | undefined;
|
||||
}
|
||||
const mem: Map<string, { code: string; expiresAt: number }> =
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
26
tools/test-otp-store.mjs
Normal file
26
tools/test-otp-store.mjs
Normal file
@ -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);
|
||||
Loading…
x
Reference in New Issue
Block a user