chore(config): update auth, env, i18n, proxy, and dependencies

- Update src/auth.ts auth configuration

- Update src/env.mjs environment variable validation

- Update src/i18n/request.ts locale handling

- Update src/proxy.ts middleware

- Update src/next-auth.d.ts type declarations

- Update package.json and package-lock.json dependencies
This commit is contained in:
SpecialX
2026-07-03 10:30:28 +08:00
parent 0e63c24ed9
commit 56c3f32e2d
7 changed files with 755 additions and 199 deletions

View File

@@ -1,23 +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 { isRole } from "@/shared/types/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"
import { normalizeBcryptHash } from "@/shared/lib/bcrypt-utils"
import { resolveClientIp } from "@/shared/lib/http-utils"
import {
getOrCreatePasswordSecurity,
recordFailedLogin,
resetFailedLogin,
} from "@/shared/lib/password-security-service"
import { normalizeRole, resolvePrimaryRole } from "@/shared/lib/role-utils"
import {
encodePermissionsBitmap,
decodePermissionsBitmap,
} from "@/shared/lib/permission-bitmap"
import { trackAuthEvent } from "@/shared/lib/track-event"
export const { handlers, auth, signIn, signOut } = NextAuth({
trustHost: true,
@@ -31,126 +22,32 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
password: { label: "Password", type: "password" },
totpCode: { label: "2FA Code", type: "text" },
},
// audit-P1-3 重构authorize 回调原混合 7 类职责107 行),
// 已抽取至 modules/auth/services/login-service.ts 的 authenticateUser 函数。
// 此处为薄包装,仅负责动态 import避免 server-only 模块在模块评估期加载)
// 与委托调用。所有登录逻辑(速率限制/账户锁定/密码校验/2FA/角色加载)均在
// login-service 中实现,可独立测试。
authorize: async (credentials) => {
const email = String(credentials?.email ?? "").trim().toLowerCase()
const password = String(credentials?.password ?? "")
const totpCode = String(credentials?.totpCode ?? "").trim()
if (!email || !password) return null
// 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"),
])
const user = await db.query.users.findFirst({
where: eq(users.email, email),
})
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) {
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)
// 2FA verification (if user has enabled it)
const { verifyTwoFactorForLogin } = await import("@/modules/settings/actions-security")
const twoFactorResult = await verifyTwoFactorForLogin({
userId: user.id,
token: totpCode || undefined,
})
if (twoFactorResult.required && !twoFactorResult.valid) {
await logLoginEvent({
userId: user.id,
userEmail: email,
action: "signin",
status: "failure",
errorMessage: totpCode
? "Invalid 2FA code"
: "2FA required but not provided",
})
return null
}
const roleRows = await db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, user.id))
const roleNames = roleRows.map((r) => r.name)
const resolvedRole = resolvePrimaryRole(roleNames)
return {
id: user.id,
name: user.name ?? undefined,
email: user.email,
role: resolvedRole,
roles: roleNames,
}
const { authenticateUser } = await import("@/modules/auth/services/login-service")
return authenticateUser(credentials)
},
}),
],
callbacks: {
jwt: async ({ token, user }) => {
if (user) {
const u = user as { id: string; role?: string; roles?: string[]; name?: string }
token.id = u.id
token.role = normalizeRole(u.role)
token.name = u.name ?? undefined
// P0-5 修复User 接口已在 next-auth.d.ts 中扩展了 role/roles 字段,
// 无需 `as` 断言即可安全访问。
if (user && user.id) {
token.id = user.id
token.role = normalizeRole(user.role)
token.name = user.name ?? undefined
// Store all roles (not just primary) and resolved permissions
const allRoles = (u.roles ?? [u.role ?? "student"]).filter(isRole)
const allRoles = (user.roles ?? [user.role ?? "student"]).filter(isRole)
token.roles = allRoles
token.permissions = resolvePermissions(allRoles)
// audit-P1-7JWT 中仅存位图(~14 字符),不存数组(~1.1KB)。
// Session callback 解码还原为 Permission[] 供业务使用。
const permissions = await resolvePermissions(allRoles)
token.permissionsBitmap = encodePermissionsBitmap(permissions)
// Onboarding status is resolved from DB below; default to false on first login
token.onboarded = false
}
@@ -181,7 +78,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
token.role = resolvePrimaryRole(allRoles)
token.name = fresh.name ?? token.name
token.roles = allRoles
token.permissions = resolvePermissions(allRoles)
// audit-P1-7刷新位图不再写 token.permissions 数组
const permissions = await resolvePermissions(allRoles)
token.permissionsBitmap = encodePermissionsBitmap(permissions)
token.onboarded = Boolean(fresh.onboardedAt)
}
}
@@ -189,11 +88,15 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
return token
},
session: async ({ session, token }) => {
// audit-P1-7session.user.permissions 由位图解码得到,
// 业务代码getAuthContext / usePermission无需感知位图存在。
if (session.user) {
session.user.id = String(token.id ?? "")
session.user.role = normalizeRole(token.role)
session.user.roles = (token.roles ?? []).filter(isRole)
session.user.permissions = (token.permissions ?? []) as typeof token.permissions
session.user.permissions = decodePermissionsBitmap(
token.permissionsBitmap ?? "",
)
session.user.onboarded = Boolean(token.onboarded)
if (typeof token.name === "string") {
session.user.name = token.name
@@ -210,17 +113,34 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
action: "signin",
status: "success",
})
// audit-P1-9登录成功埋点用于登录成功率、异常登录地理/设备告警)
// 非阻塞trackEvent 内部已吞掉异常
await trackAuthEvent("auth.signin_success", {
userId: user.id,
properties: {
// user.role 是单一主角色user.roles 是全部角色(可能未填充)
roles: (user.roles ?? (user.role ? [user.role] : [])),
},
})
},
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 ??
""
// P0-5 修复:使用 `in` 操作符对联合类型进行类型收窄,替代 `as` 断言。
// NextAuth v5 signOut 事件 message 为联合类型:
// { session: ... } | { token: JWT }
// JWT 策略下实际收到 { token: JWT },其中 JWT 已在 next-auth.d.ts 扩展。
let userId = ""
let userEmail = ""
let roles: string[] = []
if ("token" in message) {
const token = message.token
if (token) {
userId = String(token.id ?? "")
userEmail = String(token.email ?? "")
roles = (token.roles ?? []).filter(isRole)
}
}
if (userEmail) {
await logLoginEvent({
userId: userId || undefined,
@@ -229,6 +149,13 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
status: "success",
})
}
// audit-P1-9登出埋点
if (userId) {
await trackAuthEvent("auth.signout", {
userId,
properties: { roles },
})
}
},
},
})

View File

@@ -10,6 +10,13 @@ export const env = createEnv({
AI_API_KEY: z.string().min(1).optional(),
AI_BASE_URL: z.string().url().optional(),
AI_MODEL: z.string().min(1).optional(),
// Rate limit driver: "memory" (default, single-instance) or "redis" (distributed).
RATE_LIMIT_DRIVER: z.enum(["memory", "redis"]).default("memory"),
// Upstash Redis REST credentials (only required when RATE_LIMIT_DRIVER=redis).
UPSTASH_REDIS_REST_URL: z.string().url().optional(),
UPSTASH_REDIS_REST_TOKEN: z.string().min(1).optional(),
// audit-P2-5: Cron job bearer token for /api/cron/* endpoints.
CRON_SECRET: z.string().min(1).optional(),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().url().optional(),
@@ -23,6 +30,10 @@ export const env = createEnv({
AI_API_KEY: process.env.AI_API_KEY,
AI_BASE_URL: process.env.AI_BASE_URL,
AI_MODEL: process.env.AI_MODEL,
RATE_LIMIT_DRIVER: process.env.RATE_LIMIT_DRIVER,
UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL,
UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN,
CRON_SECRET: process.env.CRON_SECRET,
},
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
emptyStringAsUndefined: true,

View File

@@ -50,6 +50,10 @@ export default getRequestConfig(async () => {
users,
leave,
nav,
rbac,
questions,
parent,
invitationCodes,
] = await Promise.all([
import(`@/shared/i18n/messages/${locale}/common.json`),
import(`@/shared/i18n/messages/${locale}/auth.json`),
@@ -81,6 +85,10 @@ export default getRequestConfig(async () => {
import(`@/shared/i18n/messages/${locale}/users.json`),
import(`@/shared/i18n/messages/${locale}/leave.json`),
import(`@/shared/i18n/messages/${locale}/nav.json`),
import(`@/shared/i18n/messages/${locale}/rbac.json`),
import(`@/shared/i18n/messages/${locale}/questions.json`),
import(`@/shared/i18n/messages/${locale}/parent.json`),
import(`@/shared/i18n/messages/${locale}/invitation-codes.json`),
]);
return {
@@ -116,6 +124,10 @@ export default getRequestConfig(async () => {
users: users.default,
leave: leave.default,
nav: nav.default,
rbac: rbac.default,
questions: questions.default,
parent: parent.default,
invitationCodes: invitationCodes.default,
},
};
});

27
src/next-auth.d.ts vendored
View File

@@ -2,6 +2,12 @@ import type { DefaultSession } from "next-auth"
import type { Permission, Role } from "@/shared/types/permissions"
declare module "next-auth" {
interface User {
/** 主要角色(向后兼容) */
role?: string
/** 用户拥有的全部角色名 */
roles?: string[]
}
interface Session {
user: DefaultSession["user"] & {
id: string
@@ -18,7 +24,26 @@ declare module "next-auth/jwt" {
id: string
role: string // kept for backward compatibility
roles: Role[]
permissions: Permission[]
/**
* 权限位图base36 字符串audit-P1-7
*
* 由 `encodePermissionsBitmap()` 编码生成,约 14 字符,
* 相比 `permissions: Permission[]` JSON 数组(~1.1KB)显著减小 JWT 体积。
*
* proxy.ts 边缘运行时直接通过 `hasPermissionInBitmap()` 检查权限,
* 无需解码为完整数组。
*
* Session callback 中通过 `decodePermissionsBitmap()` 还原为 Permission[]
* 供服务端 `getAuthContext()` 和客户端 `usePermission()` 使用。
*/
permissionsBitmap: string
/**
* @deprecated 仅用于向后兼容,新代码应使用 `permissionsBitmap`。
* Session callback 已自动从 bitmap 解码并填充到 `session.user.permissions`
* 业务代码无需直接读取 `token.permissions`。
* 在 JWT 中保留此字段会导致 token 体积膨胀,未来版本将移除。
*/
permissions?: Permission[]
onboarded: boolean
}
}

View File

@@ -2,51 +2,15 @@ import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { getToken } from "next-auth/jwt"
import { Permissions } from "@/shared/types/permissions"
// Route prefix → minimum required permission
// Note: /admin/announcements is covered by /admin prefix (requires school:manage)
// Note: /announcements is accessible to all authenticated users (no permission entry needed)
// P0 修复:原先 /teacher 和 /parent 都使用 EXAM_READ但 student/parent 也有 EXAM_READ
// 导致跨角色访问漏洞(学生可访问 /teacher/*,教师可访问 /parent/*)。
// 改为使用各角色独有的权限点,确保跨角色访问被拒绝。
const ROUTE_PERMISSIONS: Record<string, string> = {
"/admin": Permissions.SCHOOL_MANAGE,
"/teacher": Permissions.EXAM_CREATE,
"/student": Permissions.HOMEWORK_SUBMIT,
"/parent": Permissions.DASHBOARD_PARENT_READ,
"/management": Permissions.GRADE_MANAGE,
}
// 仪表盘路由的细粒度权限(覆盖 ROUTE_PERMISSIONS 的前缀匹配)
// 防止拥有 EXAM_READ 的学生/家长访问 /teacher/dashboard 等
const DASHBOARD_ROUTE_PERMISSIONS: Record<string, string> = {
"/admin/dashboard": Permissions.DASHBOARD_ADMIN_READ,
"/teacher/dashboard": Permissions.DASHBOARD_TEACHER_READ,
"/student/dashboard": Permissions.DASHBOARD_STUDENT_READ,
"/parent/dashboard": Permissions.DASHBOARD_PARENT_READ,
}
// 精确路由权限(优先级最高,覆盖 ROUTE_PERMISSIONS 的前缀匹配)
// 用于将 /admin/* 下的特定页面开放给非管理员角色
// V3.1/admin/ai-settings 对所有 AI_CHAT 用户开放(管理自己的 private provider
const SPECIFIC_ROUTE_PERMISSIONS: Record<string, string> = {
"/admin/ai-settings": Permissions.AI_CHAT,
}
// API route prefix → required permission
const API_PERMISSIONS: Record<string, string> = {
"/api/ai/chat": Permissions.AI_CHAT,
}
function resolveDefaultPath(roles: string[]): string {
if (roles.includes("admin")) return "/admin/dashboard"
if (roles.includes("grade_head") || roles.includes("teaching_head")) return "/teacher/dashboard"
if (roles.includes("teacher")) return "/teacher/dashboard"
if (roles.includes("student")) return "/student/dashboard"
if (roles.includes("parent")) return "/parent/dashboard"
return "/dashboard"
}
import { type Permission } from "@/shared/types/permissions"
import { resolveDefaultPath } from "@/shared/lib/route-resolver"
import { hasPermissionInBitmap } from "@/shared/lib/permission-bitmap"
import {
SPECIFIC_ROUTE_PERMISSIONS,
ROUTE_PREFIX_PERMISSIONS,
DASHBOARD_ROUTE_PERMISSIONS,
API_ROUTE_PERMISSIONS,
} from "@/shared/lib/route-permissions"
// Next.js 16 renamed `middleware` to `proxy`.
// See: https://nextjs.org/docs/messages/middleware-to-proxy
@@ -92,13 +56,21 @@ export async function proxy(request: NextRequest) {
return NextResponse.redirect(new URL(defaultPath, request.url))
}
const permissions: string[] = (token.permissions as string[]) ?? []
const permissionsBitmap: string = (token.permissionsBitmap as string) ?? ""
const roles: string[] = (token.roles as string[]) ?? []
/**
* audit-P1-7使用位图检查权限避免每次路由检查都解码完整权限数组。
* proxy.ts 在 edge runtime 运行,每个请求都经过这里,性能至关重要。
*/
function hasPermission(requiredPerm: Permission): boolean {
return hasPermissionInBitmap(permissionsBitmap, requiredPerm)
}
// Check API route permissions
for (const [prefix, requiredPerm] of Object.entries(API_PERMISSIONS)) {
for (const [prefix, requiredPerm] of Object.entries(API_ROUTE_PERMISSIONS)) {
if (pathname.startsWith(prefix)) {
if (!permissions.includes(requiredPerm)) {
if (!hasPermission(requiredPerm)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
break
@@ -109,7 +81,7 @@ export async function proxy(request: NextRequest) {
// 优先级 1精确路由权限覆盖前缀匹配用于将 /admin/* 下特定页面开放给非管理员)
if (Object.prototype.hasOwnProperty.call(SPECIFIC_ROUTE_PERMISSIONS, pathname)) {
const requiredPerm = SPECIFIC_ROUTE_PERMISSIONS[pathname]
if (!permissions.includes(requiredPerm)) {
if (!hasPermission(requiredPerm)) {
const defaultPath = resolveDefaultPath(roles)
const redirectUrl = new URL(defaultPath, request.url)
redirectUrl.searchParams.set("from", pathname)
@@ -122,7 +94,7 @@ export async function proxy(request: NextRequest) {
// 优先级 2仪表盘路由的细粒度权限防止跨角色访问仪表盘
if (Object.prototype.hasOwnProperty.call(DASHBOARD_ROUTE_PERMISSIONS, pathname)) {
const requiredPerm = DASHBOARD_ROUTE_PERMISSIONS[pathname]
if (!permissions.includes(requiredPerm)) {
if (!hasPermission(requiredPerm)) {
const defaultPath = resolveDefaultPath(roles)
const redirectUrl = new URL(defaultPath, request.url)
redirectUrl.searchParams.set("from", pathname)
@@ -132,9 +104,9 @@ export async function proxy(request: NextRequest) {
return NextResponse.next()
}
for (const [prefix, requiredPerm] of Object.entries(ROUTE_PERMISSIONS)) {
for (const [prefix, requiredPerm] of Object.entries(ROUTE_PREFIX_PERMISSIONS)) {
if (pathname.startsWith(prefix)) {
if (!permissions.includes(requiredPerm)) {
if (!hasPermission(requiredPerm)) {
const defaultPath = resolveDefaultPath(roles)
// Carry original path + reason in URL so the target page can explain
// why the user was redirected (Web Interface Guidelines: URL reflects state).