feat: implement custom login system with direct authentication
All checks were successful
Build And Push Image / docker (push) Successful in 2m51s
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:
258
server/api/auth/direct-login.post.ts
Normal file
258
server/api/auth/direct-login.post.ts
Normal 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.'
|
||||
});
|
||||
}
|
||||
});
|
||||
145
server/api/auth/forgot-password.post.ts
Normal file
145
server/api/auth/forgot-password.post.ts
Normal 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
134
server/utils/security.ts
Normal 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);
|
||||
}
|
||||
@@ -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: '/',
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user