3 changed files with 135 additions and 0 deletions
@ -0,0 +1,44 @@ |
|||||
|
import { SignJWT, jwtVerify, decodeJwt } from "jose"; |
||||
|
import type { JWTPayload } from "jose"; |
||||
|
|
||||
|
const JWT_SECRET = new TextEncoder().encode( |
||||
|
process.env.JWT_SECRET || "dev-secret-change-in-production" |
||||
|
); |
||||
|
const ACCESS_TOKEN_EXPIRY = "15m"; |
||||
|
|
||||
|
export interface AccessTokenPayload extends JWTPayload { |
||||
|
userId: number; |
||||
|
sessionId: string; |
||||
|
role: string; |
||||
|
} |
||||
|
|
||||
|
export async function signAccessToken(payload: { |
||||
|
userId: number; |
||||
|
sessionId: string; |
||||
|
role: string; |
||||
|
}): Promise<string> { |
||||
|
return new SignJWT(payload as Record<string, unknown>) |
||||
|
.setProtectedHeader({ alg: "HS256" }) |
||||
|
.setIssuedAt() |
||||
|
.setExpirationTime(ACCESS_TOKEN_EXPIRY) |
||||
|
.sign(JWT_SECRET); |
||||
|
} |
||||
|
|
||||
|
export async function verifyAccessToken( |
||||
|
token: string |
||||
|
): Promise<AccessTokenPayload | null> { |
||||
|
try { |
||||
|
const { payload } = await jwtVerify(token, JWT_SECRET); |
||||
|
return payload as AccessTokenPayload; |
||||
|
} catch { |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function decodeAccessTokenNoVerify(token: string): AccessTokenPayload | null { |
||||
|
try { |
||||
|
return decodeJwt(token) as AccessTokenPayload; |
||||
|
} catch { |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,55 @@ |
|||||
|
import bcrypt from "bcryptjs"; |
||||
|
|
||||
|
const SALT_ROUNDS = 12; |
||||
|
const PASSWORD_HISTORY_SIZE = 5; |
||||
|
|
||||
|
export function hashPassword(password: string): Promise<string> { |
||||
|
return bcrypt.hash(password, SALT_ROUNDS); |
||||
|
} |
||||
|
|
||||
|
export async function verifyPassword( |
||||
|
password: string, |
||||
|
hashedPassword: string |
||||
|
): Promise<boolean> { |
||||
|
return bcrypt.compare(password, hashedPassword); |
||||
|
} |
||||
|
|
||||
|
export async function isPasswordInHistory( |
||||
|
password: string, |
||||
|
history: string[] |
||||
|
): Promise<boolean> { |
||||
|
if (history.length === 0) return false; |
||||
|
for (const h of history) { |
||||
|
if (await bcrypt.compare(password, h)) return true; |
||||
|
} |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
export function addPasswordToHistory( |
||||
|
newHash: string, |
||||
|
history: string[] |
||||
|
): string[] { |
||||
|
const updated = [newHash, ...history]; |
||||
|
return updated.slice(0, PASSWORD_HISTORY_SIZE); |
||||
|
} |
||||
|
|
||||
|
export interface PasswordStrengthResult { |
||||
|
valid: boolean; |
||||
|
errors: string[]; |
||||
|
} |
||||
|
|
||||
|
export function validatePasswordStrength(password: string): PasswordStrengthResult { |
||||
|
const errors: string[] = []; |
||||
|
if (password.length < 8) errors.push("至少8个字符"); |
||||
|
if (!/[A-Z]/.test(password)) errors.push("需包含大写字母"); |
||||
|
if (!/[a-z]/.test(password)) errors.push("需包含小写字母"); |
||||
|
if (!/[0-9]/.test(password)) errors.push("需包含数字"); |
||||
|
if (!/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) |
||||
|
errors.push("需包含特殊字符"); |
||||
|
return { valid: errors.length === 0, errors }; |
||||
|
} |
||||
|
|
||||
|
export function isLocked(lockoutUntil: Date | null): boolean { |
||||
|
if (!lockoutUntil) return false; |
||||
|
return Date.now() < lockoutUntil.getTime(); |
||||
|
} |
||||
@ -0,0 +1,36 @@ |
|||||
|
// In-memory rate limiter keyed by IP
|
||||
|
const loginAttempts = new Map< |
||||
|
string, |
||||
|
{ count: number; resetAt: number } |
||||
|
>(); |
||||
|
|
||||
|
const WINDOW_MS = 60_000; // 1 minute window
|
||||
|
const MAX_ATTEMPTS = 5; // max 5 attempts per window
|
||||
|
const LOCKOUT_MS = 15 * 60_000; // 15 minute lockout
|
||||
|
|
||||
|
export function checkRateLimit(ip: string): { |
||||
|
allowed: boolean; |
||||
|
retryAfterMs: number; |
||||
|
} { |
||||
|
const now = Date.now(); |
||||
|
const entry = loginAttempts.get(ip); |
||||
|
|
||||
|
if (!entry || now > entry.resetAt) { |
||||
|
loginAttempts.set(ip, { count: 1, resetAt: now + WINDOW_MS }); |
||||
|
return { allowed: true, retryAfterMs: 0 }; |
||||
|
} |
||||
|
|
||||
|
if (entry.count >= MAX_ATTEMPTS) { |
||||
|
return { |
||||
|
allowed: false, |
||||
|
retryAfterMs: entry.resetAt - now, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
entry.count++; |
||||
|
return { allowed: true, retryAfterMs: 0 }; |
||||
|
} |
||||
|
|
||||
|
export function clearRateLimit(ip: string): void { |
||||
|
loginAttempts.delete(ip); |
||||
|
} |
||||
Loading…
Reference in new issue