UI-UX/src/lib/auth.ts
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

117 lines
3.6 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 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);
}