diff --git a/package.json b/package.json index efb631e..8418777 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "scripts": { "syncModelsWithWS": "node fcdev.js", "watch:schemas": "nodemon --config nodemon.schemas.json", - "dev": "concurrently --names \"API,SCHEMAS\" --prefix-colors \"cyan,yellow\" \"nodemon --exec babel-node --experimental-specifier-resolution=node src/index.js\" \"nodemon --config nodemon.schemas.json\"", + "dev": "concurrently --kill-others --names \"API,SCHEMAS\" --prefix-colors \"cyan,yellow\" \"nodemon --exec babel-node --experimental-specifier-resolution=node src/index.js\" \"nodemon --config nodemon.schemas.json\"", "dev:api": "nodemon --exec babel-node --experimental-specifier-resolution=node src/index.js", "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js", "seed": "node src/mongo/seedData.js", diff --git a/src/database/odata.js b/src/database/odata.js index 6164e6e..59e5f27 100644 --- a/src/database/odata.js +++ b/src/database/odata.js @@ -6,7 +6,7 @@ import { listObjects } from './database.js'; const logger = log4js.getLogger('DatabaseOData'); logger.level = config.server.logLevel; -const EXCLUDED_PATHS = ['appPasswordHash', '__v']; +const EXCLUDED_PATHS = ['appPasswordHash', 'secret', '__v']; /** Check if path is an embedded object (has nested schema, not ObjectId ref). Excludes arrays. */ function isEmbeddedObject(path) { diff --git a/src/database/schemas/management/apppassword.schema.js b/src/database/schemas/management/apppassword.schema.js new file mode 100644 index 0000000..bcc4f21 --- /dev/null +++ b/src/database/schemas/management/apppassword.schema.js @@ -0,0 +1,22 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; +const { Schema } = mongoose; + +const appPasswordSchema = new mongoose.Schema( + { + _reference: { type: String, default: () => generateId()() }, + name: { type: String, required: true }, + user: { type: Schema.Types.ObjectId, ref: 'user', required: true }, + active: { type: Boolean, required: true, default: true }, + secret: { type: String, required: true, select: false }, + }, + { timestamps: true } +); + +appPasswordSchema.virtual('id').get(function () { + return this._id; +}); + +appPasswordSchema.set('toJSON', { virtuals: true }); + +export const appPasswordModel = mongoose.model('appPassword', appPasswordSchema); diff --git a/src/database/schemas/models.js b/src/database/schemas/models.js index e1cb4f5..c647630 100644 --- a/src/database/schemas/models.js +++ b/src/database/schemas/models.js @@ -14,6 +14,7 @@ import { stockAuditModel } from './inventory/stockaudit.schema.js'; import { partStockModel } from './inventory/partstock.schema.js'; import { auditLogModel } from './management/auditlog.schema.js'; import { userModel } from './management/user.schema.js'; +import { appPasswordModel } from './management/apppassword.schema.js'; import { noteTypeModel } from './management/notetype.schema.js'; import { noteModel } from './misc/note.schema.js'; import { notificationModel } from './misc/notification.schema.js'; @@ -134,6 +135,13 @@ export const models = { referenceField: '_reference', label: 'User', }, + APP: { + model: appPasswordModel, + idField: '_id', + type: 'appPassword', + referenceField: '_reference', + label: 'App Password', + }, NTY: { model: noteTypeModel, idField: '_id', diff --git a/src/index.js b/src/index.js index efba8ab..603c9d5 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,7 @@ import { dbConnect } from './database/mongo.js'; import { authRoutes, userRoutes, + appPasswordRoutes, fileRoutes, printerRoutes, jobRoutes, @@ -114,6 +115,7 @@ app.get('/', function (req, res) { app.use('/auth', authRoutes); app.use('/users', userRoutes); +app.use('/apppasswords', appPasswordRoutes); app.use('/files', fileRoutes); app.use('/spotlight', spotlightRoutes); app.use('/printers', printerRoutes); diff --git a/src/keycloak.js b/src/keycloak.js index 49edd4a..ad7787c 100644 --- a/src/keycloak.js +++ b/src/keycloak.js @@ -7,6 +7,7 @@ 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'; @@ -79,28 +80,6 @@ const isAuthenticated = async (req, res, next) => { } } - // 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) { @@ -113,6 +92,41 @@ const isAuthenticated = async (req, res, next) => { return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' }); }; +const isAppAuthenticated = async (req, res, next) => { + const authHeader = req.headers.authorization || req.headers.Authorization; + // Try 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(); + if (username && secret) { + const user = await userModel.findOne({ username }); + if (user) { + 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))) { + req.user = user; + req.session = { user }; + return next(); + } + } + } + } + } catch (error) { + logger.error('Basic auth error:', error.message); + } + } + return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' }); +}; + const clearUserCache = () => { userCache.flushAll(); logger.info('User cache cleared'); @@ -129,6 +143,7 @@ const removeUserFromCache = (username) => { export { isAuthenticated, + isAppAuthenticated, lookupUser, clearUserCache, getUserCacheStats, diff --git a/src/routes/index.js b/src/routes/index.js index 73b4ac2..41ba973 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -1,4 +1,5 @@ import userRoutes from './management/users.js'; +import appPasswordRoutes from './management/apppasswords.js'; import fileRoutes from './management/files.js'; import authRoutes from './misc/auth.js'; import printerRoutes from './production/printers.js'; @@ -40,6 +41,7 @@ import odataRoutes from './misc/odata.js'; export { userRoutes, + appPasswordRoutes, fileRoutes, authRoutes, printerRoutes, diff --git a/src/routes/management/apppasswords.js b/src/routes/management/apppasswords.js new file mode 100644 index 0000000..3f7e796 --- /dev/null +++ b/src/routes/management/apppasswords.js @@ -0,0 +1,61 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { getFilter, convertPropertiesString } from '../../utils.js'; + +const router = express.Router(); +import { + listAppPasswordsRouteHandler, + getAppPasswordRouteHandler, + editAppPasswordRouteHandler, + newAppPasswordRouteHandler, + deleteAppPasswordRouteHandler, + listAppPasswordsByPropertiesRouteHandler, + getAppPasswordStatsRouteHandler, + getAppPasswordHistoryRouteHandler, + regenerateSecretRouteHandler, +} from '../../services/management/apppasswords.js'; + +router.get('/', isAuthenticated, (req, res) => { + const { page, limit, property, search, sort, order } = req.query; + const allowedFilters = ['_id', 'name', 'user', 'active', 'user._id']; + const filter = getFilter(req.query, allowedFilters); + listAppPasswordsRouteHandler(req, res, page, limit, property, filter, search, sort, order); +}); + +router.get('/properties', isAuthenticated, (req, res) => { + const properties = convertPropertiesString(req.query.properties); + const allowedFilters = ['name', 'user', 'active']; + const filter = getFilter(req.query, allowedFilters, false); + const masterFilter = req.query.masterFilter ? JSON.parse(req.query.masterFilter) : {}; + listAppPasswordsByPropertiesRouteHandler(req, res, properties, filter, masterFilter); +}); + +router.post('/', isAuthenticated, (req, res) => { + newAppPasswordRouteHandler(req, res); +}); + +router.get('/stats', isAuthenticated, (req, res) => { + getAppPasswordStatsRouteHandler(req, res); +}); + +router.get('/history', isAuthenticated, (req, res) => { + getAppPasswordHistoryRouteHandler(req, res); +}); + +router.post('/:id/regenerateSecret', isAuthenticated, async (req, res) => { + regenerateSecretRouteHandler(req, res); +}); + +router.get('/:id', isAuthenticated, (req, res) => { + getAppPasswordRouteHandler(req, res); +}); + +router.put('/:id', isAuthenticated, async (req, res) => { + editAppPasswordRouteHandler(req, res); +}); + +router.delete('/:id', isAuthenticated, async (req, res) => { + deleteAppPasswordRouteHandler(req, res); +}); + +export default router; diff --git a/src/routes/misc/odata.js b/src/routes/misc/odata.js index 6511089..4a47057 100644 --- a/src/routes/misc/odata.js +++ b/src/routes/misc/odata.js @@ -1,10 +1,10 @@ import express from 'express'; -import { isAuthenticated } from '../../keycloak.js'; +import { isAppAuthenticated } from '../../keycloak.js'; import { listODataRouteHandler, metadataODataRouteHandler } from '../../services/misc/odata.js'; const router = express.Router(); -router.get('/$metadata', isAuthenticated, metadataODataRouteHandler); -router.get('/:objectType', isAuthenticated, listODataRouteHandler); +router.get('/$metadata', isAppAuthenticated, metadataODataRouteHandler); +router.get('/:objectType', isAppAuthenticated, listODataRouteHandler); export default router; diff --git a/src/services/management/apppasswords.js b/src/services/management/apppasswords.js new file mode 100644 index 0000000..8e1690f --- /dev/null +++ b/src/services/management/apppasswords.js @@ -0,0 +1,229 @@ +import config from '../../config.js'; +import { appPasswordModel } from '../../database/schemas/management/apppassword.schema.js'; +import log4js from 'log4js'; +import mongoose from 'mongoose'; +import bcrypt from 'bcrypt'; +import { nanoid } from 'nanoid'; +import { + listObjects, + listObjectsByProperties, + getObject, + editObject, + newObject, + deleteObject, + getModelStats, + getModelHistory, +} from '../../database/database.js'; + +const logger = log4js.getLogger('AppPasswords'); +logger.level = config.server.logLevel; + +export const listAppPasswordsRouteHandler = async ( + req, + res, + page = 1, + limit = 25, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend' +) => { + const result = await listObjects({ + model: appPasswordModel, + page, + limit, + property, + filter, + search, + sort, + order, + populate: ['user'], + }); + + if (result?.error) { + logger.error('Error listing app passwords.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of app passwords (Page ${page}, Limit ${limit}). Count: ${result.length}`); + res.send(result); +}; + +export const listAppPasswordsByPropertiesRouteHandler = async ( + req, + res, + properties = '', + filter = {}, + masterFilter = {} +) => { + const result = await listObjectsByProperties({ + model: appPasswordModel, + properties, + filter, + masterFilter, + populate: ['user'], + }); + + if (result?.error) { + logger.error('Error listing app passwords.'); + res.status(result.code).send(result); + return; + } + result.forEach((item) => { + item.secret = undefined; + }); + + logger.debug(`List of app passwords. Count: ${result.length}`); + res.send(result); +}; + +export const getAppPasswordRouteHandler = async (req, res) => { + const id = req.params.id; + const result = await getObject({ + model: appPasswordModel, + id, + populate: ['user'], + }); + if (result?.error) { + logger.warn(`App password not found with supplied id.`); + return res.status(result.code).send(result); + } + logger.debug(`Retrieved app password with ID: ${id}`); + res.send({ ...result, secret: undefined }); +}; + +export const editAppPasswordRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`App password with ID: ${id}`); + + const updateData = { + updatedAt: new Date(), + name: req.body.name, + active: req.body.active, + }; + + if (req.body.secret) { + updateData.secret = await bcrypt.hash(req.body.secret, 10); + } + + const result = await editObject({ + model: appPasswordModel, + id, + updateData, + user: req.user, + }); + + if (result?.error) { + logger.error('Error editing app password:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Edited app password with ID: ${id}`); + res.send(result); +}; + +export const newAppPasswordRouteHandler = async (req, res) => { + const plainSecret = req.body.secret?.trim() || nanoid(32); + const secretHash = await bcrypt.hash(plainSecret, 10); + + const newData = { + name: req.body.name, + user: req.body.user, + active: req.body.active ?? true, + secret: secretHash, + }; + + const result = await newObject({ + model: appPasswordModel, + newData, + user: req.user, + }); + + if (result?.error) { + logger.error('No app password created:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`New app password with ID: ${result._id}`); + res.send(result); +}; + +export const deleteAppPasswordRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`App password with ID: ${id}`); + + const result = await deleteObject({ + model: appPasswordModel, + id, + user: req.user, + }); + + if (result?.error) { + logger.error('No app password deleted:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Deleted app password with ID: ${id}`); + res.send(result); +}; + +export const getAppPasswordStatsRouteHandler = async (req, res) => { + const result = await getModelStats({ model: appPasswordModel }); + if (result?.error) { + logger.error('Error fetching app password stats:', result.error); + return res.status(result.code).send(result); + } + logger.trace('App password stats:', result); + res.send(result); +}; + +export const getAppPasswordHistoryRouteHandler = async (req, res) => { + const from = req.query.from; + const to = req.query.to; + const result = await getModelHistory({ model: appPasswordModel, from, to }); + if (result?.error) { + logger.error('Error fetching app password history:', result.error); + return res.status(result.code).send(result); + } + logger.trace('App password history:', result); + res.send(result); +}; + +export const regenerateSecretRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + const appPasswordDoc = await appPasswordModel.findById(id).select('+secret').populate('user'); + if (!appPasswordDoc) { + return res.status(404).send({ error: 'App password not found.' }); + } + + const appPasswordUserId = + appPasswordDoc.user?._id?.toString?.() ?? appPasswordDoc.user?.toString?.(); + if (appPasswordUserId !== req.user._id.toString()) { + return res.status(403).send({ + error: 'You are not authorized to regenerate the secret for this app password.', + }); + } + + const plainSecret = nanoid(32); + const secretHash = await bcrypt.hash(plainSecret, 10); + + const result = await editObject({ + model: appPasswordModel, + id, + updateData: { secret: secretHash, updatedAt: new Date() }, + user: req.user, + }); + + if (result?.error) { + logger.error('Error regenerating app password secret:', result.error); + return res.status(result.code || 500).send(result); + } + + logger.debug(`Regenerated secret for app password with ID: ${id}`); + res.send({ appPassword: plainSecret }); +}; diff --git a/src/services/misc/odata.js b/src/services/misc/odata.js index 75a45b9..3717516 100644 --- a/src/services/misc/odata.js +++ b/src/services/misc/odata.js @@ -8,7 +8,7 @@ import { getFilter } from '../../utils.js'; const logger = log4js.getLogger('OData'); logger.level = config.server.logLevel; -const EXCLUDED_PATHS = ['appPasswordHash', '__v']; +const EXCLUDED_PATHS = ['appPasswordHash', 'secret', '__v']; function mongooseTypeToEdm(path) { const instance = path?.instance; @@ -354,6 +354,7 @@ function getModelFilterFields(objectType) { salesOrder: ['client'], invoice: ['to._id', 'from._id', 'order._id', 'orderType'], auditLog: ['parent._id', 'parentType', 'owner._id', 'ownerType'], + appPassword: ['name', 'user', 'active'], }; const extra = byType[objectType] || []; return [...base, ...extra]; diff --git a/src/utils.js b/src/utils.js index 26ca1d9..e1a1e7d 100644 --- a/src/utils.js +++ b/src/utils.js @@ -361,12 +361,24 @@ function getChangedValues(oldObj, newObj, old = false) { } const AUDIT_EXCLUDED_MODELS = ['notification', 'userNotifier']; +const SENSITIVE_KEYS = ['secret']; + +function omitSensitive(obj) { + if (obj == null || typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map(omitSensitive); + const result = {}; + for (const [key, value] of Object.entries(obj)) { + if (SENSITIVE_KEYS.includes(key)) continue; + result[key] = omitSensitive(value); + } + return result; +} async function newAuditLog(newValue, parentId, parentType, user) { if (AUDIT_EXCLUDED_MODELS.includes(parentType)) return; - // Filter out createdAt and updatedAt from newValue - const filteredNewValue = { ...newValue }; + // Filter out createdAt, updatedAt, and sensitive fields from newValue + const filteredNewValue = omitSensitive({ ...newValue }); delete filteredNewValue.createdAt; delete filteredNewValue.updatedAt; const auditLog = new auditLogModel({ @@ -389,8 +401,8 @@ async function editAuditLog(oldValue, newValue, parentId, parentType, user) { if (AUDIT_EXCLUDED_MODELS.includes(parentType)) return; // Get only the changed values - const changedOldValues = getChangedValues(oldValue, newValue, true); - const changedNewValues = getChangedValues(oldValue, newValue, false); + const changedOldValues = omitSensitive(getChangedValues(oldValue, newValue, true)); + const changedNewValues = omitSensitive(getChangedValues(oldValue, newValue, false)); // If no values changed, don't create an audit log if (Object.keys(changedOldValues).length === 0 || Object.keys(changedNewValues).length === 0) { @@ -417,8 +429,8 @@ async function editAuditLog(oldValue, newValue, parentId, parentType, user) { async function editNotification(oldValue, newValue, parentId, parentType, user) { const model = getModelByName(parentType); const objectName = oldValue?.name ?? newValue?.name ?? model?.label ?? parentType; - const changedOldValues = getChangedValues(oldValue, newValue, true); - const changedNewValues = getChangedValues(oldValue, newValue, false); + const changedOldValues = omitSensitive(getChangedValues(oldValue, newValue, true)); + const changedNewValues = omitSensitive(getChangedValues(oldValue, newValue, false)); if (Object.keys(changedOldValues).length === 0 || Object.keys(changedNewValues).length === 0) { return; @@ -449,7 +461,7 @@ async function deleteAuditLog(deleteValue, parentId, parentType, user) { const auditLog = new auditLogModel({ changes: { - old: deleteValue, + old: omitSensitive(deleteValue), }, parent: parentId, parentType, @@ -473,7 +485,7 @@ async function deleteNotification(object, parentId, parentType, user) { `The ${parentType} ${parentId} has been deleted.`, 'deleteObject', { - object: object, + object: omitSensitive(object), objectType: parentType, object: { _id: parentId }, user: { _id: user._id, firstName: user.firstName, lastName: user.lastName }, @@ -599,7 +611,7 @@ async function createNotification(user, title, message, type = 'info', metadata) title, message, type, - metadata, + metadata: omitSensitive(metadata ?? {}), }); await notification.save(); const value = notification.toJSON ? notification.toJSON() : notification; @@ -658,7 +670,7 @@ async function sendEmailNotification(user, title, message, type = 'info', metada title, message, type, - metadata: metadata || {}, + metadata: omitSensitive(metadata || {}), createdAt: new Date(), updatedAt: new Date(), authCode,