iye 9d003a3b6f
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
fix(auth): persist generated OTP so real codes can verify (with in-memory fallback)
之前 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>
2026-05-13 15:21:00 +08:00

80 lines
2.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { NextRequest } from "next/server";
import { z } from "zod";
import { rateLimit } from "@/lib/rate-limit";
import { getClientIp } from "@/lib/current-user";
import { storeOtp } from "@/lib/otp-store";
import { sendOtpSms } from "@/lib/sms";
import { ok, ERR } from "@/lib/api-response";
const Body = z.object({
phone: z
.string()
.regex(/^1[3-9]\d{9}$/, "请输入有效的中国大陆手机号"),
});
/**
* POST /api/auth/send-otp
* 发送短信验证码 · 单手机号 60s 限频 / 单 IP 5 分钟 5 次
*/
export async function POST(req: NextRequest) {
try {
const parsed = Body.safeParse(await req.json());
if (!parsed.success) {
return ERR.VALIDATION(parsed.error.issues[0]?.message ?? "参数错误");
}
const { phone } = parsed.data;
// 限流
const phoneRl = await rateLimit(`otp:phone:${phone}`, 60, 1);
if (!phoneRl.allowed) {
return ERR.RATE_LIMITED();
}
const ip = await getClientIp();
if (ip) {
const ipRl = await rateLimit(`otp:ip:${ip}`, 300, 5);
if (!ipRl.allowed) return ERR.RATE_LIMITED();
}
// 生成 6 位验证码 + 存储 (Redis 可用走 Redis, 否则走进程内 Map; 始终 5min TTL)
const code = String(Math.floor(100000 + Math.random() * 900000));
await storeOtp(phone, code);
// 开发环境下把验证码打在终端, 方便本地 QA / 排错; 生产不打
if (process.env.NODE_ENV !== "production") {
console.log(`[dev-otp] phone=${phone} code=${code} (dev 环境也接受 123456)`);
}
// 调阿里云短信发送
const sms = await sendOtpSms(phone, code);
if (sms.ok) {
console.log(`[sms] sent phone=${phone} bizId=${sms.bizId}`);
return ok({ message: "验证码已发送", expiresIn: 300 });
}
// 失败处理:
// - SMS 未配置且非生产 → 控制台打 code, 仍返回成功(开发态联调)
// - 阿里云明确返回参数 / 触发流控 → 422
// - 其它 → 500
if (sms.errorCode === "SMS_NOT_CONFIGURED") {
if (process.env.NODE_ENV !== "production") {
console.log(`[dev-otp] SMS 未配置, 验证码 ${phone}: ${code}(也可用万能码 123456`);
return ok({ message: "验证码已发送", expiresIn: 300 });
}
console.error("[sms] 生产环境短信未配置, 检查 SMS_* 环境变量");
return ERR.INTERNAL("短信服务未配置");
}
console.error(
`[sms] 发送失败 phone=${phone} code=${sms.errorCode} message=${sms.errorMessage}`,
);
if (sms.errorCode?.startsWith("isv.")) {
return ERR.VALIDATION(sms.errorMessage ?? "短信发送失败");
}
return ERR.INTERNAL("短信发送失败");
} catch (e) {
console.error("[POST /api/auth/send-otp]", e);
return ERR.INTERNAL();
}
}