2025-12-13 21:07:18 +00:00

234 lines
6.9 KiB
JavaScript

// 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.'));
};
}