All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good
513 lines
16 KiB
JavaScript
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,
|
|
};
|