feat: implement custom login system with direct authentication
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:
2025-08-07 03:43:25 +02:00
parent 308c58e924
commit 2c2c0f5c33
11 changed files with 1290 additions and 46 deletions

View File

@@ -0,0 +1,258 @@
// Security utilities embedded directly
const loginAttempts = new Map<string, { count: number; lastAttempt: number }>();
const blockedIPs = new Map<string, number>();
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
const blockedUntil = blockedIPs.get(ip);
if (blockedUntil && now < blockedUntil) {
return { allowed: false, attemptsLeft: 0 };
}
if (blockedUntil && now >= blockedUntil) {
blockedIPs.delete(ip);
}
const attempts = loginAttempts.get(ip);
if (attempts && (now - attempts.lastAttempt) > windowMs) {
loginAttempts.delete(ip);
return { allowed: true, attemptsLeft: maxAttempts };
}
const currentCount = attempts?.count || 0;
if (currentCount >= maxAttempts) {
blockedIPs.set(ip, now + blockDurationMs);
return { allowed: false, attemptsLeft: 0 };
}
return { allowed: true, attemptsLeft: maxAttempts - currentCount };
};
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 clearFailedAttempts = (ip: string): void => {
loginAttempts.delete(ip);
blockedIPs.delete(ip);
};
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');
}
return errors;
};
const getClientIP = (event: any): string => {
const headers = getHeaders(event);
return (
headers['x-forwarded-for']?.split(',')[0]?.trim() ||
headers['x-real-ip'] ||
headers['x-client-ip'] ||
headers['cf-connecting-ip'] ||
'unknown'
);
};
export default defineEventHandler(async (event) => {
console.log('🔐 Direct login endpoint called at:', new Date().toISOString());
try {
const { username, password, rememberMe } = await readBody(event);
const clientIP = getClientIP(event) || 'unknown';
console.log('📝 Login attempt:', {
username: username ? 'present' : 'missing',
hasPassword: !!password,
rememberMe,
ip: clientIP
});
// Input validation
const validationErrors = validateLoginInput(username, password);
if (validationErrors.length > 0) {
console.warn('❌ Validation failed:', validationErrors);
throw createError({
statusCode: 400,
statusMessage: validationErrors.join(', ')
});
}
// Rate limiting check
const rateLimit = checkRateLimit(clientIP);
if (!rateLimit.allowed) {
console.warn('🚨 Rate limit exceeded for IP:', clientIP);
throw createError({
statusCode: 429,
statusMessage: 'Too many login attempts. Please try again later.'
});
}
const config = useRuntimeConfig();
// Validate Keycloak configuration
if (!config.keycloak?.issuer || !config.keycloak?.clientId || !config.keycloak?.clientSecret) {
console.error('❌ Missing Keycloak configuration');
throw createError({
statusCode: 500,
statusMessage: 'Authentication service configuration error'
});
}
console.log('🔧 Using Keycloak config:', {
issuer: config.keycloak.issuer,
clientId: config.keycloak.clientId,
hasSecret: !!config.keycloak.clientSecret
});
// Direct authentication with Keycloak using Resource Owner Password Credentials flow
const tokenResponse = await fetch(`${config.keycloak.issuer}/protocol/openid-connect/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'MonacoUSA-Portal/1.0'
},
body: new URLSearchParams({
grant_type: 'password',
client_id: config.keycloak.clientId,
client_secret: config.keycloak.clientSecret,
username,
password,
scope: 'openid email profile'
})
});
if (!tokenResponse.ok) {
const errorData = await tokenResponse.json().catch(() => ({}));
console.error('❌ Keycloak token error:', {
status: tokenResponse.status,
statusText: tokenResponse.statusText,
error: errorData
});
// Record failed attempt for rate limiting
recordFailedAttempt(clientIP);
// Map Keycloak errors to user-friendly messages
let errorMessage = 'Invalid username or password';
if (errorData.error === 'invalid_grant') {
errorMessage = 'Invalid username or password';
} else if (errorData.error === 'unauthorized_client') {
errorMessage = 'Authentication service error';
} else if (errorData.error_description) {
errorMessage = errorData.error_description;
}
throw createError({
statusCode: 401,
statusMessage: errorMessage
});
}
const tokens = await tokenResponse.json();
console.log('✅ Token exchange successful');
// Get user info from Keycloak
const userResponse = await fetch(`${config.keycloak.issuer}/protocol/openid-connect/userinfo`, {
headers: {
'Authorization': `Bearer ${tokens.access_token}`,
'User-Agent': 'MonacoUSA-Portal/1.0'
}
});
if (!userResponse.ok) {
console.error('❌ Failed to get user info:', userResponse.status);
throw createError({
statusCode: 500,
statusMessage: 'Failed to retrieve user information'
});
}
const userInfo = await userResponse.json();
console.log('✅ User info retrieved:', {
sub: userInfo.sub,
email: userInfo.email,
name: userInfo.name
});
// Create session data with extended expiry if remember me
const sessionData = {
user: {
id: userInfo.sub,
email: userInfo.email,
name: userInfo.name || `${userInfo.given_name || ''} ${userInfo.family_name || ''}`.trim(),
groups: userInfo.groups || [],
tier: userInfo.tier,
username: userInfo.preferred_username || username
},
tokens: {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + (tokens.expires_in * 1000)
},
rememberMe: !!rememberMe,
createdAt: Date.now(),
lastActivity: Date.now()
};
// Create session with appropriate expiration
const sessionManager = createSessionManager();
const sessionCookie = sessionManager.createSession(sessionData, !!rememberMe);
// Set session cookie
setHeader(event, 'Set-Cookie', sessionCookie);
// Clear failed attempts on successful login
clearFailedAttempts(clientIP);
console.log('✅ Login successful for user:', userInfo.email);
return {
success: true,
user: sessionData.user,
redirectTo: '/dashboard'
};
} catch (error: any) {
console.error('❌ Direct login error:', error);
// If it's already a createError, just throw it
if (error.statusCode) {
throw error;
}
// Generic error for unexpected issues
throw createError({
statusCode: 500,
statusMessage: 'Login failed. Please try again.'
});
}
});

View File

@@ -0,0 +1,145 @@
export default defineEventHandler(async (event) => {
console.log('🔄 Forgot password endpoint called at:', new Date().toISOString());
try {
const { email } = await readBody(event);
console.log('📧 Password reset request for email:', email ? 'present' : 'missing');
// Input validation
if (!email || typeof email !== 'string') {
throw createError({
statusCode: 400,
statusMessage: 'Email is required'
});
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw createError({
statusCode: 400,
statusMessage: 'Please enter a valid email address'
});
}
const config = useRuntimeConfig();
// Validate Keycloak configuration
if (!config.keycloak?.issuer || !config.keycloak?.clientId || !config.keycloak?.clientSecret) {
console.error('❌ Missing Keycloak configuration');
throw createError({
statusCode: 500,
statusMessage: 'Authentication service configuration error'
});
}
console.log('🔧 Using Keycloak config for password reset:', {
issuer: config.keycloak.issuer,
clientId: config.keycloak.clientId
});
try {
// Get admin token for Keycloak admin API
const adminTokenResponse = await fetch(`${config.keycloak.issuer}/protocol/openid-connect/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'MonacoUSA-Portal/1.0'
},
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: config.keycloak.clientId,
client_secret: config.keycloak.clientSecret
})
});
if (!adminTokenResponse.ok) {
console.error('❌ Failed to get admin token:', adminTokenResponse.status);
throw new Error('Failed to authenticate with admin service');
}
const adminToken = await adminTokenResponse.json();
console.log('✅ Admin token obtained');
// Find user by email using Keycloak admin API
const realmName = config.keycloak.issuer.split('/realms/')[1];
const adminBaseUrl = config.keycloak.issuer.replace('/realms/', '/admin/realms/');
const usersResponse = await fetch(`${adminBaseUrl}/users?email=${encodeURIComponent(email)}&exact=true`, {
headers: {
'Authorization': `Bearer ${adminToken.access_token}`,
'User-Agent': 'MonacoUSA-Portal/1.0'
}
});
if (!usersResponse.ok) {
console.error('❌ Failed to search users:', usersResponse.status);
throw new Error('Failed to search for user');
}
const users = await usersResponse.json();
console.log('🔍 User search result:', { found: users.length > 0 });
if (users.length === 0) {
// For security, don't reveal if email exists or not
console.log('⚠️ Email not found, but returning success message for security');
return {
success: true,
message: 'If the email exists in our system, a reset link has been sent.'
};
}
const userId = users[0].id;
console.log('👤 Found user:', { id: userId, email: users[0].email });
// Send reset password email using Keycloak's execute-actions-email
const resetResponse = await fetch(`${adminBaseUrl}/users/${userId}/execute-actions-email`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${adminToken.access_token}`,
'Content-Type': 'application/json',
'User-Agent': 'MonacoUSA-Portal/1.0'
},
body: JSON.stringify(['UPDATE_PASSWORD'])
});
if (!resetResponse.ok) {
console.error('❌ Failed to send reset email:', resetResponse.status);
const errorText = await resetResponse.text().catch(() => 'Unknown error');
console.error('Reset email error details:', errorText);
throw new Error('Failed to send reset email');
}
console.log('✅ Password reset email sent successfully');
return {
success: true,
message: 'If the email exists in our system, a reset link has been sent.'
};
} catch (keycloakError: any) {
console.error('❌ Keycloak API error:', keycloakError);
// For security, don't reveal specific errors to the user
return {
success: true,
message: 'If the email exists in our system, a reset link has been sent.'
};
}
} catch (error: any) {
console.error('❌ Forgot password error:', error);
// If it's already a createError, just throw it
if (error.statusCode) {
throw error;
}
// Generic error for unexpected issues
throw createError({
statusCode: 500,
statusMessage: 'Failed to process password reset request. Please try again.'
});
}
});

134
server/utils/security.ts Normal file
View 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);
}

View File

@@ -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: '/',
});
}