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>
80 lines
2.7 KiB
TypeScript
80 lines
2.7 KiB
TypeScript
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();
|
||
}
|
||
}
|