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:
119
src/shared/lib/password-policy.ts
Normal file
119
src/shared/lib/password-policy.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Password security policy and account lockout helpers.
|
||||
*
|
||||
* These utilities are pure (no DB / I/O) so they can be safely used
|
||||
* in both server and client contexts.
|
||||
*/
|
||||
|
||||
export const PASSWORD_RULES = {
|
||||
minLength: 8,
|
||||
requireUppercase: true,
|
||||
requireLowercase: true,
|
||||
requireNumber: true,
|
||||
requireSpecialChar: false,
|
||||
maxLoginAttempts: 5,
|
||||
lockoutDurationMinutes: 30,
|
||||
} as const
|
||||
|
||||
export interface PasswordValidationResult {
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a password against the configured policy.
|
||||
*/
|
||||
export function validatePassword(password: string): PasswordValidationResult {
|
||||
const errors: string[] = []
|
||||
|
||||
if (password.length < PASSWORD_RULES.minLength) {
|
||||
errors.push(`Password must be at least ${PASSWORD_RULES.minLength} characters long`)
|
||||
}
|
||||
|
||||
if (PASSWORD_RULES.requireUppercase && !/[A-Z]/.test(password)) {
|
||||
errors.push("Password must contain at least one uppercase letter")
|
||||
}
|
||||
|
||||
if (PASSWORD_RULES.requireLowercase && !/[a-z]/.test(password)) {
|
||||
errors.push("Password must contain at least one lowercase letter")
|
||||
}
|
||||
|
||||
if (PASSWORD_RULES.requireNumber && !/[0-9]/.test(password)) {
|
||||
errors.push("Password must contain at least one number")
|
||||
}
|
||||
|
||||
if (PASSWORD_RULES.requireSpecialChar && !/[^A-Za-z0-9]/.test(password)) {
|
||||
errors.push("Password must contain at least one special character")
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
export type PasswordStrength = "weak" | "medium" | "strong"
|
||||
|
||||
/**
|
||||
* Compute a coarse password strength label based on length and character
|
||||
* diversity. Useful for client-side strength indicators.
|
||||
*/
|
||||
export function getPasswordStrength(password: string): PasswordStrength {
|
||||
if (password.length === 0) return "weak"
|
||||
|
||||
let score = 0
|
||||
if (password.length >= 8) score++
|
||||
if (password.length >= 12) score++
|
||||
if (/[a-z]/.test(password)) score++
|
||||
if (/[A-Z]/.test(password)) score++
|
||||
if (/[0-9]/.test(password)) score++
|
||||
if (/[^A-Za-z0-9]/.test(password)) score++
|
||||
|
||||
if (score <= 2) return "weak"
|
||||
if (score <= 4) return "medium"
|
||||
return "strong"
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an account should be considered locked given its
|
||||
* failed-attempt count and the timestamp of the most recent failure.
|
||||
*
|
||||
* The lockout is lifted automatically once `lockoutDurationMinutes` have
|
||||
* elapsed since `lastFailedAt`.
|
||||
*/
|
||||
export function isAccountLocked(
|
||||
failedAttempts: number,
|
||||
lastFailedAt: Date | null
|
||||
): boolean {
|
||||
if (failedAttempts < PASSWORD_RULES.maxLoginAttempts) return false
|
||||
if (!lastFailedAt) return false
|
||||
|
||||
const lockoutMs = PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000
|
||||
const elapsed = Date.now() - lastFailedAt.getTime()
|
||||
return elapsed < lockoutMs
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the remaining lockout time in milliseconds (0 if unlocked).
|
||||
*/
|
||||
export function getRemainingLockoutMs(
|
||||
failedAttempts: number,
|
||||
lastFailedAt: Date | null
|
||||
): number {
|
||||
if (!isAccountLocked(failedAttempts, lastFailedAt)) return 0
|
||||
if (!lastFailedAt) return 0
|
||||
const lockoutMs = PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000
|
||||
const elapsed = Date.now() - lastFailedAt.getTime()
|
||||
return Math.max(0, lockoutMs - elapsed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable summary of the password policy. Used by the UI to
|
||||
* display requirements next to the password input.
|
||||
*/
|
||||
export const PASSWORD_REQUIREMENT_HINTS: string[] = [
|
||||
`At least ${PASSWORD_RULES.minLength} characters`,
|
||||
PASSWORD_RULES.requireUppercase ? "At least one uppercase letter (A-Z)" : null,
|
||||
PASSWORD_RULES.requireLowercase ? "At least one lowercase letter (a-z)" : null,
|
||||
PASSWORD_RULES.requireNumber ? "At least one number (0-9)" : null,
|
||||
PASSWORD_RULES.requireSpecialChar
|
||||
? "At least one special character (!@#$...)"
|
||||
: null,
|
||||
].filter((s): s is string => Boolean(s))
|
||||
Reference in New Issue
Block a user