refactor: P1-3/4/6 解耦修复 - 拆分 auth/users 文件 + notifications 反向依赖
This commit is contained in:
19
src/shared/lib/bcrypt-utils.ts
Normal file
19
src/shared/lib/bcrypt-utils.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* bcrypt hash normalization helper (pure function).
|
||||
*
|
||||
* Some legacy rows store bcrypt hashes without the leading `$2b$` version
|
||||
* marker. `compare` from `bcryptjs` requires a well-formed hash, so we
|
||||
* restore the prefix before verification.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize a stored bcrypt hash to the canonical `$2b$...` form.
|
||||
* - Already-prefixed hashes (`$2a$`, `$2b$`, `$2y$`, ...) are returned as-is.
|
||||
* - Hashes starting with `$` but missing the version (e.g. `$abc...`) get `$2b` prepended.
|
||||
* - Bare hashes get the full `$2b$` prefix prepended.
|
||||
*/
|
||||
export const normalizeBcryptHash = (value: string): string => {
|
||||
if (value.startsWith("$2")) return value
|
||||
if (value.startsWith("$")) return `$2b${value}`
|
||||
return `$2b$${value}`
|
||||
}
|
||||
28
src/shared/lib/http-utils.ts
Normal file
28
src/shared/lib/http-utils.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* HTTP request helpers (server-only).
|
||||
*
|
||||
* Thin wrappers around `next/headers` used for best-effort client
|
||||
* identification (rate-limit keying, logging). These values are NOT
|
||||
* stored as audit-trail identifiers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolve the client IP from request headers (best-effort, used for
|
||||
* rate-limit keying only — not stored).
|
||||
*
|
||||
* Falls back to `"unknown"` when headers are unavailable (e.g. during
|
||||
* build or in non-request contexts).
|
||||
*/
|
||||
export 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"
|
||||
}
|
||||
}
|
||||
94
src/shared/lib/password-security-service.ts
Normal file
94
src/shared/lib/password-security-service.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import "server-only"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { PASSWORD_RULES } from "@/shared/lib/password-policy"
|
||||
|
||||
/**
|
||||
* Password security DB operations.
|
||||
*
|
||||
* These functions operate on the `password_security` table to track
|
||||
* failed-login counters and account lockout state. They receive `db`
|
||||
* and the `passwordSecurity` schema object as parameters so the caller
|
||||
* (NextAuth `authorize` callback) can share a single dynamic-import
|
||||
* resolution with its own DB access.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get or create a password_security row for a user.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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))
|
||||
}
|
||||
34
src/shared/lib/role-utils.ts
Normal file
34
src/shared/lib/role-utils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Role normalization utilities (pure functions).
|
||||
*
|
||||
* These helpers map various role names (including legacy aliases) to the
|
||||
* canonical K12 role set: admin / teacher / student / parent.
|
||||
*/
|
||||
|
||||
export type NormalizedRole = "admin" | "teacher" | "student" | "parent"
|
||||
|
||||
/**
|
||||
* Normalize a single role value to one of the canonical roles.
|
||||
* Legacy aliases such as `grade_head` / `teaching_head` collapse to `teacher`.
|
||||
* Unknown values fall back to `student`.
|
||||
*/
|
||||
export const normalizeRole = (value: unknown): NormalizedRole => {
|
||||
const role = String(value ?? "").trim().toLowerCase()
|
||||
if (role === "grade_head" || role === "teaching_head") return "teacher"
|
||||
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
|
||||
return "student"
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of role names (e.g. from `users_to_roles`), resolve the
|
||||
* primary role used for routing/permission checks. Priority order:
|
||||
* admin > teacher > parent > student.
|
||||
*/
|
||||
export const resolvePrimaryRole = (roleNames: string[]): NormalizedRole => {
|
||||
const mapped = roleNames.map((name) => normalizeRole(name)).filter(Boolean)
|
||||
if (mapped.includes("admin")) return "admin"
|
||||
if (mapped.includes("teacher")) return "teacher"
|
||||
if (mapped.includes("parent")) return "parent"
|
||||
if (mapped.includes("student")) return "student"
|
||||
return "student"
|
||||
}
|
||||
Reference in New Issue
Block a user