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:
SpecialX
2026-06-17 13:44:37 +08:00
parent 125f7ec54c
commit 3b6272c99d
195 changed files with 27274 additions and 416 deletions

View File

@@ -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",
})
}
},
},
})