286 lines
8.8 KiB
TypeScript
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.'
|
|
});
|
|
}
|
|
});
|