500 lines
16 KiB
JavaScript
500 lines
16 KiB
JavaScript
import dotenv from 'dotenv';
|
|
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';
|
|
dotenv.config();
|
|
|
|
const logger = log4js.getLogger('Auth');
|
|
logger.level = process.env.LOG_LEVEL;
|
|
|
|
// 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 = `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/auth`;
|
|
const callBackState = `/auth/${redirectType}/callback`;
|
|
const callbackUrl = `${process.env.APP_URL_API}${callBackState}`;
|
|
const state = encodeURIComponent(redirectUrl);
|
|
|
|
logger.warn(req.query.redirect_uri);
|
|
|
|
res.redirect(
|
|
`${authUrl}?client_id=${process.env.KEYCLOAK_CLIENT_ID}&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 = `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/userinfo`;
|
|
|
|
try {
|
|
const response = await axios.post(
|
|
userInfoUrl,
|
|
new URLSearchParams({
|
|
client_id: process.env.KEYCLOAK_CLIENT_ID,
|
|
client_secret: process.env.KEYCLOAK_CLIENT_SECRET,
|
|
}),
|
|
{
|
|
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 = `${process.env.APP_URL_API}${callBackState}`;
|
|
const tokenUrl = `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`;
|
|
|
|
const response = await axios.post(
|
|
tokenUrl,
|
|
new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
client_id: process.env.KEYCLOAK_CLIENT_ID,
|
|
client_secret: process.env.KEYCLOAK_CLIENT_SECRET,
|
|
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 = process.env.APP_URL_CLIENT || 'http://localhost:3000';
|
|
break;
|
|
case 'app-scheme':
|
|
appUrl = 'farmcontrol://app';
|
|
break;
|
|
case 'app-localhost':
|
|
appUrl = process.env.APP_DEV_AUTH_CLIENT || 'http://localhost:3500';
|
|
break;
|
|
default:
|
|
appUrl = process.env.APP_URL_CLIENT || 'http://localhost:3000';
|
|
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 = `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/logout`;
|
|
const encodedRedirectUri = encodeURIComponent(`${process.env.APP_URL_CLIENT}${redirectUrl}`);
|
|
|
|
// Redirect to Keycloak logout with the redirect URI
|
|
res.redirect(
|
|
`${logoutUrl}?client_id=${process.env.KEYCLOAK_CLIENT_ID}&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 = `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/registrations`;
|
|
const redirectUri = encodeURIComponent(process.env.APP_URL_CLIENT + '/auth/login');
|
|
|
|
res.redirect(
|
|
`${registrationUrl}?client_id=${process.env.KEYCLOAK_CLIENT_ID}&redirect_uri=${redirectUri}`
|
|
);
|
|
};
|
|
|
|
// Forgot password handler - redirect to Keycloak's reset password page
|
|
export const forgotPasswordRouteHandler = (req, res) => {
|
|
const resetUrl = `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/login-actions/reset-credentials`;
|
|
const redirectUri = encodeURIComponent(process.env.APP_URL_CLIENT + '/auth/login');
|
|
|
|
res.redirect(
|
|
`${resetUrl}?client_id=${process.env.KEYCLOAK_CLIENT_ID}&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 = `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`;
|
|
|
|
axios
|
|
.post(
|
|
tokenUrl,
|
|
new URLSearchParams({
|
|
grant_type: 'refresh_token',
|
|
client_id: process.env.KEYCLOAK_CLIENT_ID,
|
|
client_secret: process.env.KEYCLOAK_CLIENT_SECRET,
|
|
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' });
|
|
});
|
|
*/
|