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>
117 lines
3.6 KiB
TypeScript
117 lines
3.6 KiB
TypeScript
import NextAuth, { type NextAuthConfig } from "next-auth";
|
||
import Credentials from "next-auth/providers/credentials";
|
||
import { prisma } from "./prisma";
|
||
import { consumeOtp } from "./otp-store";
|
||
import { z } from "zod";
|
||
|
||
/**
|
||
* Auth.js v5 配置
|
||
*
|
||
* 支持渠道(按优先级):
|
||
* 1. 手机号 + 短信 OTP(国内主推)
|
||
* 2. 微信扫码(待团队配置 appId/secret 后开启 · 启用时需把 PrismaAdapter 加回来)
|
||
* 3. 邮箱(境外用户备用)
|
||
*
|
||
* 当前阶段:使用 JWT session 策略,Credentials 不需要 Adapter。
|
||
* 数据库不可用时自动降级为内存用户(开发态友好)。
|
||
*/
|
||
|
||
const OtpCredentials = z.object({
|
||
phone: z
|
||
.string()
|
||
.regex(/^1[3-9]\d{9}$/, "请输入有效的中国大陆手机号"),
|
||
code: z.string().regex(/^\d{6}$/, "请输入 6 位验证码"),
|
||
});
|
||
|
||
export const authConfig: NextAuthConfig = {
|
||
// NOTE: Credentials + JWT 策略不需要 adapter。
|
||
// 启用微信 OAuth 时再把 PrismaAdapter 加回来。
|
||
session: { strategy: "jwt" },
|
||
pages: {
|
||
signIn: "/login",
|
||
},
|
||
providers: [
|
||
Credentials({
|
||
id: "phone-otp",
|
||
name: "Phone OTP",
|
||
credentials: {
|
||
phone: { label: "手机号", type: "tel" },
|
||
code: { label: "验证码", type: "text" },
|
||
},
|
||
async authorize(raw) {
|
||
const parsed = OtpCredentials.safeParse(raw);
|
||
if (!parsed.success) return null;
|
||
const { phone, code } = parsed.data;
|
||
|
||
const validOtp = await verifyOtp(phone, code);
|
||
if (!validOtp) return null;
|
||
|
||
// 尝试持久化到数据库;失败则降级为内存身份(开发态)
|
||
try {
|
||
const user = await prisma.user.upsert({
|
||
where: { phone },
|
||
create: {
|
||
phone,
|
||
nickname: `粉丝_${phone.slice(-4)}`,
|
||
loginType: "PHONE",
|
||
},
|
||
update: { lastLoginAt: new Date() },
|
||
});
|
||
return {
|
||
id: String(user.id),
|
||
name: user.nickname,
|
||
image: user.avatar ?? undefined,
|
||
};
|
||
} catch (err) {
|
||
if (process.env.NODE_ENV === "production") {
|
||
console.error("[auth] DB upsert failed:", err);
|
||
return null;
|
||
}
|
||
// 开发态:用手机号末 9 位做确定性 ID,便于跨重启保持身份
|
||
const fakeId = String(parseInt(phone.slice(-9), 10));
|
||
console.warn(
|
||
`[auth] DB 不可用,开发态降级身份 id=${fakeId} phone=${phone}`,
|
||
);
|
||
return {
|
||
id: fakeId,
|
||
name: `粉丝_${phone.slice(-4)}`,
|
||
};
|
||
}
|
||
},
|
||
}),
|
||
|
||
// TODO[团队]: 启用微信扫码登录(需配置 WECHAT_APP_ID/SECRET 并加回 PrismaAdapter)
|
||
],
|
||
callbacks: {
|
||
async jwt({ token, user }) {
|
||
if (user) {
|
||
token.uid = user.id;
|
||
}
|
||
return token;
|
||
},
|
||
async session({ session, token }) {
|
||
if (token.uid) {
|
||
(session.user as { id?: unknown }).id = token.uid;
|
||
}
|
||
return session;
|
||
},
|
||
},
|
||
trustHost: true,
|
||
};
|
||
|
||
export const { handlers, signIn, signOut, auth } = NextAuth(authConfig);
|
||
|
||
/**
|
||
* 校验 OTP 验证码。
|
||
* - 开发态万能码 "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;
|
||
}
|
||
return await consumeOtp(phone, code);
|
||
}
|