feat: 完成 P1 全部功能 + 修复 proxy 导出 + 切换 MySQL 端口至 14013
## P1 功能(20 项) - 站内消息系统、家长仪表盘、学生考勤管理 - Excel 导入导出、用户批量导入、成绩导出 - 排课规则+自动排课+课表调整 - 成绩趋势+对比分析、密码安全策略、速率限制 - 数据变更日志、文件预览+存储策略、全文检索 - 依赖审计集成 CI、数据库定时备份、E2E 测试完善 - 通知偏好管理 ## 基础设施修复 - src/proxy.ts: 将 middleware 导出重命名为 proxy(Next.js 16 要求) - .env: MySQL 端口从 13002 切换至 14013 - scripts/create-db.ts: 新增数据库初始化脚本 ## 架构文档同步 - 004_architecture_impact_map.md 和 005_architecture_data.json 完整记录所有新增表、模块、路由、权限、依赖关系
This commit is contained in:
186
src/auth.ts
186
src/auth.ts
@@ -1,7 +1,14 @@
|
||||
import { compare } from "bcryptjs"
|
||||
import NextAuth from "next-auth"
|
||||
import Credentials from "next-auth/providers/credentials"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { resolvePermissions } from "@/shared/lib/permissions"
|
||||
import { logLoginEvent } from "@/shared/lib/login-logger"
|
||||
import {
|
||||
PASSWORD_RULES,
|
||||
isAccountLocked,
|
||||
} from "@/shared/lib/password-policy"
|
||||
import { RATE_LIMIT_RULES, rateLimit, rateLimitKey, resetRateLimit } from "@/shared/lib/rate-limit"
|
||||
|
||||
const normalizeRole = (value: unknown) => {
|
||||
const role = String(value ?? "").trim().toLowerCase()
|
||||
@@ -25,6 +32,103 @@ const normalizeBcryptHash = (value: string) => {
|
||||
return `$2b$${value}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the client IP from request headers (best-effort, used for
|
||||
* rate-limit keying only — not stored).
|
||||
*/
|
||||
const resolveClientIp = async (): Promise<string> => {
|
||||
try {
|
||||
const { headers } = await import("next/headers")
|
||||
const headerList = await headers()
|
||||
return (
|
||||
headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
headerList.get("x-real-ip") ??
|
||||
"unknown"
|
||||
)
|
||||
} catch {
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a password_security row for a user.
|
||||
*/
|
||||
const getOrCreatePasswordSecurity = async (
|
||||
db: typeof import("@/shared/db")["db"],
|
||||
passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"],
|
||||
userId: string
|
||||
) => {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(passwordSecurity)
|
||||
.where(eq(passwordSecurity.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (existing) return existing
|
||||
|
||||
const { createId } = await import("@paralleldrive/cuid2")
|
||||
const id = createId()
|
||||
await db.insert(passwordSecurity).values({
|
||||
id,
|
||||
userId,
|
||||
failedLoginAttempts: 0,
|
||||
})
|
||||
const [created] = await db
|
||||
.select()
|
||||
.from(passwordSecurity)
|
||||
.where(eq(passwordSecurity.userId, userId))
|
||||
.limit(1)
|
||||
return created
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment failed login attempts and lock the account if the threshold
|
||||
* is reached.
|
||||
*/
|
||||
const recordFailedLogin = async (
|
||||
db: typeof import("@/shared/db")["db"],
|
||||
passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"],
|
||||
userId: string
|
||||
): Promise<{ locked: boolean; lockedUntil: Date | null }> => {
|
||||
const current = await getOrCreatePasswordSecurity(db, passwordSecurity, userId)
|
||||
const nextAttempts = current.failedLoginAttempts + 1
|
||||
const shouldLock = nextAttempts >= PASSWORD_RULES.maxLoginAttempts
|
||||
const lockedUntil = shouldLock
|
||||
? new Date(Date.now() + PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000)
|
||||
: null
|
||||
|
||||
await db
|
||||
.update(passwordSecurity)
|
||||
.set({
|
||||
failedLoginAttempts: nextAttempts,
|
||||
lockedUntil,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(passwordSecurity.userId, userId))
|
||||
|
||||
return { locked: shouldLock, lockedUntil }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset failed login attempts on successful login.
|
||||
*/
|
||||
const resetFailedLogin = async (
|
||||
db: typeof import("@/shared/db")["db"],
|
||||
passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"],
|
||||
userId: string
|
||||
): Promise<void> => {
|
||||
const current = await getOrCreatePasswordSecurity(db, passwordSecurity, userId)
|
||||
if (current.failedLoginAttempts === 0 && !current.lockedUntil) return
|
||||
await db
|
||||
.update(passwordSecurity)
|
||||
.set({
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(passwordSecurity.userId, userId))
|
||||
}
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
trustHost: true,
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
@@ -41,8 +145,24 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
const password = String(credentials?.password ?? "")
|
||||
if (!email || !password) return null
|
||||
|
||||
const [{ eq }, { db }, { roles, users, usersToRoles }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
// Rate limit by IP + email to slow brute-force attempts
|
||||
const clientIp = await resolveClientIp()
|
||||
const loginLimitKey = rateLimitKey("login", `${clientIp}:${email}`)
|
||||
const limit = rateLimit({
|
||||
key: loginLimitKey,
|
||||
...RATE_LIMIT_RULES.LOGIN,
|
||||
})
|
||||
if (!limit.success) {
|
||||
await logLoginEvent({
|
||||
userEmail: email,
|
||||
action: "signin",
|
||||
status: "failure",
|
||||
errorMessage: "Rate limit exceeded",
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const [{ db }, { users, roles, usersToRoles, passwordSecurity }] = await Promise.all([
|
||||
import("@/shared/db"),
|
||||
import("@/shared/db/schema"),
|
||||
])
|
||||
@@ -52,12 +172,43 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
})
|
||||
if (!user) return null
|
||||
|
||||
// Account lockout check
|
||||
const security = await getOrCreatePasswordSecurity(db, passwordSecurity, user.id)
|
||||
const lastFailedAt = security.lockedUntil
|
||||
? new Date(security.lockedUntil.getTime() - PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000)
|
||||
: null
|
||||
if (isAccountLocked(security.failedLoginAttempts, lastFailedAt)) {
|
||||
await logLoginEvent({
|
||||
userId: user.id,
|
||||
userEmail: email,
|
||||
action: "signin",
|
||||
status: "failure",
|
||||
errorMessage: "Account locked",
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const storedPassword = user.password ?? 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
|
||||
|
||||
if (!ok) {
|
||||
await recordFailedLogin(db, passwordSecurity, user.id)
|
||||
await logLoginEvent({
|
||||
userId: user.id,
|
||||
userEmail: email,
|
||||
action: "signin",
|
||||
status: "failure",
|
||||
errorMessage: "Invalid credentials",
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
// Successful login: reset counters and rate limit
|
||||
await resetFailedLogin(db, passwordSecurity, user.id)
|
||||
resetRateLimit(loginLimitKey)
|
||||
|
||||
const roleRows = await db
|
||||
.select({ name: roles.name })
|
||||
@@ -136,4 +287,33 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
return session
|
||||
},
|
||||
},
|
||||
events: {
|
||||
async signIn({ user }) {
|
||||
await logLoginEvent({
|
||||
userId: user.id,
|
||||
userEmail: user.email ?? "",
|
||||
action: "signin",
|
||||
status: "success",
|
||||
})
|
||||
},
|
||||
async signOut(message) {
|
||||
// NextAuth v5 signOut event receives the session/token info
|
||||
const userId =
|
||||
(message as { userId?: string })?.userId ??
|
||||
(message as { token?: { id?: string } })?.token?.id ??
|
||||
""
|
||||
const userEmail =
|
||||
(message as { token?: { email?: string } })?.token?.email ??
|
||||
(message as { session?: { user?: { email?: string } } })?.session?.user?.email ??
|
||||
""
|
||||
if (userEmail) {
|
||||
await logLoginEvent({
|
||||
userId: userId || undefined,
|
||||
userEmail,
|
||||
action: "signout",
|
||||
status: "success",
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user