// Security utilities embedded directly const loginAttempts = new Map(); const blockedIPs = new Map(); 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) => { try { const { username, password, rememberMe } = await readBody(event); const clientIP = getClientIP(event) || 'unknown'; // 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' }); } // 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 roles' }) }); 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(); // Decode the access token to extract user information let tokenPayload = null; try { const tokenParts = tokens.access_token.split('.'); const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64').toString()); tokenPayload = payload; } catch (err) { console.warn('⚠️ Could not decode access token:', err); } // 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(); // Extract groups/roles from multiple possible locations const extractGroups = (tokenPayload: any, userInfo: any): string[] => { const allGroups: string[] = []; // Check userInfo for groups (from userinfo endpoint) if (userInfo.groups && Array.isArray(userInfo.groups)) { allGroups.push(...userInfo.groups); } // Check userInfo for roles if (userInfo.roles && Array.isArray(userInfo.roles)) { allGroups.push(...userInfo.roles); } // Check token payload for realm_access.roles if (tokenPayload?.realm_access?.roles && Array.isArray(tokenPayload.realm_access.roles)) { allGroups.push(...tokenPayload.realm_access.roles); } // Check token payload for resource_access roles if (tokenPayload?.resource_access) { Object.keys(tokenPayload.resource_access).forEach(clientId => { const clientRoles = tokenPayload.resource_access[clientId]?.roles; if (clientRoles && Array.isArray(clientRoles)) { allGroups.push(...clientRoles); } }); } // Check token payload for groups claim if (tokenPayload?.groups && Array.isArray(tokenPayload.groups)) { allGroups.push(...tokenPayload.groups); } // Remove duplicates and filter out default Keycloak roles const uniqueGroups = [...new Set(allGroups)].filter(group => !['default-roles-monacousa', 'offline_access', 'uma_authorization'].includes(group) ); return uniqueGroups; }; // Tier determination logic - admin > board > user priority const determineTier = (groups: string[]): 'user' | 'board' | 'admin' => { if (groups.includes('admin')) { return 'admin'; } if (groups.includes('board')) { return 'board'; } return 'user'; // Default tier }; // Extract groups from all possible sources const extractedGroups = extractGroups(tokenPayload, userInfo); // Create simplified session data to reduce cookie size const userTier = determineTier(extractedGroups); const userGroups = extractedGroups.length > 0 ? extractedGroups.slice(0, 10) : ['user']; // Limit groups to prevent large cookies const sessionData = { user: { id: userInfo.sub, email: userInfo.email, name: userInfo.name || `${userInfo.given_name || ''} ${userInfo.family_name || ''}`.trim(), firstName: userInfo.given_name, lastName: userInfo.family_name, username: userInfo.preferred_username || username, tier: userTier, groups: userGroups }, 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 server-side storage const sessionManager = createSessionManager(); try { // Create session and get cookie string const cookieString = sessionManager.createSession(sessionData, !!rememberMe); // Parse the cookie string to get the session ID const cookieParts = cookieString.split(';')[0].split('='); const sessionId = cookieParts[1]; // Set the cookie using Nuxt's setCookie helper const maxAge = !!rememberMe ? 60 * 60 * 24 * 30 : 60 * 60 * 24 * 7; // 30 days vs 7 days setCookie(event, 'monacousa-session', sessionId, { httpOnly: true, secure: true, sameSite: 'none', maxAge, path: '/', }); } catch (cookieError) { console.error('❌ Failed to set session cookie:', cookieError); throw createError({ statusCode: 500, statusMessage: 'Failed to create session' }); } // Clear failed attempts on successful login clearFailedAttempts(clientIP); console.log('✅ Login successful for user:', userInfo.email); // Add a small delay to ensure cookie is set await new Promise(resolve => setTimeout(resolve, 100)); // Ensure we return a proper response with explicit status setResponseStatus(event, 200); setHeader(event, 'Content-Type', 'application/json'); // Return a minimal response to prevent 502 errors return { success: true, redirectTo: '/dashboard', user: { email: userInfo.email, name: userInfo.name, tier: userTier } }; } 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.' }); } });