Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions create-a-container/middlewares/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });

Expand Down Expand Up @@ -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
};
31 changes: 31 additions & 0 deletions create-a-container/migrations/20260120165508-create-settings.js
Original file line number Diff line number Diff line change
@@ -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');
}
};
66 changes: 66 additions & 0 deletions create-a-container/migrations/20260122000000-create-api-keys.js
Original file line number Diff line number Diff line change
@@ -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');
}
};
Original file line number Diff line number Diff line change
@@ -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');
}
};
36 changes: 36 additions & 0 deletions create-a-container/migrations/20260123082105-add-smtp-settings.js
Original file line number Diff line number Diff line change
@@ -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' });
}
};
102 changes: 102 additions & 0 deletions create-a-container/models/apikey.js
Original file line number Diff line number Diff line change
@@ -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;
};
Loading
Loading