Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
201 lines
6.2 KiB
JavaScript
201 lines
6.2 KiB
JavaScript
/**
|
|
* Authentication middleware - uses Redis session store.
|
|
* Keycloak is used only for login/refresh; session validation is done via Redis.
|
|
*/
|
|
import config, { getEnvironment } from './config.js';
|
|
import log4js from 'log4js';
|
|
import NodeCache from 'node-cache';
|
|
import bcrypt from 'bcrypt';
|
|
import { userModel } from './database/schemas/management/user.schema.js';
|
|
import { appPasswordModel } from './database/schemas/management/apppassword.schema.js';
|
|
import { getObject } from './database/database.js';
|
|
import { hostModel } from './database/schemas/management/host.schema.js';
|
|
import { getSession, lookupUserByToken } from './services/misc/auth.js';
|
|
|
|
const logger = log4js.getLogger('Keycloak');
|
|
logger.level = config.server.logLevel || 'info';
|
|
|
|
const userCache = new NodeCache({ stdTTL: 300 });
|
|
|
|
userCache.on('expired', (key, value) => {
|
|
logger.debug(`Cache entry expired: ${key}`);
|
|
});
|
|
|
|
userCache.on('flush', () => {
|
|
logger.info('Cache flushed');
|
|
});
|
|
|
|
const lookupUser = async (preferredUsername) => {
|
|
try {
|
|
const cachedUser = userCache.get(preferredUsername);
|
|
if (cachedUser) {
|
|
logger.debug(`User found in cache: ${preferredUsername}`);
|
|
return cachedUser;
|
|
}
|
|
|
|
logger.debug(`User not in cache, querying database: ${preferredUsername}`);
|
|
const user = await userModel.findOne({ username: preferredUsername });
|
|
|
|
if (user) {
|
|
userCache.set(preferredUsername, user);
|
|
logger.debug(`User stored in cache: ${preferredUsername}`);
|
|
return user;
|
|
}
|
|
|
|
logger.warn(`User not found in database: ${preferredUsername}`);
|
|
return null;
|
|
} catch (error) {
|
|
logger.error(`Error looking up user ${preferredUsername}:`, error.message);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Middleware to check if the user is authenticated.
|
|
* Supports: 1) Bearer token (Redis session), 2) Bearer token (email-render JWT for Puppeteer),
|
|
* 3) x-host-id + x-auth-code (host auth)
|
|
*/
|
|
const isAuthenticated = async (req, res, next) => {
|
|
const authHeader = req.headers.authorization || req.headers.Authorization;
|
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
const token = authHeader.substring(7);
|
|
|
|
try {
|
|
const session = await getSession(token);
|
|
if (session && session.expiresAt > Date.now()) {
|
|
req.user = session.user;
|
|
req.session = session;
|
|
return next();
|
|
}
|
|
|
|
// Try email-render JWT (short-lived token for Puppeteer email notifications)
|
|
const user = await lookupUserByToken(token);
|
|
if (user) {
|
|
req.user = user;
|
|
req.session = { user };
|
|
return next();
|
|
}
|
|
} catch (error) {
|
|
logger.error('Session lookup error:', error.message);
|
|
}
|
|
}
|
|
|
|
const hostId = req.headers['x-host-id'];
|
|
const authCode = req.headers['x-auth-code'];
|
|
if (hostId && authCode) {
|
|
const host = await getObject({ model: hostModel, id: hostId });
|
|
if (host && host.authCode === authCode) {
|
|
return next();
|
|
}
|
|
}
|
|
logger.debug('Not authenticated', { hostId, authCode }, 'req.headers', req.headers);
|
|
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
|
|
};
|
|
|
|
const authenticateWithAppPassword = async (username, secret) => {
|
|
if (!username || !secret) return null;
|
|
|
|
const user = await userModel.findOne({ username }).lean();
|
|
if (!user) return null;
|
|
|
|
const appPasswords = await appPasswordModel
|
|
.find({ user: user._id, active: true })
|
|
.select('+secret')
|
|
.lean();
|
|
|
|
for (const appPassword of appPasswords) {
|
|
const storedHash = appPassword.secret;
|
|
if (storedHash && (await bcrypt.compare(secret, storedHash))) {
|
|
return user;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const isAppAuthenticated = async (req, res, next) => {
|
|
const authHeader = req.headers.authorization || req.headers.Authorization;
|
|
const apiKey = req.headers['x-api-key'];
|
|
const userParam = req.params.username;
|
|
const passwordParam = req.params.password;
|
|
|
|
logger.debug('App authentication request', {
|
|
hasBasicAuth: authHeader?.startsWith('Basic ') === true,
|
|
hasApiKey: Boolean(apiKey),
|
|
hasPasswordParam: Boolean(passwordParam),
|
|
userParam,
|
|
});
|
|
|
|
// Supports HTTP Basic Auth (username + app password secret)
|
|
if (authHeader?.startsWith('Basic ')) {
|
|
try {
|
|
logger.debug('Basic auth header present');
|
|
const base64Credentials = authHeader.substring(6);
|
|
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
|
|
const colonIndex = credentials.indexOf(':');
|
|
const username = credentials.substring(0, colonIndex).trim();
|
|
const secret = credentials.substring(colonIndex + 1).trim();
|
|
const user = await authenticateWithAppPassword(username, secret);
|
|
if (user) {
|
|
req.user = user;
|
|
req.session = { user };
|
|
return next();
|
|
}
|
|
} catch (error) {
|
|
logger.error('Basic auth error:', error.message);
|
|
}
|
|
}
|
|
const appPassword = apiKey || passwordParam;
|
|
if (appPassword && userParam) {
|
|
logger.debug('App password and username present', { userParam });
|
|
const user = await authenticateWithAppPassword(userParam, appPassword);
|
|
if (user) {
|
|
req.user = user;
|
|
req.session = { user };
|
|
return next();
|
|
}
|
|
}
|
|
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
|
|
};
|
|
|
|
const isAppQueryAuthenticated = async (req, res, next) => {
|
|
try {
|
|
const username = typeof req.query.u === 'string' ? req.query.u.trim() : '';
|
|
const secret = typeof req.query.p === 'string' ? req.query.p : '';
|
|
const user = await authenticateWithAppPassword(username, secret);
|
|
if (user) {
|
|
req.user = user;
|
|
req.session = { user };
|
|
return next();
|
|
}
|
|
} catch (error) {
|
|
logger.error('Query auth error:', error.message);
|
|
}
|
|
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
|
|
};
|
|
|
|
const clearUserCache = () => {
|
|
userCache.flushAll();
|
|
logger.info('User cache cleared');
|
|
};
|
|
|
|
const getUserCacheStats = () => {
|
|
return userCache.getStats();
|
|
};
|
|
|
|
const removeUserFromCache = (username) => {
|
|
userCache.del(username);
|
|
logger.debug(`User removed from cache: ${username}`);
|
|
};
|
|
|
|
export {
|
|
isAuthenticated,
|
|
isAppAuthenticated,
|
|
isAppQueryAuthenticated,
|
|
lookupUser,
|
|
clearUserCache,
|
|
getUserCacheStats,
|
|
removeUserFromCache,
|
|
getEnvironment,
|
|
};
|