// auth.js - Keycloak authentication handler import axios from 'axios'; import jwt from 'jsonwebtoken'; import log4js from 'log4js'; // Load configuration import { loadConfig } from '../config.js'; import { editObject, getObject, listObjects } from '../database/database.js'; import { hostModel } from '../database/schemas/management/host.schema.js'; import { userModel } from '../database/schemas/management/user.schema.js'; import { generateAuthCode } from '../utils.js'; const config = loadConfig(); const logger = log4js.getLogger('Auth'); logger.level = config.server.logLevel; export class KeycloakAuth { constructor() { this.config = config.auth; this.tokenCache = new Map(); // Cache for verified tokens } // Verify a token with Keycloak server async verifyToken(token) { // Check cache first if (this.tokenCache.has(token)) { const cachedInfo = this.tokenCache.get(token); if (cachedInfo.expiresAt > Date.now()) { return { valid: true, user: cachedInfo.user }; } else { // Token expired, remove from cache this.tokenCache.delete(token); } } try { // Verify token with Keycloak introspection endpoint const response = await axios.post( `${this.config.keycloak.url}/realms/${this.config.keycloak.realm}/protocol/openid-connect/token/introspect`, new URLSearchParams({ token, client_id: this.config.keycloak.clientId, client_secret: this.config.keycloak.clientSecret }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } ); const introspection = response.data; if (!introspection.active) { logger.info('Token is not active'); return { valid: false }; } // Verify required roles if configured if (this.config.requiredRoles && this.config.requiredRoles.length > 0) { const hasRequiredRole = this.checkRoles( introspection, this.config.requiredRoles ); if (!hasRequiredRole) { logger.info("User doesn't have required roles"); return { valid: false }; } } // Parse token to extract user info const decodedToken = jwt.decode(token); const decodedUser = { id: decodedToken.sub, username: decodedToken.preferred_username, email: decodedToken.email, name: decodedToken.name, roles: this.extractRoles(decodedToken) }; const user = await listObjects({ model: userModel, filter: { username: decodedUser.username } }); // Cache the verified token const expiresAt = introspection.exp * 1000; // Convert to milliseconds this.tokenCache.set(token, { expiresAt, user: user[0] }); return { valid: true, user: user[0] }; } catch (error) { logger.error('Token verification error:', error.message); return { valid: false }; } } // Extract roles from token extractRoles(token) { const roles = []; // Extract realm roles if (token.realm_access && token.realm_access.roles) { roles.push(...token.realm_access.roles); } // Extract client roles if (token.resource_access) { for (const client in token.resource_access) { if (token.resource_access[client].roles) { roles.push( ...token.resource_access[client].roles.map( role => `${client}:${role}` ) ); } } } return roles; } // Check if user has required roles checkRoles(tokenInfo, requiredRoles) { // Extract roles from token const userRoles = this.extractRoles(tokenInfo); // Check if user has any of the required roles return requiredRoles.some(role => userRoles.includes(role)); } } export class CodeAuth { // Verify a code with the database async verifyCode(id, authCode) { try { logger.trace('Verifying code:', { id, authCode }); const host = await getObject({ model: hostModel, id, cached: true }); logger.trace('Host retrieved:', host); if (host == undefined) { const error = 'Host not found.'; logger.warn(error, 'Host:', id); return { valid: false, error: error }; } if (host.active == false) { const error = 'Host not active.'; logger.warn(error, 'Host:', id); return { valid: false, error: error }; } if (host.authCode == undefined || host.authCode == '') { const error = 'No authCode on database.'; logger.warn(error, 'Host:', id); return { valid: false, error: error }; } if (host.authCode != authCode) { const error = 'authCode does not match.'; logger.warn(error, 'Host:', id); return { valid: false, error: error }; } logger.trace('Code verification successful:', host); return { valid: true, host: host }; } catch (error) { logger.error('Code verification error:', error.message); return { valid: false }; } } async verifyOtp(otp) { try { const hosts = await listObjects({ model: hostModel, filter: { otp: otp }, cached: false }); const host = hosts[0]; if (host == undefined) { const error = `No host found with OTP: ${otp}`; logger.warn(error); return { valid: false, error: error }; } const id = host._id.toString(); if (host.active == false) { const error = 'Host is not active.'; logger.warn(error, 'Host:', id); return { valid: false, error: error }; } if (host.otp == undefined) { const error = 'No OTP on database.'; logger.warn(error, 'Host:', id); return { valid: false, error: error }; } if (host.otpExpiresAt == undefined) { const error = 'No OTP expiry.'; logger.warn(error, 'Host:', id); return { valid: false, error: error }; } if (host.otpExpiresAt < Date.now()) { const error = 'OTP expired.'; logger.warn(error, 'Host:', id); return { valid: false, error: error }; } const authCodeHost = await editObject({ model: hostModel, id: id, updateData: { authCode: generateAuthCode() } }); logger.info('Host found with OTP:', otp); return { valid: true, host: authCodeHost }; } catch (error) { logger.error('OTP verification error:', error.message); return { valid: false, error: error.message }; } } } // Socket.IO middleware for authentication export function createAuthMiddleware(socketUser) { return async (packet, next) => { const [event] = packet; // event name is always first element // Allow the 'authenticate' event through without checks if (event === 'authenticate') { next(); return; } if (socketUser.authenticated) { next(); return; } return next(new Error('Authentication is required.')); }; }