Compare commits

..

2 Commits

Author SHA1 Message Date
289673813a Implemented redis session storage.
All checks were successful
farmcontrol/farmcontrol-ws/pipeline/head This commit looks good
2026-03-01 17:06:17 +00:00
840aa0781b Updated models. 2026-03-01 16:55:15 +00:00
3 changed files with 232 additions and 124 deletions

View File

@ -17,7 +17,11 @@
"mongo": { "mongo": {
"url": "mongodb://127.0.0.1:27017/farmcontrol" "url": "mongodb://127.0.0.1:27017/farmcontrol"
}, },
"redis": { "host": "localhost", "port": 6379, "password": "" } "redis": {
"host": "localhost",
"port": 6379,
"password": ""
}
}, },
"otpExpiryMins": 0.5 "otpExpiryMins": 0.5
}, },
@ -39,7 +43,11 @@
"mongo": { "mongo": {
"url": "mongodb://127.0.0.1:27017/farmcontrol-test" "url": "mongodb://127.0.0.1:27017/farmcontrol-test"
}, },
"redis": { "host": "localhost", "port": 6379, "password": "" } "redis": {
"host": "localhost",
"port": 6379,
"password": ""
}
}, },
"otpExpiryMins": 0.5 "otpExpiryMins": 0.5
}, },
@ -60,6 +68,11 @@
"database": { "database": {
"mongo": { "mongo": {
"url": "mongodb://localhost:27017/farmcontrol" "url": "mongodb://localhost:27017/farmcontrol"
},
"redis": {
"host": "localhost",
"port": 6379,
"password": ""
} }
} }
} }

View File

@ -1,9 +1,7 @@
// auth.js - Keycloak authentication handler // auth.js - Redis session authentication (shared with API)
import axios from 'axios';
import jwt from 'jsonwebtoken';
import log4js from 'log4js'; import log4js from 'log4js';
// Load configuration
import { loadConfig } from '../config.js'; import { loadConfig } from '../config.js';
import { redisServer } from '../database/redis.js';
import { editObject, getObject, listObjects } from '../database/database.js'; import { editObject, getObject, listObjects } from '../database/database.js';
import { hostModel } from '../database/schemas/management/host.schema.js'; import { hostModel } from '../database/schemas/management/host.schema.js';
import { userModel } from '../database/schemas/management/user.schema.js'; import { userModel } from '../database/schemas/management/user.schema.js';
@ -14,123 +12,69 @@ const config = loadConfig();
const logger = log4js.getLogger('Auth'); const logger = log4js.getLogger('Auth');
logger.level = config.server.logLevel; logger.level = config.server.logLevel;
export class KeycloakAuth { const SESSION_KEY_PREFIX = 'session:';
/**
* SessionAuth - validates tokens by looking up Redis session (created by API after Keycloak login).
* Same session key format as API: session:{sessionToken}
*/
export class SessionAuth {
constructor() { constructor() {
this.config = config.auth; this.config = config.auth;
this.tokenCache = new Map(); // Cache for verified tokens this.tokenCache = new Map();
} }
// Verify a token with Keycloak server
async verifyToken(token) { async verifyToken(token) {
// Check cache first if (!token) return { valid: false };
if (this.tokenCache.has(token)) { if (this.tokenCache.has(token)) {
const cachedInfo = this.tokenCache.get(token); const cached = this.tokenCache.get(token);
if (cachedInfo.expiresAt > Date.now()) { if (cached.expiresAt > Date.now()) {
return { valid: true, user: cachedInfo.user }; return { valid: true, user: cached.user };
} else {
// Token expired, remove from cache
this.tokenCache.delete(token);
} }
this.tokenCache.delete(token);
} }
try { try {
// Verify token with Keycloak introspection endpoint const key = SESSION_KEY_PREFIX + token;
const response = await axios.post( const session = await redisServer.getKey(key);
`${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 (!session || !session.user) {
logger.info('Session not found or invalid');
if (!introspection.active) { return { valid: false };
logger.info('Token is not active'); }
if (session.expiresAt && session.expiresAt <= Date.now()) {
logger.info('Session expired');
return { valid: false }; return { valid: false };
} }
// Verify required roles if configured
if (this.config.requiredRoles && this.config.requiredRoles.length > 0) { if (this.config.requiredRoles && this.config.requiredRoles.length > 0) {
const hasRequiredRole = this.checkRoles( const roles = session.user?.roles || [];
introspection, const hasRole = this.config.requiredRoles.some((r) => roles.includes(r));
this.config.requiredRoles if (!hasRole) {
);
if (!hasRequiredRole) {
logger.info("User doesn't have required roles"); logger.info("User doesn't have required roles");
return { valid: false }; return { valid: false };
} }
} }
// Parse token to extract user info this.tokenCache.set(token, {
const decodedToken = jwt.decode(token); expiresAt: session.expiresAt,
const decodedUser = { user: session.user
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 return { valid: true, user: session.user };
const expiresAt = introspection.exp * 1000; // Convert to milliseconds
this.tokenCache.set(token, { expiresAt, user: user[0] });
return { valid: true, user: user[0] };
} catch (error) { } catch (error) {
logger.error('Token verification error:', error.message); logger.error('Session verification error:', error.message);
return { valid: false }; 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 /** @deprecated Use SessionAuth - kept for backward compatibility */
if (token.resource_access) { export class KeycloakAuth extends SessionAuth {}
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 { export class CodeAuth {
// Verify a code with the database
async verifyCode(id, authCode) { async verifyCode(id, authCode) {
try { try {
logger.trace('Verifying code:', { id, authCode }); logger.trace('Verifying code:', { id, authCode });
@ -212,12 +156,9 @@ export class CodeAuth {
} }
} }
// Socket.IO middleware for authentication
export function createAuthMiddleware(socketUser) { export function createAuthMiddleware(socketUser) {
return async (packet, next) => { return async (packet, next) => {
const [event] = packet; // event name is always first element const [event] = packet;
// Allow the 'authenticate' event through without checks
if (event === 'authenticate') { if (event === 'authenticate') {
next(); next();

View File

@ -35,89 +35,243 @@ import { salesOrderModel } from './sales/salesorder.schema.js';
// Map prefixes to models and id fields // Map prefixes to models and id fields
export const models = { export const models = {
PRN: { model: printerModel, idField: '_id', type: 'printer', referenceField: '_reference' }, PRN: {
FIL: { model: filamentModel, idField: '_id', type: 'filament', referenceField: '_reference' }, model: printerModel,
GCF: { model: gcodeFileModel, idField: '_id', type: 'gcodeFile', referenceField: '_reference' }, idField: '_id',
JOB: { model: jobModel, idField: '_id', type: 'job', referenceField: '_reference' }, type: 'printer',
PRT: { model: partModel, idField: '_id', type: 'part', referenceField: '_reference' }, referenceField: '_reference',
PRD: { model: productModel, idField: '_id', type: 'product', referenceField: '_reference' }, label: 'Printer',
VEN: { model: vendorModel, idField: '_id', type: 'vendor', referenceField: '_reference' }, },
SJB: { model: subJobModel, idField: '_id', type: 'subJob', referenceField: '_reference' }, FIL: {
model: filamentModel,
idField: '_id',
type: 'filament',
referenceField: '_reference',
label: 'Filament',
},
GCF: {
model: gcodeFileModel,
idField: '_id',
type: 'gcodeFile',
referenceField: '_reference',
label: 'G-Code File',
},
JOB: { model: jobModel, idField: '_id', type: 'job', referenceField: '_reference', label: 'Job' },
PRT: {
model: partModel,
idField: '_id',
type: 'part',
referenceField: '_reference',
label: 'Part',
},
PRD: {
model: productModel,
idField: '_id',
type: 'product',
referenceField: '_reference',
label: 'Product',
},
VEN: {
model: vendorModel,
idField: '_id',
type: 'vendor',
referenceField: '_reference',
label: 'Vendor',
},
SJB: {
model: subJobModel,
idField: '_id',
type: 'subJob',
referenceField: '_reference',
label: 'Sub Job',
},
FLS: { FLS: {
model: filamentStockModel, model: filamentStockModel,
idField: '_id', idField: '_id',
type: 'filamentStock', type: 'filamentStock',
referenceField: '_reference', referenceField: '_reference',
label: 'Filament Stock',
},
SEV: {
model: stockEventModel,
idField: '_id',
type: 'stockEvent',
referenceField: '_reference',
label: 'Stock Event',
},
SAU: {
model: stockAuditModel,
idField: '_id',
type: 'stockAudit',
referenceField: '_reference',
label: 'Stock Audit',
},
PTS: {
model: partStockModel,
idField: '_id',
type: 'partStock',
referenceField: '_reference',
label: 'Part Stock',
},
PDS: {
model: null,
idField: '_id',
type: 'productStock',
referenceField: '_reference',
label: 'Product Stock',
}, // No productStockModel found
ADL: {
model: auditLogModel,
idField: '_id',
type: 'auditLog',
referenceField: '_reference',
label: 'Audit Log',
},
USR: {
model: userModel,
idField: '_id',
type: 'user',
referenceField: '_reference',
label: 'User',
},
NTY: {
model: noteTypeModel,
idField: '_id',
type: 'noteType',
referenceField: '_reference',
label: 'Note Type',
},
NTE: {
model: noteModel,
idField: '_id',
type: 'note',
referenceField: '_reference',
label: 'Note',
}, },
SEV: { model: stockEventModel, idField: '_id', type: 'stockEvent', referenceField: '_reference' },
SAU: { model: stockAuditModel, idField: '_id', type: 'stockAudit', referenceField: '_reference' },
PTS: { model: partStockModel, idField: '_id', type: 'partStock', referenceField: '_reference' },
PDS: { model: null, idField: '_id', type: 'productStock', referenceField: '_reference' }, // No productStockModel found
ADL: { model: auditLogModel, idField: '_id', type: 'auditLog', referenceField: '_reference' },
USR: { model: userModel, idField: '_id', type: 'user', referenceField: '_reference' },
NTY: { model: noteTypeModel, idField: '_id', type: 'noteType', referenceField: '_reference' },
NTE: { model: noteModel, idField: '_id', type: 'note', referenceField: '_reference' },
NTF: { NTF: {
model: notificationModel, model: notificationModel,
idField: '_id', idField: '_id',
type: 'notification', type: 'notification',
label: 'Notification',
referenceField: '_reference', referenceField: '_reference',
}, },
ONF: { ONF: {
model: userNotifierModel, model: userNotifierModel,
idField: '_id', idField: '_id',
type: 'userNotifier', type: 'userNotifier',
label: 'User Notifier',
referenceField: '_reference', referenceField: '_reference',
}, },
DSZ: { DSZ: {
model: documentSizeModel, model: documentSizeModel,
idField: '_id', idField: '_id',
type: 'documentSize', type: 'documentSize',
label: 'Document Size',
referenceField: '_reference', referenceField: '_reference',
}, },
DTP: { DTP: {
model: documentTemplateModel, model: documentTemplateModel,
idField: '_id', idField: '_id',
type: 'documentTemplate', type: 'documentTemplate',
label: 'Document Template',
referenceField: '_reference', referenceField: '_reference',
}, },
DPR: { DPR: {
model: documentPrinterModel, model: documentPrinterModel,
idField: '_id', idField: '_id',
type: 'documentPrinter', type: 'documentPrinter',
label: 'Document Printer',
referenceField: '_reference', referenceField: '_reference',
}, },
DJB: { DJB: {
model: documentJobModel, model: documentJobModel,
idField: '_id', idField: '_id',
type: 'documentJob', type: 'documentJob',
label: 'Document Job',
referenceField: '_reference', referenceField: '_reference',
}, },
HST: { model: hostModel, idField: '_id', type: 'host', referenceField: '_reference' }, HST: {
FLE: { model: fileModel, idField: '_id', type: 'file', referenceField: '_reference' }, model: hostModel,
idField: '_id',
type: 'host',
referenceField: '_reference',
label: 'Host',
},
FLE: {
model: fileModel,
idField: '_id',
type: 'file',
referenceField: '_reference',
label: 'File',
},
POR: { POR: {
model: purchaseOrderModel, model: purchaseOrderModel,
idField: '_id', idField: '_id',
type: 'purchaseOrder', type: 'purchaseOrder',
label: 'Purchase Order',
referenceField: '_reference', referenceField: '_reference',
}, },
ODI: { ODI: {
model: orderItemModel, model: orderItemModel,
idField: '_id', idField: '_id',
type: 'orderItem', type: 'orderItem',
label: 'Order Item',
referenceField: '_reference', referenceField: '_reference',
}, },
COS: { COS: {
model: courierServiceModel, model: courierServiceModel,
idField: '_id', idField: '_id',
type: 'courierService', type: 'courierService',
label: 'Courier Service',
referenceField: '_reference',
},
COR: {
model: courierModel,
idField: '_id',
type: 'courier',
label: 'Courier',
referenceField: '_reference',
},
TXR: {
model: taxRateModel,
idField: '_id',
type: 'taxRate',
label: 'Tax Rate',
referenceField: '_reference',
},
TXD: {
model: taxRecordModel,
idField: '_id',
type: 'taxRecord',
label: 'Tax Record',
referenceField: '_reference',
},
SHP: {
model: shipmentModel,
idField: '_id',
type: 'shipment',
label: 'Shipment',
referenceField: '_reference',
},
INV: {
model: invoiceModel,
idField: '_id',
type: 'invoice',
label: 'Invoice',
referenceField: '_reference',
},
CLI: {
model: clientModel,
idField: '_id',
type: 'client',
label: 'Client',
referenceField: '_reference',
},
SOR: {
model: salesOrderModel,
idField: '_id',
type: 'salesOrder',
label: 'Sales Order',
referenceField: '_reference', referenceField: '_reference',
}, },
COR: { model: courierModel, idField: '_id', type: 'courier', referenceField: '_reference' },
TXR: { model: taxRateModel, idField: '_id', type: 'taxRate', referenceField: '_reference' },
TXD: { model: taxRecordModel, idField: '_id', type: 'taxRecord', referenceField: '_reference' },
SHP: { model: shipmentModel, idField: '_id', type: 'shipment', referenceField: '_reference' },
INV: { model: invoiceModel, idField: '_id', type: 'invoice', referenceField: '_reference' },
CLI: { model: clientModel, idField: '_id', type: 'client', referenceField: '_reference' },
SOR: { model: salesOrderModel, idField: '_id', type: 'salesOrder', referenceField: '_reference' },
}; };