Browse Source

feat(auth): add password/jwt/rate-limit utility libs

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
npmrun 1 week ago
parent
commit
e2ffc316fa
  1. 44
      server/service/auth/lib/jwt.ts
  2. 55
      server/service/auth/lib/password.ts
  3. 36
      server/service/auth/lib/rate-limit.ts

44
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<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;
}
}

55
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<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();
}

36
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);
}
Loading…
Cancel
Save