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

之前 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:
iye 2026-05-13 15:21:00 +08:00
parent 8597957af4
commit 9d003a3b6f
4 changed files with 102 additions and 24 deletions

View File

@ -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);
// 缓存到 Redis5 分钟过期。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)`);
}
// 调阿里云短信发送

View File

@ -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
View 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
View 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);