499 lines
16 KiB
JavaScript

import config from '../../config.js';
import { keycloak } from '../../keycloak.js';
import log4js from 'log4js';
import axios from 'axios';
import { userModel } from '../../database/schemas/management/user.schema.js';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import NodeCache from 'node-cache';
import jwt from 'jsonwebtoken';
const logger = log4js.getLogger('Auth');
logger.level = config.server.logLevel;
// Initialize NodeCache with 5-minute TTL for token-based user lookup
const tokenUserCache = new NodeCache({ stdTTL: 300 }); // 300 seconds = 5 minutes
// Cache event listeners for monitoring
tokenUserCache.on('expired', (key, value) => {
logger.debug(`Token user cache entry expired: ${key.substring(0, 20)}...`);
});
tokenUserCache.on('flush', () => {
logger.info('Token user cache flushed');
});
const loginTokenRequests = new Map();
// Token-based user lookup function with caching
const lookupUserByToken = async (token) => {
try {
// Check cache first
const cachedUser = tokenUserCache.get(token);
if (cachedUser) {
logger.trace(`User found in token cache for token: ${token.substring(0, 20)}...`);
return cachedUser;
}
// If not in cache, decode token and lookup user
logger.trace(`User not in token cache, decoding token: ${token.substring(0, 20)}...`);
const decodedToken = jwt.decode(token);
if (!decodedToken || !decodedToken.preferred_username) {
logger.trace('Invalid token or missing preferred_username');
return null;
}
// Query database for user
const user = await userModel.findOne({ username: decodedToken.preferred_username });
if (user) {
// Store in cache using token as key
tokenUserCache.set(token, user);
logger.trace(`User stored in token cache for token: ${token.substring(0, 20)}...`);
return user;
}
logger.warn(`User not found in database for username: ${decodedToken.preferred_username}`);
return null;
} catch (error) {
logger.error(`Error looking up user by token:`, error.message);
return null;
}
};
// Cache management utility functions
const clearTokenUserCache = () => {
tokenUserCache.flushAll();
logger.info('Token user cache cleared');
};
const getTokenUserCacheStats = () => {
return tokenUserCache.getStats();
};
const removeUserFromTokenCache = (token) => {
tokenUserCache.del(token);
logger.debug(`User removed from token cache for token: ${token.substring(0, 20)}...`);
};
// Login handler
export const loginRouteHandler = (req, res, redirectType = 'web') => {
// Get the redirect URL from form data or default to production overview
const redirectUrl = req.query.redirect_uri || '/production/overview';
// Store the original URL to redirect after login
const authUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/auth`;
const callBackState = `/auth/${redirectType}/callback`;
const callbackUrl = `${config.app.urlApi}${callBackState}`;
const state = encodeURIComponent(redirectUrl);
logger.warn(req.query.redirect_uri);
res.redirect(
`${authUrl}?client_id=${config.auth.keycloak.clientId}&redirect_uri=${callbackUrl}&response_type=code&scope=openid&state=${state}`
);
};
// Function to fetch user from Keycloak and store in database and session
const fetchAndStoreUser = async (req, token) => {
const userInfoUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/userinfo`;
try {
const response = await axios.post(
userInfoUrl,
new URLSearchParams({
client_id: config.auth.keycloak.clientId,
client_secret: config.auth.keycloak.clientSecret,
}),
{
headers: {
Authorization: `Bearer ${token.access_token}`,
},
}
);
const userInfo = {
roles: token.realm_access?.roles || [],
username: response.data.preferred_username,
email: response.data.email,
name: response.data.name,
firstName: response.data.given_name,
lastName: response.data.family_name,
};
// Create or update user in database
const user = await createOrUpdateUser(userInfo);
const fullUserInfo = { ...userInfo, _id: user._id };
// Store user info in session
req.session.user = fullUserInfo;
return fullUserInfo;
} catch (error) {
logger.error('Error fetching and storing user:', error);
throw error;
}
};
// Function to exchange authorization code for tokens, fetch user, and set session
export const loginTokenRouteHandler = async (req, res, redirectType = 'web') => {
const code = req.query.code;
if (!code) {
return res.status(400).json({ error: 'Authorization code missing' });
}
try {
// If a request for this code is already in progress, wait for it
if (loginTokenRequests.has(code)) {
const tokenData = await loginTokenRequests.get(code);
return res.status(200).json(tokenData);
}
// Otherwise, start the request and store the promise
const tokenPromise = (async () => {
const callBackState = `/auth/${redirectType}/callback`;
const callbackUrl = `${config.app.urlApi}${callBackState}`;
const tokenUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/token`;
const response = await axios.post(
tokenUrl,
new URLSearchParams({
grant_type: 'authorization_code',
client_id: config.auth.keycloak.clientId,
client_secret: config.auth.keycloak.clientSecret,
code: code,
redirect_uri: callbackUrl,
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
const tokenData = {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
id_token: response.data.id_token,
expires_at: new Date().getTime() + response.data.expires_in * 1000,
};
req.session['keycloak-token'] = tokenData;
// Fetch and store user data, set session
const userData = await fetchAndStoreUser(req, tokenData);
const userAndTokenData = { ...tokenData, ...userData };
return userAndTokenData;
})();
loginTokenRequests.set(code, tokenPromise);
const userAndTokenData = await tokenPromise;
res.status(200).json(userAndTokenData);
} catch (err) {
var error = err?.response?.data?.error_description || err.message;
res.status(err?.status || 500).json({ error: error });
}
};
// Login callback handler
export const loginCallbackRouteHandler = async (req, res, redirectType = 'web') => {
// Don't use keycloak.protect() here as it expects an already authenticated session
// Extract the code and state from the query parameters
const code = req.query.code;
const state = req.query.state || '/production/overview';
if (!code) {
return res.status(400).send('Authorization code missing');
}
var appUrl;
switch (redirectType) {
case 'web':
appUrl = config.app.urlClient;
break;
case 'app-scheme':
appUrl = 'farmcontrol://app';
break;
case 'app-localhost':
appUrl = config.app.devAuthClient;
break;
default:
appUrl = config.app.urlClient;
break;
}
const redirectUriRaw = `${appUrl}${state}`;
let redirectUri;
try {
// Try to parse as a URL (works for http/https)
const url = new URL(redirectUriRaw);
url.searchParams.set('authCode', code);
redirectUri = url.toString();
} catch (e) {
// Fallback for custom schemes (e.g., farmcontrol://app)
if (redirectUriRaw.includes('?')) {
redirectUri = `${redirectUriRaw}&authCode=${encodeURIComponent(code)}`;
} else {
redirectUri = `${redirectUriRaw}?authCode=${encodeURIComponent(code)}`;
}
}
// Save session and redirect to the original URL
req.session.save(async () => {
if (redirectType == 'app-scheme') {
// Read HTML template and inject redirectUri
const templatePath = resolve(process.cwd(), 'src/services/misc/applaunch.html');
let html = readFileSync(templatePath, 'utf8');
html = html.replace('__REDIRECT_URI__', redirectUri);
res.send(html);
} else {
res.redirect(redirectUri);
}
});
};
// Function to create or update user
const createOrUpdateUser = async (userInfo) => {
try {
const { username, email, name, firstName, lastName } = userInfo;
// Find existing user by username
const existingUser = await userModel.findOne({ username });
if (existingUser) {
// Check if any values have changed
const hasChanges =
existingUser.email !== email ||
existingUser.name !== name ||
existingUser.firstName !== firstName ||
existingUser.lastName !== lastName;
if (hasChanges) {
// Update existing user only if there are changes
const updateData = {
email,
name,
firstName,
lastName,
updatedAt: new Date(),
};
await userModel.updateOne({ username }, { $set: updateData });
// Fetch the updated user to return
return await userModel.findOne({ username });
}
return existingUser;
} else {
// Create new user
const newUser = new userModel({
username,
email,
name,
firstName,
lastName,
});
await newUser.save();
return newUser;
}
} catch (error) {
logger.error('Error creating/updating user:', error);
throw error;
}
};
export const userRouteHandler = (req, res) => {
if (req.session && req.session.user) {
res.json(req.session.user);
} else {
res.status(401).json({ error: 'Not authenticated' });
}
};
// Logout handler
export const logoutRouteHandler = (req, res) => {
// Get the redirect URL from query or default to login page
const redirectUrl = req.query.redirect_uri || '/login';
// Destroy the session
req.session.destroy((err) => {
if (err) {
logger.error('Error destroying session:', err);
return res.status(500).json({ error: 'Failed to logout' });
}
// Construct the Keycloak logout URL with the redirect URI
const logoutUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/logout`;
const encodedRedirectUri = encodeURIComponent(`${config.app.urlClient}${redirectUrl}`);
// Redirect to Keycloak logout with the redirect URI
res.redirect(
`${logoutUrl}?client_id=${config.auth.keycloak.clientId}&post_logout_redirect_uri=${encodedRedirectUri}`
);
});
};
// Token validation - protected route middleware
export const validateTokenMiddleware = keycloak.protect();
// Check if user has a specific role
export const hasRole = (role) => {
return keycloak.protect((token) => {
return token && token.hasRole(role);
});
};
// Get user info from the token
export const getUserInfoHandler = (req, res) => {
if (req.kauth && req.kauth.grant) {
const token = req.kauth.grant.access_token;
const userInfo = {
id: token.content.sub,
email: token.content.email,
name:
token.content.name ||
`${token.content.given_name || ''} ${token.content.family_name || ''}`.trim(),
roles: token.content.realm_access?.roles || [],
};
return res.json(userInfo);
}
return res.status(401).json({ error: 'Not authenticated' });
};
// Register route - Since we're using Keycloak, registration should be handled there
// This endpoint will redirect to Keycloak's registration page
export const registerRouteHandler = (req, res) => {
const registrationUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/registrations`;
const redirectUri = encodeURIComponent(config.app.urlClient + '/auth/login');
res.redirect(
`${registrationUrl}?client_id=${config.auth.keycloak.clientId}&redirect_uri=${redirectUri}`
);
};
// Forgot password handler - redirect to Keycloak's reset password page
export const forgotPasswordRouteHandler = (req, res) => {
const resetUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/login-actions/reset-credentials`;
const redirectUri = encodeURIComponent(config.app.urlClient + '/auth/login');
res.redirect(
`${resetUrl}?client_id=${config.auth.keycloak.clientId}&redirect_uri=${redirectUri}`
);
};
// Refresh token handler
export const refreshTokenRouteHandler = (req, res) => {
if (
!req.session ||
!req.session['keycloak-token'] ||
!req.session['keycloak-token'].refresh_token
) {
return res.status(401).json({ error: 'No refresh token available' });
}
const refreshToken = req.session['keycloak-token'].refresh_token;
const tokenUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/token`;
axios
.post(
tokenUrl,
new URLSearchParams({
grant_type: 'refresh_token',
client_id: config.auth.keycloak.clientId,
client_secret: config.auth.keycloak.clientSecret,
refresh_token: refreshToken,
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
)
.then((response) => {
// Update session with new tokens
req.session['keycloak-token'] = {
...req.session['keycloak-token'],
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
expires_at: new Date().getTime() + response.data.expires_in * 1000,
};
// Save session and return new token info
req.session.save(() => {
res.json({
access_token: response.data.access_token,
expires_at: req.session['keycloak-token'].expires_at,
});
});
})
.catch((error) => {
logger.error('Token refresh error:', error.response?.data || error.message);
// If refresh token is invalid, clear the session
if (error.response?.status === 400) {
req.session.destroy();
}
res.status(500).json({ error: 'Failed to refresh token' });
});
};
// Middleware to populate req.user from session or token
export const populateUserMiddleware = async (req, res, next) => {
const authHeader = req.headers.authorization || req.headers.Authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
try {
// Use token-based cache to lookup user
const user = await lookupUserByToken(token);
if (user) {
req.user = user;
// Also set session user for compatibility
req.session.user = user;
return next();
}
} catch (error) {
logger.error('Error in token-based user lookup:', error.message);
}
}
// Fallback to session-based authentication
if (req.session && req.session.user) {
req.user = req.session.user;
} else {
req.user = null;
}
next();
};
// Export cache management functions
export { lookupUserByToken, clearTokenUserCache, getTokenUserCacheStats, removeUserFromTokenCache };
// Example of how to set up your routes in Express
/*
import express from "express";
const app = express();
// Apply session middleware
app.use(sessionMiddleware);
// Initialize Keycloak middleware
app.use(keycloak.middleware());
// Set up routes
app.get('/auth/login', loginRouteHandler);
app.get('/auth/logout', logoutRouteHandler);
app.get('/auth/register', registerRouteHandler);
app.get('/auth/forgot-password', forgotPasswordRouteHandler);
// Protected route example
app.get('/api/profile', validateTokenMiddleware, getUserInfoHandler);
// Admin-only route example
app.get('/api/admin', hasRole('admin'), (req, res) => {
res.json({ message: 'Admin access granted' });
});
*/