513 lines
16 KiB
JavaScript

import config from '../../config.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';
import { getAndConsumeEmailRenderTokenData } from './emailRenderAuth.js';
import {
createSession,
getSession,
updateSessionKeycloakTokens,
deleteSession,
getAndConsumeSlicerAuthCode,
isSlicerAuthCode,
} from './sessionStore.js';
import { expandObjectIds } from '../../utils.js';
const logger = log4js.getLogger('Auth');
logger.level = config.server.logLevel;
// Initialize NodeCache with 5-minute TTL for token-based user lookup (email render tokens)
const tokenUserCache = new NodeCache({ stdTTL: 300 });
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();
// Lookup user by email-render JWT token (short-lived, for Puppeteer)
const lookupUserByEmailRenderToken = async (token) => {
try {
const cachedUser = tokenUserCache.get(token);
if (cachedUser) {
logger.trace(`User found in token cache for token: ${token.substring(0, 20)}...`);
return cachedUser;
}
const decodedToken = jwt.verify(token, config.auth.sessionSecret);
if (!decodedToken || !decodedToken.preferred_username) {
logger.trace('Invalid token or missing preferred_username');
return null;
}
const user = await userModel.findOne({ username: decodedToken.preferred_username });
if (user) {
tokenUserCache.set(token, user);
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;
}
};
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 - redirect to Keycloak
export const loginRouteHandler = (req, res, redirectType = 'web') => {
const redirectUrl = req.query.redirect_uri || '/production/overview';
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);
res.redirect(
`${authUrl}?client_id=${config.auth.keycloak.clientId}&redirect_uri=${callbackUrl}&response_type=code&scope=openid&state=${state}`
);
};
// Fetch user from Keycloak and create/update in database
const fetchAndStoreUser = async (keycloakTokenData) => {
const userInfoUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/userinfo`;
const response = await axios.post(
userInfoUrl,
new URLSearchParams({
client_id: config.auth.keycloak.clientId,
client_secret: config.auth.keycloak.clientSecret,
}),
{
headers: {
Authorization: `Bearer ${keycloakTokenData.access_token}`,
},
}
);
const decoded = jwt.decode(keycloakTokenData.access_token);
const roles = decoded?.realm_access?.roles || [];
const userInfo = {
roles,
username: response.data.preferred_username,
email: response.data.email,
name: response.data.name,
firstName: response.data.given_name,
lastName: response.data.family_name,
};
const user = await createOrUpdateUser(userInfo);
return { ...userInfo, ...user };
};
// Exchange auth code for tokens, create Redis session, return our session token to client
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 (isSlicerAuthCode(code)) {
const slicerTokenData = await getAndConsumeSlicerAuthCode(code);
if (!slicerTokenData) {
return res.status(401).json({ error: 'Authorization code invalid or expired' });
}
logger.debug('Exchanged slicer auth code for session');
return res.status(200).json(slicerTokenData);
}
// Check for temporary email render auth code (30s TTL for Puppeteer)
const emailRenderData = getAndConsumeEmailRenderTokenData(code);
if (emailRenderData) {
logger.debug('Exchanged email render auth code for token');
return res.status(200).json(emailRenderData);
}
if (loginTokenRequests.has(code)) {
const tokenData = await loginTokenRequests.get(code);
return res.status(200).json(tokenData);
}
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 keycloakTokenData = {
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,
};
const userData = await fetchAndStoreUser(keycloakTokenData);
// Create Redis session with our own token
const { sessionToken, expiresAt } = await createSession({
user: userData,
keycloakTokens: keycloakTokenData,
});
// Return our session token to client (UI expects access_token, expires_at, user)
return {
access_token: sessionToken,
expires_at: expiresAt,
...userData,
};
})();
loginTokenRequests.set(code, tokenPromise);
const userAndTokenData = await tokenPromise;
res.status(200).json(userAndTokenData);
} catch (err) {
const error = err?.response?.data?.error_description || err.message;
res.status(err?.response?.status || 500).json({ error: error });
}
};
// Login callback - redirect to client with auth code
export const loginCallbackRouteHandler = async (req, res, redirectType = 'web') => {
const code = req.query.code;
const state = req.query.state || '/production/overview';
if (!code) {
return res.status(400).send('Authorization code missing');
}
let 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 {
const url = new URL(redirectUriRaw);
url.searchParams.set('authCode', code);
redirectUri = url.toString();
} catch (e) {
if (redirectUriRaw.includes('?')) {
redirectUri = `${redirectUriRaw}&authCode=${encodeURIComponent(code)}`;
} else {
redirectUri = `${redirectUriRaw}?authCode=${encodeURIComponent(code)}`;
}
}
if (redirectType === 'web') {
res.redirect(redirectUri);
} else {
res.redirect(
config.app.urlClient +
'/applaunch?redirect=' +
encodeURIComponent(redirectUri) +
'&redirectType=' +
redirectType
);
}
};
const createOrUpdateUser = async (userInfo) => {
try {
const { username, email, name, firstName, lastName } = userInfo;
const existingUser = await userModel.findOne({ username }).lean();
if (existingUser) {
const hasChanges =
existingUser.email !== email ||
existingUser.name !== name ||
existingUser.firstName !== firstName ||
existingUser.lastName !== lastName;
if (hasChanges) {
await userModel.updateOne(
{ username },
{
$set: {
email,
name,
firstName,
lastName,
updatedAt: new Date(),
},
}
);
return expandObjectIds(await userModel.findOne({ username }).lean());
}
return expandObjectIds(existingUser);
}
const newUser = new userModel({
username,
email,
name,
firstName,
lastName,
});
await newUser.save();
return expandObjectIds(newUser._doc);
} catch (error) {
logger.error('Error creating/updating user:', error);
throw error;
}
};
export const userRouteHandler = async (req, res) => {
if (req.user) {
const authHeader = req.headers.authorization || req.headers.Authorization;
const token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
const user = await userModel.findOne({ _id: req.user._id }).lean();
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
return res.json({
access_token: token,
expires_at: req.session?.expiresAt,
user,
});
}
res.status(401).json({ error: 'Not authenticated' });
};
// Logout - delete session from Redis, redirect to Keycloak logout
export const logoutRouteHandler = async (req, res) => {
const redirectUrl = req.query.redirect_uri || '/login';
const authHeader = req.headers.authorization || req.headers.Authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
try {
await deleteSession(token);
} catch (err) {
logger.error('Error deleting session:', err);
}
}
const logoutUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/logout`;
const encodedRedirectUri = encodeURIComponent(`${config.app.urlClient}${redirectUrl}`);
res.redirect(
`${logoutUrl}?client_id=${config.auth.keycloak.clientId}&post_logout_redirect_uri=${encodedRedirectUri}`
);
};
// Middleware: require valid session token
export const validateTokenMiddleware = async (req, res, next) => {
const authHeader = req.headers.authorization || req.headers.Authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
logger.debug('No auth header or not bearer token');
return res.status(401).json({ error: 'Not authenticated', code: 'UNAUTHORIZED' });
}
const token = authHeader.substring(7);
const session = await getSession(token);
if (!session) {
logger.debug('Session not found');
return res.status(401).json({ error: 'Session invalid or expired', code: 'UNAUTHORIZED' });
}
req.user = session.user;
req.session = session;
next();
};
// Middleware: require specific role
export const hasRole = (role) => {
return async (req, res, next) => {
const authHeader = req.headers.authorization || req.headers.Authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Not authenticated', code: 'UNAUTHORIZED' });
}
const token = authHeader.substring(7);
const session = await getSession(token);
if (!session) {
return res.status(401).json({ error: 'Session invalid or expired', code: 'UNAUTHORIZED' });
}
const roles = session.user?.roles || [];
if (!roles.includes(role)) {
return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' });
}
req.user = session.user;
req.session = session;
next();
};
};
export const getUserInfoHandler = (req, res) => {
if (req.user) {
const userInfo = {
id: req.user._id,
email: req.user.email,
profileImage: req.user?.profileImage || null,
name: req.user.name || `${req.user.firstName || ''} ${req.user.lastName || ''}`.trim(),
roles: req.user.roles || [],
};
return res.json(userInfo);
}
res.status(401).json({ error: 'Not authenticated' });
};
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}`
);
};
export const forgotPasswordRouteHandler = (req, res, _email) => {
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 - use Bearer token to find session, refresh via Keycloak, update Redis
export const refreshTokenRouteHandler = async (req, res) => {
const authHeader = req.headers.authorization || req.headers.Authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No session token provided' });
}
const sessionToken = authHeader.substring(7);
const session = await getSession(sessionToken);
if (!session || !session.keycloakTokens?.refresh_token) {
return res.status(401).json({ error: 'No refresh token available' });
}
const tokenUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/token`;
try {
const response = await axios.post(
tokenUrl,
new URLSearchParams({
grant_type: 'refresh_token',
client_id: config.auth.keycloak.clientId,
client_secret: config.auth.keycloak.clientSecret,
refresh_token: session.keycloakTokens.refresh_token,
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
const keycloakTokenData = {
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,
};
await updateSessionKeycloakTokens(sessionToken, keycloakTokenData);
res.json({
access_token: sessionToken,
expires_at: keycloakTokenData.expires_at,
});
} catch (error) {
logger.error('Token refresh error:', error.response?.data || error.message);
if (error.response?.status === 400) {
await deleteSession(sessionToken);
}
res.status(500).json({ error: 'Failed to refresh token' });
}
};
// Middleware to populate req.user from Bearer token (Redis session or email-render JWT)
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 {
// 1. Try Redis session first
const session = await getSession(token);
if (session) {
req.user = session.user;
req.session = session;
return next();
}
// 2. Try email-render JWT (short-lived)
const user = await lookupUserByEmailRenderToken(token);
if (user) {
req.user = user;
return next();
}
} catch (error) {
logger.error('Error in token-based user lookup:', error.message);
}
}
req.user = null;
next();
};
export {
lookupUserByEmailRenderToken as lookupUserByToken,
clearTokenUserCache,
getTokenUserCacheStats,
removeUserFromTokenCache,
getSession,
};