diff --git a/create-a-container/middlewares/index.js b/create-a-container/middlewares/index.js index cd9f5bac..971bd169 100644 --- a/create-a-container/middlewares/index.js +++ b/create-a-container/middlewares/index.js @@ -7,8 +7,56 @@ function isApiRequest(req) { // Authentication middleware (single) --- // Detect API requests and browser requests. API requests return 401 JSON, browser requests redirect to /login. -function requireAuth(req, res, next) { +// Also accepts API key authentication via Authorization header. +async function requireAuth(req, res, next) { + // First check session authentication if (req.session && req.session.user) return next(); + + // Try API key authentication + const authHeader = req.get('Authorization'); + if (authHeader && authHeader.startsWith('Bearer ')) { + const apiKey = authHeader.substring(7); + + if (apiKey) { + const { ApiKey, User } = require('../models'); + const { extractKeyPrefix } = require('../utils/apikey'); + + const keyPrefix = extractKeyPrefix(apiKey); + + const apiKeys = await ApiKey.findAll({ + where: { keyPrefix }, + include: [{ + model: User, + as: 'user', + include: [{ association: 'groups' }] + }] + }); + + for (const storedKey of apiKeys) { + const isValid = await storedKey.validateKey(apiKey); + if (isValid) { + req.user = storedKey.user; + req.apiKey = storedKey; + req.isAdmin = storedKey.user.groups?.some(g => g.isAdmin) || false; + + // Populate req.session for compatibility with routes that check req.session.user + if (!req.session) { + req.session = {}; + } + req.session.user = storedKey.user.uid; + req.session.isAdmin = req.isAdmin; + + storedKey.recordUsage().catch(err => { + console.error('Failed to update API key last used timestamp:', err); + }); + + return next(); + } + } + } + } + + // Neither session nor API key authentication succeeded if (isApiRequest(req)) return res.status(401).json({ error: 'Unauthorized' }); @@ -57,4 +105,10 @@ function requireLocalhost(req, res, next) { const { setCurrentSite, loadSites } = require('./currentSite'); -module.exports = { requireAuth, requireAdmin, requireLocalhost, setCurrentSite, loadSites }; +module.exports = { + requireAuth, + requireAdmin, + requireLocalhost, + setCurrentSite, + loadSites +}; diff --git a/create-a-container/migrations/20260120165508-create-settings.js b/create-a-container/migrations/20260120165508-create-settings.js new file mode 100644 index 00000000..6fc2d8e5 --- /dev/null +++ b/create-a-container/migrations/20260120165508-create-settings.js @@ -0,0 +1,31 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('Settings', { + key: { + type: Sequelize.STRING, + primaryKey: true, + allowNull: false, + unique: true + }, + value: { + type: Sequelize.STRING, + allowNull: false + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('Settings'); + } +}; diff --git a/create-a-container/migrations/20260122000000-create-api-keys.js b/create-a-container/migrations/20260122000000-create-api-keys.js new file mode 100644 index 00000000..a66c9ed8 --- /dev/null +++ b/create-a-container/migrations/20260122000000-create-api-keys.js @@ -0,0 +1,66 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('ApiKeys', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + uidNumber: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'Users', + key: 'uidNumber' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + keyPrefix: { + type: Sequelize.STRING(8), + allowNull: false, + comment: 'First 8 characters of the API key for identification' + }, + keyHash: { + type: Sequelize.STRING(255), + allowNull: false, + comment: 'Argon2 hash of the full API key' + }, + description: { + type: Sequelize.STRING(255), + allowNull: true, + comment: 'User-provided description of the API key purpose' + }, + lastUsedAt: { + type: Sequelize.DATE, + allowNull: true, + comment: 'Timestamp of when this key was last used' + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + + // Add indexes for performance + await queryInterface.addIndex('ApiKeys', ['uidNumber'], { + name: 'apikeys_uidnumber_idx' + }); + + await queryInterface.addIndex('ApiKeys', ['keyPrefix'], { + name: 'apikeys_keyprefix_idx' + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('ApiKeys'); + } +}; diff --git a/create-a-container/migrations/20260123082104-create-password-reset-tokens.js b/create-a-container/migrations/20260123082104-create-password-reset-tokens.js new file mode 100644 index 00000000..b99c0ed8 --- /dev/null +++ b/create-a-container/migrations/20260123082104-create-password-reset-tokens.js @@ -0,0 +1,53 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('PasswordResetTokens', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + uidNumber: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'Users', + key: 'uidNumber' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + token: { + type: Sequelize.STRING(64), + allowNull: false, + unique: true + }, + expiresAt: { + type: Sequelize.DATE, + allowNull: false + }, + used: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + + await queryInterface.addIndex('PasswordResetTokens', ['token']); + await queryInterface.addIndex('PasswordResetTokens', ['uidNumber']); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('PasswordResetTokens'); + } +}; diff --git a/create-a-container/migrations/20260123082105-add-smtp-settings.js b/create-a-container/migrations/20260123082105-add-smtp-settings.js new file mode 100644 index 00000000..ff0fd879 --- /dev/null +++ b/create-a-container/migrations/20260123082105-add-smtp-settings.js @@ -0,0 +1,36 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + const settingsTable = await queryInterface.describeTable('Settings'); + + // Check if smtp_url already exists + if (!settingsTable.smtp_url) { + await queryInterface.bulkInsert('Settings', [ + { + key: 'smtp_url', + value: '', + createdAt: new Date(), + updatedAt: new Date() + } + ]); + } + + // Check if smtp_noreply_address already exists + if (!settingsTable.smtp_noreply_address) { + await queryInterface.bulkInsert('Settings', [ + { + key: 'smtp_noreply_address', + value: 'noreply@localhost', + createdAt: new Date(), + updatedAt: new Date() + } + ]); + } + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete('Settings', { key: 'smtp_url' }); + await queryInterface.bulkDelete('Settings', { key: 'smtp_noreply_address' }); + } +}; diff --git a/create-a-container/models/apikey.js b/create-a-container/models/apikey.js new file mode 100644 index 00000000..e095e343 --- /dev/null +++ b/create-a-container/models/apikey.js @@ -0,0 +1,102 @@ +'use strict'; +const { + Model +} = require('sequelize'); +const argon2 = require('argon2'); + +module.exports = (sequelize, DataTypes) => { + class ApiKey extends Model { + /** + * Helper method for defining associations. + * This method is not a part of Sequelize lifecycle. + * The `models/index` file will call this method automatically. + */ + static associate(models) { + ApiKey.belongsTo(models.User, { + foreignKey: 'uidNumber', + as: 'user' + }); + } + + /** + * Validates a plaintext API key against the stored encrypted key + * @param {string} plainKey - The plaintext API key to validate + * @returns {boolean} - True if the key matches, false otherwise + */ + async validateKey(plainKey) { + return await argon2.verify(this.keyHash, plainKey); + } + + /** + * Updates the lastUsedAt timestamp + */ + async recordUsage() { + this.lastUsedAt = new Date(); + await this.save({ fields: ['lastUsedAt'] }); + } + } + + ApiKey.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false + }, + uidNumber: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'Users', + key: 'uidNumber' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + keyPrefix: { + type: DataTypes.STRING(8), + allowNull: false, + comment: 'First 8 characters of the API key for identification' + }, + keyHash: { + type: DataTypes.STRING(255), + allowNull: false, + comment: 'Argon2 hash of the full API key' + }, + description: { + type: DataTypes.STRING(255), + allowNull: true, + comment: 'User-provided description of the API key purpose' + }, + lastUsedAt: { + type: DataTypes.DATE, + allowNull: true, + comment: 'Timestamp of when this key was last used' + } + }, { + sequelize, + modelName: 'ApiKey', + tableName: 'ApiKeys', + timestamps: true, + indexes: [ + { + fields: ['uidNumber'] + }, + { + fields: ['keyPrefix'] + } + ], + hooks: { + beforeCreate: async (apiKey, options) => { + if (!apiKey.keyHash) { + throw new Error('keyHash must be provided before creating an API key'); + } + if (!apiKey.keyPrefix) { + throw new Error('keyPrefix must be provided before creating an API key'); + } + } + } + }); + + return ApiKey; +}; diff --git a/create-a-container/models/password-reset-token.js b/create-a-container/models/password-reset-token.js new file mode 100644 index 00000000..4b7eb231 --- /dev/null +++ b/create-a-container/models/password-reset-token.js @@ -0,0 +1,128 @@ +'use strict'; +const { Model } = require('sequelize'); +const crypto = require('crypto'); + +module.exports = (sequelize, DataTypes) => { + class PasswordResetToken extends Model { + static associate(models) { + PasswordResetToken.belongsTo(models.User, { + foreignKey: 'uidNumber', + as: 'user' + }); + } + + /** + * Generate a new password reset token for a user + * @param {number} uidNumber - User's UID number + * @param {number} expirationHours - Hours until token expires (default: 1) + * @returns {Promise<{token: string, resetToken: PasswordResetToken}>} + */ + static async generateToken(uidNumber, expirationHours = 1) { + // Generate a random token + const token = crypto.randomBytes(32).toString('hex'); + + // Calculate expiration time + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + expirationHours); + + // Create the reset token record + const resetToken = await PasswordResetToken.create({ + uidNumber, + token, + expiresAt, + used: false + }); + + return { token, resetToken }; + } + + /** + * Validate and retrieve a token + * @param {string} token - The token string + * @returns {Promise} + */ + static async validateToken(token) { + const resetToken = await PasswordResetToken.findOne({ + where: { + token, + used: false + }, + include: [{ + association: 'user', + required: true + }] + }); + + if (!resetToken) { + return null; + } + + // Check if token has expired + if (new Date() > resetToken.expiresAt) { + return null; + } + + return resetToken; + } + + /** + * Mark token as used + */ + async markAsUsed() { + this.used = true; + await this.save(); + } + + /** + * Clean up expired and used tokens + */ + static async cleanup() { + const now = new Date(); + await PasswordResetToken.destroy({ + where: { + [sequelize.Sequelize.Op.or]: [ + { expiresAt: { [sequelize.Sequelize.Op.lt]: now } }, + { used: true } + ] + } + }); + } + } + + PasswordResetToken.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false + }, + uidNumber: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'Users', + key: 'uidNumber' + } + }, + token: { + type: DataTypes.STRING(64), + allowNull: false, + unique: true + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: false + }, + used: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + } + }, { + sequelize, + modelName: 'PasswordResetToken', + tableName: 'PasswordResetTokens' + }); + + return PasswordResetToken; +}; diff --git a/create-a-container/models/setting.js b/create-a-container/models/setting.js new file mode 100644 index 00000000..96fa986b --- /dev/null +++ b/create-a-container/models/setting.js @@ -0,0 +1,75 @@ +'use strict'; +const { + Model +} = require('sequelize'); + +module.exports = (sequelize, DataTypes) => { + class Setting extends Model { + /** + * Helper method for defining associations. + * This method is not a part of Sequelize lifecycle. + * The `models/index` file will call this method automatically. + */ + static associate(models) { + // No associations for now + } + + /** + * Gets a setting value by key + * @param {string} key - The setting key + * @returns {Promise} - The setting value or null if not found + */ + static async get(key) { + const setting = await Setting.findByPk(key); + return setting ? setting.value : null; + } + + /** + * Sets a setting value + * @param {string} key - The setting key + * @param {string} value - The setting value + * @returns {Promise} - The created or updated setting + */ + static async set(key, value) { + const [setting] = await Setting.upsert({ key, value }); + return setting; + } + + /** + * Gets multiple settings by keys + * @param {string[]} keys - Array of setting keys + * @returns {Promise} - Object with keys and their values + */ + static async getMultiple(keys) { + const settings = await Setting.findAll({ + where: { + key: keys + } + }); + return settings.reduce((acc, setting) => { + acc[setting.key] = setting.value; + return acc; + }, {}); + } + } + + Setting.init({ + key: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false, + unique: true + }, + value: { + type: DataTypes.STRING, + allowNull: false + } + }, { + sequelize, + modelName: 'Setting', + tableName: 'Settings', + timestamps: true + }); + + return Setting; +}; diff --git a/create-a-container/models/user.js b/create-a-container/models/user.js index 1254abd5..2c261a6a 100644 --- a/create-a-container/models/user.js +++ b/create-a-container/models/user.js @@ -39,6 +39,15 @@ module.exports = (sequelize, DataTypes) => { async validatePassword(plainPassword) { return await argon2.verify(this.userPassword, plainPassword); } + + /** + * Set a new password for the user + * @param {string} plainPassword - The new plaintext password + */ + async setPassword(plainPassword) { + this.userPassword = plainPassword; + await this.save(); + } } User.init({ uidNumber: { diff --git a/create-a-container/public/style.css b/create-a-container/public/style.css index abed62a9..52201bd5 100644 --- a/create-a-container/public/style.css +++ b/create-a-container/public/style.css @@ -172,36 +172,17 @@ select:focus { } /* Navbar container for logout button */ -.navbar { - position: relative; - top: 0; - left: 0; - right: 0; - width: 100%; - background-color: #2c3e50; - color: white; - padding: 0.5rem 1rem; - display: flex; - align-items: center; - justify-content: flex-end; - z-index: 1000; +.navbar-dark .breadcrumb-item a { + color: #fff; + text-decoration: none; } -/* Logout button style */ -.navbar .logout-button { - background-color: #e74c3c; - color: white; - border: none; - padding: 0.5rem 1rem; - margin-right: 2rem; - border-radius: 4px; - cursor: pointer; - font-size: 1rem; - transition: background-color 0.3s; +.navbar-dark .breadcrumb-item.active { + color: #adb5bd; } -.navbar .logout-button:hover { - background-color: #c0392b; +.navbar-dark .breadcrumb-item + .breadcrumb-item::before { + color: #6c757d; } #signUpBtn { @@ -245,77 +226,80 @@ select:focus { /* Sidebar styling */ .sidebar { position: sticky; - top: 0; - height: 100vh; + top: 56px; + height: calc(100vh - 56px); padding: 0; - border-right: 2px solid #1a252f; - background-color: #2c3e50; + border-right: 1px solid rgba(0, 0, 0, 0.1); + background-color: #212529; + overflow-y: auto; + min-width: 200px; + max-width: 250px; } -.sidebar-logo { +.sidebar .nav { padding: 0; - text-align: center; - border-bottom: 1px solid #34495e; - height: 56px; - display: flex; - align-items: center; - justify-content: center; } -.sidebar-logo a { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - width: 100%; +.sidebar .nav-link { + color: #adb5bd; + padding: 0.75rem 1rem; + border-radius: 0; + transition: background-color 0.2s, color 0.2s; + white-space: nowrap; } -.sidebar-logo .logo { - max-height: 100%; - max-width: 100%; - width: auto; - height: auto; - object-fit: contain; - margin: 0; +.sidebar .nav-link:hover, +.sidebar .nav-link.active { + background-color: #343a40; + color: #fff; } -.sidebar .nav { - padding: 1rem 0; +.sidebar .nav-link i { + margin-right: 0.5rem; } -.sidebar .nav-link { - color: #ecf0f1; - padding: 0.75rem 1rem; - border-radius: 0.25rem; - transition: background-color 0.2s, color 0.2s; +/* Mobile: Make sidebar full-width when expanded */ +@media (max-width: 767.98px) { + .sidebar { + position: fixed; + top: 56px; + left: 0; + right: 0; + width: 100%; + height: calc(100vh - 56px); + z-index: 1040; + max-width: 100%; + min-width: 100%; + } } -.sidebar .nav-link:hover { - background-color: #34495e; - color: #3498db; +/* Main content spacing */ +main { + padding-top: 1rem; + min-height: calc(100vh - 56px); } /* Site selector dropdown in sidebar */ .sidebar #site-selector { - background-color: #34495e; - color: #ecf0f1; - border: 1px solid #4a5f7f; + background-color: #343a40; + color: #fff; + border: 1px solid #495057; } .sidebar #site-selector:focus { - background-color: #2c3e50; - border-color: #3498db; - color: #ecf0f1; - box-shadow: 0 0 0 0.2rem rgba(52, 152, 219, 0.25); + background-color: #343a40; + border-color: #0d6efd; + color: #fff; + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); } .sidebar #site-selector option { - background-color: #34495e; - color: #ecf0f1; + background-color: #343a40; + color: #fff; } .sidebar .nav-link.active { - background-color: #3498db; + background-color: #0d6efd; color: white; } @@ -340,8 +324,18 @@ select:focus { .sidebar .site-group:hover > .nav-link.site-selector-wrapper, .sidebar .site-group:focus-within > .nav-link.site-selector-wrapper { - background-color: #34495e; - color: #3498db; + background-color: #343a40; + color: #fff; +} + +.sidebar .sites-root-link { + color: #adb5bd; + text-decoration: none; + padding-right: 0.5rem; +} + +.sidebar .sites-root-link:hover { + color: #fff; } /* Nested children indentation */ @@ -349,19 +343,20 @@ select:focus { list-style: none; margin: 0 0 0.5rem 0; padding: 0 0 0.5rem 0.25rem; - border-left: 2px solid #34495e; + border-left: 2px solid #495057; } .sidebar .site-children .child-link { display: block; padding: 0.4rem 0.75rem 0.4rem 1.25rem; /* extra left padding for indentation */ font-size: 0.9rem; - color: #bdc3c7; + color: #adb5bd; } -.sidebar .site-children .child-link:hover { - background-color: #34495e; - color: #ecf0f1; +.sidebar .site-children .child-link:hover, +.sidebar .site-children .child-link.active { + background-color: #343a40; + color: #fff; } .sidebar .site-children .child-link.active { diff --git a/create-a-container/routers/apikeys.js b/create-a-container/routers/apikeys.js new file mode 100644 index 00000000..b14d4808 --- /dev/null +++ b/create-a-container/routers/apikeys.js @@ -0,0 +1,177 @@ +const express = require('express'); +const router = express.Router(); +const { ApiKey, User } = require('../models'); +const { requireAuth } = require('../middlewares'); +const { createApiKeyData } = require('../utils/apikey'); + +// Apply auth to all routes - users can only manage their own API keys +router.use(requireAuth); + +// GET /apikeys - List all API keys for the current user +router.get('/', async (req, res) => { + const user = await User.findOne({ where: { uid: req.session.user } }); + if (!user) { + req.flash('error', 'User not found'); + return res.redirect('/login'); + } + + const apiKeys = await ApiKey.findAll({ + where: { uidNumber: user.uidNumber }, + order: [['createdAt', 'DESC']], + attributes: ['id', 'keyPrefix', 'description', 'lastUsedAt', 'createdAt', 'updatedAt'] + }); + + // Check if this is an API request + const acceptsJSON = req.get('Accept') && req.get('Accept').includes('application/json'); + const isAjax = req.get('X-Requested-With') === 'XMLHttpRequest'; + + if (acceptsJSON || isAjax) { + return res.json({ apiKeys }); + } + + return res.render('apikeys/index', { + apiKeys, + req + }); +}); + +// GET /apikeys/new - Display form for creating a new API key +router.get('/new', (req, res) => { + return res.render('apikeys/form', { + req + }); +}); + +// POST /apikeys - Create a new API key +router.post('/', async (req, res) => { + const user = await User.findOne({ where: { uid: req.session.user } }); + if (!user) { + req.flash('error', 'User not found'); + return res.redirect('/login'); + } + + const { description } = req.body; + + const apiKeyData = await createApiKeyData(user.uidNumber, description); + + // Store the hashed key in the database + const apiKey = await ApiKey.create({ + uidNumber: apiKeyData.uidNumber, + keyPrefix: apiKeyData.keyPrefix, + keyHash: apiKeyData.keyHash, + description: apiKeyData.description + }); + + // Check if this is an API request + const acceptsJSON = req.get('Accept') && req.get('Accept').includes('application/json'); + const isAjax = req.get('X-Requested-With') === 'XMLHttpRequest'; + + if (acceptsJSON || isAjax) { + return res.status(201).json({ + apiKey: { + id: apiKey.id, + key: apiKeyData.plainKey, // Only shown once! + keyPrefix: apiKey.keyPrefix, + description: apiKey.description, + createdAt: apiKey.createdAt + }, + warning: 'This is the only time the full API key will be displayed. Please store it securely.' + }); + } + + req.flash('success', 'API key created successfully. This is the only time it will be shown!'); + return res.render('apikeys/created', { + plainKey: apiKeyData.plainKey, + apiKey, + req + }); +}); + +// GET /apikeys/:id - Show details of a specific API key +router.get('/:id', async (req, res) => { + const user = await User.findOne({ where: { uid: req.session.user } }); + if (!user) { + req.flash('error', 'User not found'); + return res.redirect('/login'); + } + + const id = req.params.id; + + const apiKey = await ApiKey.findOne({ + where: { + id, + uidNumber: user.uidNumber // Ensure user can only view their own keys + }, + attributes: ['id', 'keyPrefix', 'description', 'lastUsedAt', 'createdAt', 'updatedAt'] + }); + + if (!apiKey) { + const acceptsJSON = req.get('Accept') && req.get('Accept').includes('application/json'); + const isAjax = req.get('X-Requested-With') === 'XMLHttpRequest'; + + if (acceptsJSON || isAjax) { + return res.status(404).json({ error: 'API key not found' }); + } + + req.flash('error', 'API key not found'); + return res.redirect('/apikeys'); + } + + // Check if this is an API request + const acceptsJSON = req.get('Accept') && req.get('Accept').includes('application/json'); + const isAjax = req.get('X-Requested-With') === 'XMLHttpRequest'; + + if (acceptsJSON || isAjax) { + return res.json({ apiKey }); + } + + return res.render('apikeys/show', { + apiKey, + req + }); +}); + +// DELETE /apikeys/:id - Delete an API key +router.delete('/:id', async (req, res) => { + const user = await User.findOne({ where: { uid: req.session.user } }); + if (!user) { + req.flash('error', 'User not found'); + return res.redirect('/login'); + } + + const id = req.params.id; + + const apiKey = await ApiKey.findOne({ + where: { + id, + uidNumber: user.uidNumber // Ensure user can only delete their own keys + } + }); + + if (!apiKey) { + const acceptsJSON = req.get('Accept') && req.get('Accept').includes('application/json'); + const isAjax = req.get('X-Requested-With') === 'XMLHttpRequest'; + + if (acceptsJSON || isAjax) { + return res.status(404).json({ error: 'API key not found' }); + } + + req.flash('error', 'API key not found'); + return res.redirect('/apikeys'); + } + + await apiKey.destroy(); + + // Check if this is an API request + const acceptsJSON = req.get('Accept') && req.get('Accept').includes('application/json'); + const isAjax = req.get('X-Requested-With') === 'XMLHttpRequest'; + + if (acceptsJSON || isAjax) { + return res.status(204).send(); + } + + req.flash('success', 'API key deleted successfully'); + return res.redirect('/apikeys'); +}); + +module.exports = router; diff --git a/create-a-container/routers/login.js b/create-a-container/routers/login.js index e3ba1fec..955964e9 100644 --- a/create-a-container/routers/login.js +++ b/create-a-container/routers/login.js @@ -1,6 +1,6 @@ const express = require('express'); const router = express.Router(); -const { User } = require('../models'); +const { User, Setting } = require('../models'); const { isSafeRelativeUrl } = require('../utils'); // GET / - Display login form @@ -35,6 +35,56 @@ router.post('/', async (req, res) => { return res.redirect('/login'); } + // Check if push notification 2FA is enabled + const settings = await Setting.getMultiple(['push_notification_url', 'push_notification_enabled']); + const pushNotificationUrl = settings.push_notification_url || ''; + const pushNotificationEnabled = settings.push_notification_enabled === 'true'; + + if (pushNotificationEnabled && pushNotificationUrl.trim() !== '') { + const notificationPayload = { + username: user.uid, + title: 'Authentication Request', + body: 'Please review and respond to your pending authentication request.', + actions: [ + { icon: 'approve', title: 'Approve', callback: 'approve' }, + { icon: 'reject', title: 'Reject', callback: 'reject' } + ] + }; + + try { + const response = await fetch(`${pushNotificationUrl}/send-notification`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(notificationPayload) + }); + + const result = await response.json(); + + // Check for no device found error + if (result.success === false && result.error?.includes('No device found with this Username')) { + const registrationUrl = pushNotificationUrl; + req.flash('error', `No device found with this username. Please register your device at: ${registrationUrl}`); + return res.redirect('/login'); + } + + if (!response.ok) { + req.flash('error', 'Failed to send push notification. Please contact support.'); + return res.redirect('/login'); + } + + if (result.action?.toUpperCase() !== 'APPROVE') { + req.flash('error', 'Authentication request was denied'); + return res.redirect('/login'); + } + } catch (error) { + console.error('Push notification error:', error); + req.flash('error', 'Failed to send push notification. Please contact support.'); + return res.redirect('/login'); + } + } + // Set session variables req.session.user = user.uid; req.session.isAdmin = user.groups?.some(group => group.isAdmin) || false; diff --git a/create-a-container/routers/reset-password.js b/create-a-container/routers/reset-password.js new file mode 100644 index 00000000..84ae3aea --- /dev/null +++ b/create-a-container/routers/reset-password.js @@ -0,0 +1,133 @@ +const express = require('express'); +const router = express.Router(); +const { User, PasswordResetToken } = require('../models'); +const { sendPasswordResetEmail } = require('../utils/email'); + +// GET /reset-password - Display the form to request password reset +router.get('/', (req, res) => { + res.render('reset-password/request', { + successMessages: req.flash('success'), + errorMessages: req.flash('error') + }); +}); + +// POST /reset-password - Handle password reset request +router.post('/', async (req, res) => { + const { usernameOrEmail } = req.body; + + if (!usernameOrEmail || usernameOrEmail.trim() === '') { + req.flash('error', 'Please enter your username or email address'); + return res.redirect('/reset-password'); + } + + try { + // Look up user by username or email + const user = await User.findOne({ + where: { + [require('sequelize').Op.or]: [ + { uid: usernameOrEmail.trim() }, + { mail: usernameOrEmail.trim() } + ] + } + }); + + if (!user) { + req.flash('error', 'User not found'); + return res.redirect('/reset-password'); + } + + // Generate reset token + const { token } = await PasswordResetToken.generateToken(user.uidNumber); + + // Build reset URL + const resetUrl = `${req.protocol}://${req.get('host')}/reset-password/${token}`; + + // Send email + try { + await sendPasswordResetEmail(user.mail, user.uid, resetUrl); + req.flash('success', 'Password reset instructions have been sent to your email address'); + return res.redirect('/login'); + } catch (emailError) { + console.error('Failed to send password reset email:', emailError); + req.flash('error', 'Password reset failed, please contact an administrator'); + return res.redirect('/reset-password'); + } + } catch (error) { + console.error('Password reset error:', error); + req.flash('error', 'Password reset failed, please contact an administrator'); + return res.redirect('/reset-password'); + } +}); + +// GET /reset-password/:token - Display password reset form with token +router.get('/:token', async (req, res) => { + const { token } = req.params; + + try { + const resetToken = await PasswordResetToken.validateToken(token); + + if (!resetToken) { + req.flash('error', 'Invalid or expired password reset link'); + return res.redirect('/login'); + } + + res.render('reset-password/reset', { + token, + username: resetToken.user.uid, + successMessages: req.flash('success'), + errorMessages: req.flash('error') + }); + } catch (error) { + console.error('Password reset token validation error:', error); + req.flash('error', 'Password reset failed, please contact an administrator'); + return res.redirect('/login'); + } +}); + +// POST /reset-password/:token - Handle password reset +router.post('/:token', async (req, res) => { + const { token } = req.params; + const { password, confirmPassword } = req.body; + + // Validate passwords + if (!password || !confirmPassword) { + req.flash('error', 'Please enter and confirm your new password'); + return res.redirect(`/reset-password/${token}`); + } + + if (password !== confirmPassword) { + req.flash('error', 'Passwords do not match'); + return res.redirect(`/reset-password/${token}`); + } + + if (password.length < 8) { + req.flash('error', 'Password must be at least 8 characters long'); + return res.redirect(`/reset-password/${token}`); + } + + try { + const resetToken = await PasswordResetToken.validateToken(token); + + if (!resetToken) { + req.flash('error', 'Invalid or expired password reset link'); + return res.redirect('/login'); + } + + const user = resetToken.user; + + // Update password (User model should handle hashing) + await user.setPassword(password); + + // Mark token as used + await resetToken.markAsUsed(); + + req.flash('success', 'Your password has been reset successfully. Please log in with your new password.'); + return res.redirect('/login'); + } catch (error) { + console.error('Password reset error:', error); + req.flash('error', 'Password reset failed, please contact an administrator'); + return res.redirect(`/reset-password/${token}`); + } +}); + +module.exports = router; diff --git a/create-a-container/routers/settings.js b/create-a-container/routers/settings.js new file mode 100644 index 00000000..a5ef7555 --- /dev/null +++ b/create-a-container/routers/settings.js @@ -0,0 +1,50 @@ +const express = require('express'); +const router = express.Router(); +const { Setting } = require('../models'); +const { requireAuth, requireAdmin } = require('../middlewares'); + +router.use(requireAuth); +router.use(requireAdmin); + +router.get('/', async (req, res) => { + const settings = await Setting.getMultiple([ + 'push_notification_url', + 'push_notification_enabled', + 'smtp_url', + 'smtp_noreply_address' + ]); + + res.render('settings/index', { + pushNotificationUrl: settings.push_notification_url || '', + pushNotificationEnabled: settings.push_notification_enabled === 'true', + smtpUrl: settings.smtp_url || '', + smtpNoreplyAddress: settings.smtp_noreply_address || '', + req + }); +}); + +router.post('/', async (req, res) => { + const { + push_notification_url, + push_notification_enabled, + smtp_url, + smtp_noreply_address + } = req.body; + + const enabled = push_notification_enabled === 'on'; + + if (enabled && (!push_notification_url || push_notification_url.trim() === '')) { + req.flash('error', 'Push notification URL is required when push notifications are enabled'); + return res.redirect('/settings'); + } + + await Setting.set('push_notification_url', push_notification_url || ''); + await Setting.set('push_notification_enabled', enabled ? 'true' : 'false'); + await Setting.set('smtp_url', smtp_url || ''); + await Setting.set('smtp_noreply_address', smtp_noreply_address || ''); + + req.flash('success', 'Settings saved successfully'); + return res.redirect('/settings'); +}); + +module.exports = router; diff --git a/create-a-container/routers/sites.js b/create-a-container/routers/sites.js index e0137267..803b5da4 100644 --- a/create-a-container/routers/sites.js +++ b/create-a-container/routers/sites.js @@ -106,12 +106,25 @@ router.get('/:siteId/ldap.conf', requireLocalhost, async (req, res) => { return res.status(404).send('Site not found'); } + // Get push notification settings + const { Setting } = require('../models'); + const settings = await Setting.getMultiple(['push_notification_url', 'push_notification_enabled']); + const pushNotificationUrl = settings.push_notification_url || ''; + const pushNotificationEnabled = settings.push_notification_enabled === 'true'; + // define the environment object const env = { - AUTH_BACKENDS: 'sql', DIRECTORY_BACKEND: 'sql', }; + // Configure AUTH_BACKENDS and NOTIFICATION_URL based on push notification settings + if (pushNotificationEnabled && pushNotificationUrl.trim() !== '') { + env.AUTH_BACKENDS = 'sql,notification'; + env.NOTIFICATION_URL = `${pushNotificationUrl}/send-notification`; + } else { + env.AUTH_BACKENDS = 'sql'; + } + // Get the real IP from the request or the x-forwarded-for header // and do a reverse DNS lookup to get the hostname. If the clientIP is any // localhost address, use the FQDN of the server instead. diff --git a/create-a-container/seeders/20260120165612-push-notification-settings.js b/create-a-container/seeders/20260120165612-push-notification-settings.js new file mode 100644 index 00000000..071be2c5 --- /dev/null +++ b/create-a-container/seeders/20260120165612-push-notification-settings.js @@ -0,0 +1,27 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.bulkInsert('Settings', [ + { + key: 'push_notification_url', + value: '', + createdAt: new Date(), + updatedAt: new Date() + }, + { + key: 'push_notification_enabled', + value: 'false', + createdAt: new Date(), + updatedAt: new Date() + } + ], {}); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete('Settings', { + key: ['push_notification_url', 'push_notification_enabled'] + }, {}); + } +}; diff --git a/create-a-container/server.js b/create-a-container/server.js index 31a40adb..21b1d2e7 100644 --- a/create-a-container/server.js +++ b/create-a-container/server.js @@ -103,12 +103,19 @@ async function main() { const groupsRouter = require('./routers/groups'); const sitesRouter = require('./routers/sites'); // Includes nested nodes and containers routers const jobsRouter = require('./routers/jobs'); + const settingsRouter = require('./routers/settings'); + const apikeysRouter = require('./routers/apikeys'); + const resetPasswordRouter = require('./routers/reset-password'); + app.use('/jobs', jobsRouter); app.use('/login', loginRouter); app.use('/register', registerRouter); app.use('/users', usersRouter); app.use('/groups', groupsRouter); app.use('/sites', sitesRouter); // /sites/:siteId/nodes and /sites/:siteId/containers routes nested here + app.use('/settings', settingsRouter); + app.use('/apikeys', apikeysRouter); + app.use('/reset-password', resetPasswordRouter); // --- Routes --- const PORT = 3000; diff --git a/create-a-container/test-api-key.sh b/create-a-container/test-api-key.sh new file mode 100755 index 00000000..4184e9ae --- /dev/null +++ b/create-a-container/test-api-key.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# Test API key authentication for all non-admin routes +# Usage: ./test-api-key.sh + +API_KEY="$1" +BASE_URL="http://localhost:3000" + +if [ -z "$API_KEY" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Testing non-admin routes with API key authentication" +echo "=================================================" +echo "" + +# Sites +echo "GET /sites" +curl -s -H "Authorization: Bearer $API_KEY" "$BASE_URL/sites" | jq -r '.error // "✓ Success"' + +# API Keys +echo "GET /apikeys" +curl -s -H "Authorization: Bearer $API_KEY" "$BASE_URL/apikeys" | jq -r '.error // "✓ Success"' + +# Containers (requires siteId) +echo "GET /sites/1/containers" +curl -s -H "Authorization: Bearer $API_KEY" "$BASE_URL/sites/1/containers" | jq -r '.error // "✓ Success"' + +echo "GET /sites/1/containers/new" +curl -s -H "Authorization: Bearer $API_KEY" "$BASE_URL/sites/1/containers/new" | jq -r '.error // "✓ Success"' + +# Jobs +echo "GET /jobs/:id (using test id 123)" +curl -s -H "Authorization: Bearer $API_KEY" "$BASE_URL/jobs/123" | jq -r '.error // "✓ Success"' + +echo "GET /jobs/:id/status (using test id 123)" +curl -s -H "Authorization: Bearer $API_KEY" "$BASE_URL/jobs/123/status" | jq -r '.error // "✓ Success"' + +# External Domains +echo "GET /sites/1/external-domains" +curl -s -H "Authorization: Bearer $API_KEY" "$BASE_URL/sites/1/external-domains" | jq -r '.error // "✓ Success"' + +echo "" +echo "=================================================" +echo "Test complete" diff --git a/create-a-container/utils/apikey.js b/create-a-container/utils/apikey.js new file mode 100644 index 00000000..78919993 --- /dev/null +++ b/create-a-container/utils/apikey.js @@ -0,0 +1,69 @@ +const crypto = require('crypto'); +const argon2 = require('argon2'); + +/** + * Generates a secure API key with high entropy + * @returns {string} A 44-character base64url-encoded random string + */ +function generateApiKey() { + // Generate 32 random bytes (256 bits of entropy) + const buffer = crypto.randomBytes(32); + // Convert to base64url encoding (URL-safe, no padding) + return buffer.toString('base64url'); +} + +/** + * Extracts the prefix from an API key (first 8 characters) + * @param {string} apiKey - The full API key + * @returns {string} The first 8 characters of the API key + */ +function extractKeyPrefix(apiKey) { + return apiKey.substring(0, 8); +} + +/** + * Hashes an API key using argon2 + * @param {string} apiKey - The plaintext API key + * @returns {Promise} The argon2 hash of the API key + */ +async function hashApiKey(apiKey) { + return await argon2.hash(apiKey); +} + +/** + * Verifies an API key against a hash + * @param {string} hash - The stored argon2 hash + * @param {string} apiKey - The plaintext API key to verify + * @returns {Promise} True if the key matches the hash + */ +async function verifyApiKey(hash, apiKey) { + return await argon2.verify(hash, apiKey); +} + +/** + * Generates a complete API key object ready for database insertion + * @param {number} uidNumber - The user's UID number + * @param {string} description - Optional description for the API key + * @returns {Promise<{plainKey: string, keyPrefix: string, keyHash: string, uidNumber: number, description: string}>} + */ +async function createApiKeyData(uidNumber, description = null) { + const plainKey = generateApiKey(); + const keyPrefix = extractKeyPrefix(plainKey); + const keyHash = await hashApiKey(plainKey); + + return { + plainKey, // This should only be shown once to the user + keyPrefix, + keyHash, + uidNumber, + description + }; +} + +module.exports = { + generateApiKey, + extractKeyPrefix, + hashApiKey, + verifyApiKey, + createApiKeyData +}; diff --git a/create-a-container/utils/email.js b/create-a-container/utils/email.js new file mode 100644 index 00000000..6f5a8561 --- /dev/null +++ b/create-a-container/utils/email.js @@ -0,0 +1,101 @@ +const nodemailer = require('nodemailer'); +const { Setting } = require('../models'); + +/** + * Parse SMTP URL and create nodemailer transport + * Format: smtp[s]://[[username][:password]@][:port] + */ +async function createTransport() { + const settings = await Setting.getMultiple(['smtp_url', 'smtp_noreply_address']); + const smtpUrl = settings.smtp_url; + + if (!smtpUrl || smtpUrl.trim() === '') { + throw new Error('SMTP URL is not configured'); + } + + try { + const url = new URL(smtpUrl); + + const isSecure = url.protocol === 'smtps:'; + const host = url.hostname; + const port = url.port || (isSecure ? 465 : 587); + const auth = url.username ? { + user: decodeURIComponent(url.username), + pass: decodeURIComponent(url.password || '') + } : undefined; + + return nodemailer.createTransport({ + host, + port: parseInt(port), + secure: isSecure, + auth + }); + } catch (error) { + throw new Error(`Invalid SMTP URL format: ${error.message}`); + } +} + +/** + * Send password reset email + * @param {string} to - Recipient email address + * @param {string} username - Username for display + * @param {string} resetUrl - Full URL for password reset + */ +async function sendPasswordResetEmail(to, username, resetUrl) { + const settings = await Setting.getMultiple(['smtp_noreply_address']); + const from = settings.smtp_noreply_address || 'noreply@localhost'; + + const transporter = await createTransport(); + + const mailOptions = { + from, + to, + subject: 'Password Reset Request', + text: `Hello ${username}, + +You have requested to reset your password for the MIE Container Creation system. + +Please click the following link to reset your password: +${resetUrl} + +This link will expire in 1 hour. + +If you did not request this password reset, please ignore this email. + +--- +Medical Informatics Engineering`, + html: ` +
+

Password Reset Request

+

Hello ${username},

+

You have requested to reset your password for the MIE Container Creation system.

+

Please click the button below to reset your password:

+ +

Or copy and paste this link into your browser:

+

+ ${resetUrl} +

+

This link will expire in 1 hour.

+
+

+ If you did not request this password reset, please ignore this email. +

+

+ Medical Informatics Engineering +

+
+ ` + }; + + await transporter.sendMail(mailOptions); +} + +module.exports = { + createTransport, + sendPasswordResetEmail +}; diff --git a/create-a-container/views/apikeys/created.ejs b/create-a-container/views/apikeys/created.ejs new file mode 100644 index 00000000..46a406b6 --- /dev/null +++ b/create-a-container/views/apikeys/created.ejs @@ -0,0 +1,106 @@ +<%- include('../layouts/header', { + title: 'API Key Created - MIE', + breadcrumbs: [ + { label: 'API Keys', url: '/apikeys' }, + { label: 'Created', url: '#' } + ], + req +}) %> + +
+
+
+
+

+ API Key Successfully Created +

+
+
+ + +
+ +
+ + +
+
+ <% if (apiKey.description) { %> + Description: <%= apiKey.description %> + <% } %> +
+
+ +
+
+
Key Details
+
+
Key Prefix:
+
<%= apiKey.keyPrefix %>********
+ +
Created:
+
<%= new Date(apiKey.createdAt).toLocaleString() %>
+ +
Key ID:
+
<%= apiKey.id %>
+
+
+
+ + +
+
+
+
+ + + +<%- include('../layouts/footer') %> diff --git a/create-a-container/views/apikeys/form.ejs b/create-a-container/views/apikeys/form.ejs new file mode 100644 index 00000000..7753f8f3 --- /dev/null +++ b/create-a-container/views/apikeys/form.ejs @@ -0,0 +1,52 @@ +<%- include('../layouts/header', { + title: 'Create API Key - MIE', + breadcrumbs: [ + { label: 'API Keys', url: '/apikeys' }, + { label: 'Create', url: '/apikeys/new' } + ], + req +}) %> + +
+
+
+
+

Create New API Key

+ + + +
+
+ + +
+ Optional: Add a description to help identify this key's purpose. +
+
+ +
+ + Cancel + + +
+
+
+
+
+
+ +<%- include('../layouts/footer') %> diff --git a/create-a-container/views/apikeys/index.ejs b/create-a-container/views/apikeys/index.ejs new file mode 100644 index 00000000..c30a88ea --- /dev/null +++ b/create-a-container/views/apikeys/index.ejs @@ -0,0 +1,129 @@ +<%- include('../layouts/header', { + title: 'API Keys - MIE', + breadcrumbs: [ + { label: 'API Keys', url: '/apikeys' } + ], + req +}) %> +
+
+
+

API Keys

+ + New API Key + +
+ + +
+ <% if (apiKeys.length === 0) { %> +
+

No API keys found. Create one to get started.

+
+ <% } else { %> + <% apiKeys.forEach(key => { %> +
+
+
+
Key Prefix
+ <% if (key.lastUsedAt) { %> + Active + <% } else { %> + Unused + <% } %> +
+ <%= key.keyPrefix %>******** + + <% if (key.description) { %> +

Description: <%= key.description %>

+ <% } %> + +

+ + Created: <%= new Date(key.createdAt).toLocaleDateString() %>
+ Last Used: <%= key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleDateString() : 'Never' %> +
+

+ +
+ + View Details + +
+ + +
+
+
+
+ <% }) %> + <% } %> +
+ + +
+ + + + + + + + + + + + <% if (apiKeys.length === 0) { %> + + + + <% } else { %> + <% apiKeys.forEach(key => { %> + + + + + + + + <% }) %> + <% } %> + +
Key PrefixDescriptionLast UsedCreatedActions
+

No API keys found. Create one to get started.

+
+ <%= key.keyPrefix %>******** + + <% if (key.description) { %> + <%= key.description %> + <% } else { %> + No description + <% } %> + + <% if (key.lastUsedAt) { %> + <%= new Date(key.lastUsedAt).toLocaleString() %> + <% } else { %> + Never + <% } %> + + <%= new Date(key.createdAt).toLocaleString() %> + +
+ + View + +
+ + +
+
+
+
+
+
+ +<%- include('../layouts/footer') %> diff --git a/create-a-container/views/apikeys/show.ejs b/create-a-container/views/apikeys/show.ejs new file mode 100644 index 00000000..7890183a --- /dev/null +++ b/create-a-container/views/apikeys/show.ejs @@ -0,0 +1,82 @@ +<%- include('../layouts/header', { + title: 'API Key Details - MIE', + breadcrumbs: [ + { label: 'API Keys', url: '/apikeys' }, + { label: 'Details', url: '#' } + ], + req +}) %> + +
+
+
+
+
+

API Key Details

+
+ + +
+
+ + + +
+
Key Prefix:
+
+ <%= apiKey.keyPrefix %>******** +
+ +
Description:
+
+ <% if (apiKey.description) { %> + <%= apiKey.description %> + <% } else { %> + No description provided + <% } %> +
+ +
Key ID:
+
+ <%= apiKey.id %> +
+ +
Created:
+
+ <%= new Date(apiKey.createdAt).toLocaleString() %> +
+ +
Last Updated:
+
+ <%= new Date(apiKey.updatedAt).toLocaleString() %> +
+ +
Last Used:
+
+ <% if (apiKey.lastUsedAt) { %> + <%= new Date(apiKey.lastUsedAt).toLocaleString() %> + Active + <% } else { %> + Never used + Inactive + <% } %> +
+
+ + +
+
+
+
+ +<%- include('../layouts/footer') %> diff --git a/create-a-container/views/layouts/footer.ejs b/create-a-container/views/layouts/footer.ejs index 6711f12b..1935e964 100644 --- a/create-a-container/views/layouts/footer.ejs +++ b/create-a-container/views/layouts/footer.ejs @@ -1,7 +1,4 @@ - - - - + diff --git a/create-a-container/views/layouts/header.ejs b/create-a-container/views/layouts/header.ejs index 756cfc5d..e166197a 100644 --- a/create-a-container/views/layouts/header.ejs +++ b/create-a-container/views/layouts/header.ejs @@ -9,16 +9,37 @@ -
+ + + +
- -