diff --git a/server/service/auth/lib/jwt.ts b/server/service/auth/lib/jwt.ts new file mode 100644 index 0000000..bd7f640 --- /dev/null +++ b/server/service/auth/lib/jwt.ts @@ -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 { + return new SignJWT(payload as Record) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime(ACCESS_TOKEN_EXPIRY) + .sign(JWT_SECRET); +} + +export async function verifyAccessToken( + token: string +): Promise { + 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; + } +} \ No newline at end of file diff --git a/server/service/auth/lib/password.ts b/server/service/auth/lib/password.ts new file mode 100644 index 0000000..7cb4da4 --- /dev/null +++ b/server/service/auth/lib/password.ts @@ -0,0 +1,55 @@ +import bcrypt from "bcryptjs"; + +const SALT_ROUNDS = 12; +const PASSWORD_HISTORY_SIZE = 5; + +export function hashPassword(password: string): Promise { + return bcrypt.hash(password, SALT_ROUNDS); +} + +export async function verifyPassword( + password: string, + hashedPassword: string +): Promise { + return bcrypt.compare(password, hashedPassword); +} + +export async function isPasswordInHistory( + password: string, + history: string[] +): Promise { + 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(); +} \ No newline at end of file diff --git a/server/service/auth/lib/rate-limit.ts b/server/service/auth/lib/rate-limit.ts new file mode 100644 index 0000000..956e017 --- /dev/null +++ b/server/service/auth/lib/rate-limit.ts @@ -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); +} \ No newline at end of file