diff --git a/docs/work_log.md b/docs/work_log.md index 8dd4e56..837bab0 100644 --- a/docs/work_log.md +++ b/docs/work_log.md @@ -1,5 +1,20 @@ # Work Log +## 2026-02-24 + +### 1. Credentials 登录与密码安全修复 +* **密码哈希规范化**: + * 注册时统一生成标准 bcrypt 前缀(`$2a/$2b/$2y`),确保数据库内哈希格式一致。 + * 登录时对非标准前缀(如 `$10$...`)自动补全为 `$2b$...` 再校验,避免格式不一致导致登录失败。 +* **登录一致性修复**: + * 登录校验不再对密码做 `trim()`,避免前后空格导致的 bcrypt 比对失败。 +* **调试排障(已回收)**: + * 临时加入安全日志定位失败原因(不输出明文),定位后已移除。 + +### 2. 验证 +* `npm run typecheck`:通过 +* `npm run lint`:存在既有告警(未新增) + ## 2026-01-15 ### 1. Schedule Module Optimization diff --git a/package-lock.json b/package-lock.json index 2206e5a..f1926cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "@tiptap/pm": "^3.15.3", "@tiptap/react": "^3.15.3", "@tiptap/starter-kit": "^3.15.3", + "bcryptjs": "^2.4.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.1", @@ -63,6 +64,7 @@ "@faker-js/faker": "^10.1.0", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.16", + "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -5397,6 +5399,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -6461,6 +6470,12 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", diff --git a/package.json b/package.json index 9cb9a2a..8136db0 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@tiptap/starter-kit": "^3.15.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "bcryptjs": "^2.4.3", "drizzle-orm": "^0.45.1", "lucide-react": "^0.562.0", "mysql2": "^3.16.0", @@ -68,6 +69,7 @@ "@faker-js/faker": "^10.1.0", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.16", + "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index 49cc1a9..dacb327 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -1,4 +1,5 @@ import { Metadata } from "next" +import { hash } from "bcryptjs" import { createId } from "@paralleldrive/cuid2" import { eq } from "drizzle-orm" @@ -10,6 +11,12 @@ export const metadata: Metadata = { description: "Create an account", } +const normalizeBcryptHash = (value: string) => { + if (value.startsWith("$2")) return value + if (value.startsWith("$")) return `$2b${value}` + return `$2b$${value}` +} + export default function RegisterPage() { async function registerAction(formData: FormData): Promise { "use server" @@ -37,11 +44,12 @@ export default function RegisterPage() { }) if (existing) return { success: false, message: "该邮箱已注册" } + const hashedPassword = normalizeBcryptHash(await hash(password, 10)) await db.insert(users).values({ id: createId(), name: name.length ? name : null, email, - password, + password: hashedPassword, role: "student", }) diff --git a/src/auth.ts b/src/auth.ts index 8f3d0f6..3bb5270 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,6 +1,19 @@ +import { compare, hash } from "bcryptjs" import NextAuth from "next-auth" import Credentials from "next-auth/providers/credentials" +const normalizeRole = (value: unknown) => { + const role = String(value ?? "").trim().toLowerCase() + if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role + return "student" +} + +const normalizeBcryptHash = (value: string) => { + if (value.startsWith("$2")) return value + if (value.startsWith("$")) return `$2b${value}` + return `$2b$${value}` +} + export const { handlers, auth, signIn, signOut } = NextAuth({ trustHost: true, secret: process.env.NEXTAUTH_SECRET, @@ -29,17 +42,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ if (!user) return null const storedPassword = user.password ?? null - if (storedPassword) { - if (storedPassword !== password) return null - } else if (process.env.NODE_ENV === "production") { - return null - } + if (!storedPassword) return null + const normalizedPassword = normalizeBcryptHash(storedPassword) + if (!normalizedPassword.startsWith("$2")) return null + const ok = await compare(password, normalizedPassword) + if (!ok) return null return { id: user.id, name: user.name ?? undefined, email: user.email, - role: (user.role ?? "student") as string, + role: normalizeRole(user.role), } }, }), @@ -48,7 +61,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ jwt: async ({ token, user }) => { if (user) { token.id = (user as { id: string }).id - token.role = (user as { role?: string }).role ?? "student" + token.role = normalizeRole((user as { role?: string }).role) token.name = (user as { name?: string }).name } @@ -66,7 +79,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }) if (fresh) { - token.role = fresh.role ?? token.role ?? "student" + token.role = normalizeRole(fresh.role ?? token.role) token.name = fresh.name ?? token.name } } @@ -76,7 +89,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ session: async ({ session, token }) => { if (session.user) { session.user.id = String(token.id ?? "") - session.user.role = String(token.role ?? "student") + session.user.role = normalizeRole(token.role) if (typeof token.name === "string") { session.user.name = token.name }