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