You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

141 lines
3.7 KiB
TypeScript

import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { randomBytes } from 'node:crypto';
import type { Request, Response, NextFunction } from 'express';
const SALT_ROUNDS = 10;
const TOKEN_EXPIRY = '24h';
const MAX_ATTEMPTS = 5;
const RATE_WINDOW_MS = 60000;
interface AttemptRecord {
count: number;
firstAttempt: number;
}
export class AuthManager {
private passwordHash: string | null = null;
private jwtSecret: string;
private attempts = new Map<string, AttemptRecord>();
private authDisabled = false;
constructor() {
this.jwtSecret = randomBytes(32).toString('hex');
}
disableAuth(): void {
this.authDisabled = true;
}
isAuthDisabled(): boolean {
return this.authDisabled;
}
async setPassword(password: string): Promise<void> {
this.passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
}
async verifyPassword(password: string): Promise<boolean> {
if (!this.passwordHash) return false;
return bcrypt.compare(password, this.passwordHash);
}
generateToken(): string {
return jwt.sign({ ts: Date.now() }, this.jwtSecret, { expiresIn: TOKEN_EXPIRY });
}
verifyToken(token: string): boolean {
if (this.authDisabled) return true;
try {
jwt.verify(token, this.jwtSecret);
return true;
} catch {
return false;
}
}
isRateLimited(ip: string): boolean {
const record = this.attempts.get(ip);
if (!record) return false;
// Reset if window expired
if (Date.now() - record.firstAttempt > RATE_WINDOW_MS) {
this.attempts.delete(ip);
return false;
}
return record.count >= MAX_ATTEMPTS;
}
recordAttempt(ip: string): void {
const record = this.attempts.get(ip);
if (!record || Date.now() - record.firstAttempt > RATE_WINDOW_MS) {
this.attempts.set(ip, { count: 1, firstAttempt: Date.now() });
} else {
record.count++;
}
}
resetAttempts(ip: string): void {
this.attempts.delete(ip);
}
loginHandler() {
return async (req: Request, res: Response): Promise<void> => {
const ip = req.ip || req.socket.remoteAddress || 'unknown';
if (this.isRateLimited(ip)) {
res.status(429).json({ error: 'Too many attempts. Try again later.' });
return;
}
const { password } = req.body as { password?: string };
if (!password) {
res.status(400).json({ error: 'Password required' });
return;
}
this.recordAttempt(ip);
const valid = await this.verifyPassword(password);
if (!valid) {
console.log(`[auth] Failed login from ${ip}`);
res.status(401).json({ error: 'Invalid password' });
return;
}
this.resetAttempts(ip);
const token = this.generateToken();
console.log(`[auth] Successful login from ${ip}`);
res.json({ token });
};
}
cspMiddleware() {
return (_req: Request, res: Response, next: NextFunction): void => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data:;"
);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'no-referrer');
next();
};
}
}
export function generatePassphrase(): string {
const words = [
'alpha', 'bravo', 'cedar', 'delta', 'ember', 'frost', 'grape', 'hatch',
'ivory', 'junco', 'knack', 'lunar', 'maple', 'north', 'orbit', 'pearl',
'quilt', 'ridge', 'solar', 'thorn', 'ultra', 'vivid', 'watch', 'xenon',
];
const picked: string[] = [];
const bytes = randomBytes(4);
for (let i = 0; i < 4; i++) {
picked.push(words[bytes[i] % words.length]);
}
return picked.join('-');
}