feat: implement custom login system with direct authentication
All checks were successful
Build And Push Image / docker (push) Successful in 2m51s
All checks were successful
Build And Push Image / docker (push) Successful in 2m51s
- Add custom login page with username/password form and SSO fallback - Implement direct login API endpoint with security features - Add forgot password functionality and email notifications - Create guest middleware for authentication routing - Update Keycloak configuration and add cookie domain settings - Add security utilities for rate limiting and validation - Include comprehensive documentation for custom login implementation
This commit is contained in:
134
server/utils/security.ts
Normal file
134
server/utils/security.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// Simple in-memory rate limiting (for production, consider Redis or database)
|
||||
const loginAttempts = new Map<string, { count: number; lastAttempt: number }>();
|
||||
const blockedIPs = new Map<string, number>(); // IP -> blocked until timestamp
|
||||
|
||||
export const checkRateLimit = (ip: string): { allowed: boolean; attemptsLeft: number } => {
|
||||
const now = Date.now();
|
||||
const maxAttempts = 5;
|
||||
const windowMs = 15 * 60 * 1000; // 15 minutes
|
||||
const blockDurationMs = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
// Check if IP is currently blocked
|
||||
const blockedUntil = blockedIPs.get(ip);
|
||||
if (blockedUntil && now < blockedUntil) {
|
||||
return { allowed: false, attemptsLeft: 0 };
|
||||
}
|
||||
|
||||
// Remove expired block
|
||||
if (blockedUntil && now >= blockedUntil) {
|
||||
blockedIPs.delete(ip);
|
||||
}
|
||||
|
||||
// Get current attempts for this IP
|
||||
const attempts = loginAttempts.get(ip);
|
||||
|
||||
// Clean up old attempts outside the window
|
||||
if (attempts && (now - attempts.lastAttempt) > windowMs) {
|
||||
loginAttempts.delete(ip);
|
||||
return { allowed: true, attemptsLeft: maxAttempts };
|
||||
}
|
||||
|
||||
const currentCount = attempts?.count || 0;
|
||||
|
||||
if (currentCount >= maxAttempts) {
|
||||
// Block the IP
|
||||
blockedIPs.set(ip, now + blockDurationMs);
|
||||
console.warn(`🚨 IP ${ip} blocked due to ${currentCount} failed login attempts`);
|
||||
return { allowed: false, attemptsLeft: 0 };
|
||||
}
|
||||
|
||||
return { allowed: true, attemptsLeft: maxAttempts - currentCount };
|
||||
};
|
||||
|
||||
export const recordFailedAttempt = (ip: string): void => {
|
||||
const now = Date.now();
|
||||
const attempts = loginAttempts.get(ip);
|
||||
|
||||
if (attempts) {
|
||||
attempts.count += 1;
|
||||
attempts.lastAttempt = now;
|
||||
} else {
|
||||
loginAttempts.set(ip, { count: 1, lastAttempt: now });
|
||||
}
|
||||
|
||||
const newCount = loginAttempts.get(ip)?.count || 1;
|
||||
console.warn(`⚠️ Failed login attempt from ${ip} (${newCount}/5)`);
|
||||
};
|
||||
|
||||
export const clearFailedAttempts = (ip: string): void => {
|
||||
loginAttempts.delete(ip);
|
||||
blockedIPs.delete(ip);
|
||||
console.log(`✅ Cleared failed attempts for ${ip}`);
|
||||
};
|
||||
|
||||
// Input validation
|
||||
export const validateLoginInput = (username: string, password: string): string[] => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!username || typeof username !== 'string') {
|
||||
errors.push('Username is required');
|
||||
} else if (username.length < 2) {
|
||||
errors.push('Username must be at least 2 characters');
|
||||
} else if (username.length > 100) {
|
||||
errors.push('Username is too long');
|
||||
}
|
||||
|
||||
if (!password || typeof password !== 'string') {
|
||||
errors.push('Password is required');
|
||||
} else if (password.length < 6) {
|
||||
errors.push('Password must be at least 6 characters');
|
||||
} else if (password.length > 200) {
|
||||
errors.push('Password is too long');
|
||||
}
|
||||
|
||||
// Basic sanitization check
|
||||
const dangerousChars = /<script|javascript:|data:|vbscript:/i;
|
||||
if (dangerousChars.test(username) || dangerousChars.test(password)) {
|
||||
errors.push('Invalid characters detected');
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
// Get client IP helper
|
||||
export const getClientIP = (event: any): string => {
|
||||
// Try various headers that might contain the real IP
|
||||
const headers = getHeaders(event);
|
||||
|
||||
return (
|
||||
headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
headers['x-real-ip'] ||
|
||||
headers['x-client-ip'] ||
|
||||
headers['cf-connecting-ip'] || // Cloudflare
|
||||
event.node?.req?.connection?.remoteAddress ||
|
||||
event.node?.req?.socket?.remoteAddress ||
|
||||
'unknown'
|
||||
);
|
||||
};
|
||||
|
||||
// Clean up old entries periodically (call this from a cron job or similar)
|
||||
export const cleanupOldEntries = (): void => {
|
||||
const now = Date.now();
|
||||
const windowMs = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
// Clean up old login attempts
|
||||
for (const [ip, attempts] of loginAttempts.entries()) {
|
||||
if ((now - attempts.lastAttempt) > windowMs) {
|
||||
loginAttempts.delete(ip);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up expired blocks
|
||||
for (const [ip, blockedUntil] of blockedIPs.entries()) {
|
||||
if (now >= blockedUntil) {
|
||||
blockedIPs.delete(ip);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🧹 Cleaned up old security entries');
|
||||
};
|
||||
|
||||
// Initialize cleanup interval (runs every 5 minutes)
|
||||
if (typeof setInterval !== 'undefined') {
|
||||
setInterval(cleanupOldEntries, 5 * 60 * 1000);
|
||||
}
|
||||
@@ -27,19 +27,21 @@ export class SessionManager {
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
createSession(sessionData: SessionData): string {
|
||||
createSession(sessionData: SessionData, rememberMe: boolean = false): string {
|
||||
const data = JSON.stringify(sessionData);
|
||||
const encrypted = this.encrypt(data);
|
||||
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || undefined;
|
||||
console.log('🍪 Creating session cookie with domain:', cookieDomain);
|
||||
const maxAge = rememberMe ? 60 * 60 * 24 * 30 : 60 * 60 * 24 * 7; // 30 days vs 7 days
|
||||
|
||||
console.log(`🍪 Creating session cookie (Remember Me: ${rememberMe}) with domain:`, cookieDomain);
|
||||
|
||||
return serialize(this.cookieName, encrypted, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
domain: cookieDomain,
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
maxAge,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user