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

286 lines
8.8 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) => {
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
});
// 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
};
// 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(),
firstName: userInfo.given_name,
lastName: userInfo.family_name,
username: userInfo.preferred_username || username,
tier: determineTier(userInfo.groups || []),
groups: userInfo.groups || ['user']
},
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 maxAge = !!rememberMe ? 60 * 60 * 24 * 30 : 60 * 60 * 24 * 7; // 30 days vs 7 days
// Don't set a domain for the cookie - let it default to the current domain
console.log(`🍪 Setting session cookie (Remember Me: ${!!rememberMe}) without explicit domain`);
// Create the session cookie string using the session manager
const sessionCookieString = sessionManager.createSession(sessionData, !!rememberMe);
// Parse the cookie string to get just the value
const cookieValue = sessionCookieString.split('=')[1].split(';')[0];
// Use Nuxt's setCookie helper with the encrypted value
setCookie(event, 'monacousa-session', cookieValue, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge,
path: '/',
});
// Clear failed attempts on successful login
clearFailedAttempts(clientIP);
console.log('✅ Login successful for user:', userInfo.email);
// Ensure we return a proper response with status
setResponseStatus(event, 200);
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.'
});
}
});