Fix-auth-hashing-update-worklog

This commit is contained in:
SpecialX
2026-02-24 15:50:38 +08:00
parent bb4555f611
commit a2e89ce795
5 changed files with 63 additions and 10 deletions

View File

@@ -1,5 +1,20 @@
# Work Log # 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 ## 2026-01-15
### 1. Schedule Module Optimization ### 1. Schedule Module Optimization

15
package-lock.json generated
View File

@@ -36,6 +36,7 @@
"@tiptap/pm": "^3.15.3", "@tiptap/pm": "^3.15.3",
"@tiptap/react": "^3.15.3", "@tiptap/react": "^3.15.3",
"@tiptap/starter-kit": "^3.15.3", "@tiptap/starter-kit": "^3.15.3",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
@@ -63,6 +64,7 @@
"@faker-js/faker": "^10.1.0", "@faker-js/faker": "^10.1.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -5397,6 +5399,13 @@
"tslib": "^2.4.0" "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": { "node_modules/@types/d3-array": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@@ -6461,6 +6470,12 @@
"baseline-browser-mapping": "dist/cli.js" "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": { "node_modules/bignumber.js": {
"version": "9.3.1", "version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",

View File

@@ -43,6 +43,7 @@
"@tiptap/starter-kit": "^3.15.3", "@tiptap/starter-kit": "^3.15.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"bcryptjs": "^2.4.3",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"mysql2": "^3.16.0", "mysql2": "^3.16.0",
@@ -68,6 +69,7 @@
"@faker-js/faker": "^10.1.0", "@faker-js/faker": "^10.1.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

View File

@@ -1,4 +1,5 @@
import { Metadata } from "next" import { Metadata } from "next"
import { hash } from "bcryptjs"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
@@ -10,6 +11,12 @@ export const metadata: Metadata = {
description: "Create an account", 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() { export default function RegisterPage() {
async function registerAction(formData: FormData): Promise<ActionState> { async function registerAction(formData: FormData): Promise<ActionState> {
"use server" "use server"
@@ -37,11 +44,12 @@ export default function RegisterPage() {
}) })
if (existing) return { success: false, message: "该邮箱已注册" } if (existing) return { success: false, message: "该邮箱已注册" }
const hashedPassword = normalizeBcryptHash(await hash(password, 10))
await db.insert(users).values({ await db.insert(users).values({
id: createId(), id: createId(),
name: name.length ? name : null, name: name.length ? name : null,
email, email,
password, password: hashedPassword,
role: "student", role: "student",
}) })

View File

@@ -1,6 +1,19 @@
import { compare, hash } from "bcryptjs"
import NextAuth from "next-auth" import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials" 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({ export const { handlers, auth, signIn, signOut } = NextAuth({
trustHost: true, trustHost: true,
secret: process.env.NEXTAUTH_SECRET, secret: process.env.NEXTAUTH_SECRET,
@@ -29,17 +42,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (!user) return null if (!user) return null
const storedPassword = user.password ?? null const storedPassword = user.password ?? null
if (storedPassword) { if (!storedPassword) return null
if (storedPassword !== password) return null const normalizedPassword = normalizeBcryptHash(storedPassword)
} else if (process.env.NODE_ENV === "production") { if (!normalizedPassword.startsWith("$2")) return null
return null const ok = await compare(password, normalizedPassword)
} if (!ok) return null
return { return {
id: user.id, id: user.id,
name: user.name ?? undefined, name: user.name ?? undefined,
email: user.email, 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 }) => { jwt: async ({ token, user }) => {
if (user) { if (user) {
token.id = (user as { id: string }).id 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 token.name = (user as { name?: string }).name
} }
@@ -66,7 +79,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
}) })
if (fresh) { if (fresh) {
token.role = fresh.role ?? token.role ?? "student" token.role = normalizeRole(fresh.role ?? token.role)
token.name = fresh.name ?? token.name token.name = fresh.name ?? token.name
} }
} }
@@ -76,7 +89,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
session: async ({ session, token }) => { session: async ({ session, token }) => {
if (session.user) { if (session.user) {
session.user.id = String(token.id ?? "") session.user.id = String(token.id ?? "")
session.user.role = String(token.role ?? "student") session.user.role = normalizeRole(token.role)
if (typeof token.name === "string") { if (typeof token.name === "string") {
session.user.name = token.name session.user.name = token.name
} }