farmcontrol-api/src/keycloak.js

138 lines
4.3 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 { 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) HTTP Basic Auth (username + app password), 4) 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);
}
}
// Try HTTP Basic Auth (username + app password)
if (authHeader && authHeader.startsWith('Basic ')) {
try {
logger.debug('Basic auth header:', authHeader);
const base64Credentials = authHeader.substring(6);
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
const [username, password] = credentials.split(':');
logger.debug('Basic auth credentials:', { username, password });
if (username && password) {
const user = await userModel.findOne({ username }).select('+appPasswordHash');
if (user?.appPasswordHash && (await bcrypt.compare(password, user.appPasswordHash))) {
user.appPasswordHash = undefined; // don't expose hash downstream
req.user = user;
req.session = { user };
return next();
}
}
} catch (error) {
logger.error('Basic auth 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 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,
lookupUser,
clearUserCache,
getUserCacheStats,
removeUserFromCache,
getEnvironment,
};