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