farmcontrol-api/src/keycloak.js

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,
};