monacousa-portal/server/api/auth/direct-login.post.ts

344 lines
11 KiB
TypeScript

// 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) => {
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.'
});
}
});