Skip to content

Commit c424eee

Browse files
Merge pull request #167 from mieweb/push-notification-2fa
Push notification 2fa
2 parents 135fdb6 + 05e0e6d commit c424eee

42 files changed

Lines changed: 2368 additions & 183 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

create-a-container/middlewares/index.js

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,56 @@ function isApiRequest(req) {
77

88
// Authentication middleware (single) ---
99
// Detect API requests and browser requests. API requests return 401 JSON, browser requests redirect to /login.
10-
function requireAuth(req, res, next) {
10+
// Also accepts API key authentication via Authorization header.
11+
async function requireAuth(req, res, next) {
12+
// First check session authentication
1113
if (req.session && req.session.user) return next();
14+
15+
// Try API key authentication
16+
const authHeader = req.get('Authorization');
17+
if (authHeader && authHeader.startsWith('Bearer ')) {
18+
const apiKey = authHeader.substring(7);
19+
20+
if (apiKey) {
21+
const { ApiKey, User } = require('../models');
22+
const { extractKeyPrefix } = require('../utils/apikey');
23+
24+
const keyPrefix = extractKeyPrefix(apiKey);
25+
26+
const apiKeys = await ApiKey.findAll({
27+
where: { keyPrefix },
28+
include: [{
29+
model: User,
30+
as: 'user',
31+
include: [{ association: 'groups' }]
32+
}]
33+
});
34+
35+
for (const storedKey of apiKeys) {
36+
const isValid = await storedKey.validateKey(apiKey);
37+
if (isValid) {
38+
req.user = storedKey.user;
39+
req.apiKey = storedKey;
40+
req.isAdmin = storedKey.user.groups?.some(g => g.isAdmin) || false;
41+
42+
// Populate req.session for compatibility with routes that check req.session.user
43+
if (!req.session) {
44+
req.session = {};
45+
}
46+
req.session.user = storedKey.user.uid;
47+
req.session.isAdmin = req.isAdmin;
48+
49+
storedKey.recordUsage().catch(err => {
50+
console.error('Failed to update API key last used timestamp:', err);
51+
});
52+
53+
return next();
54+
}
55+
}
56+
}
57+
}
58+
59+
// Neither session nor API key authentication succeeded
1260
if (isApiRequest(req))
1361
return res.status(401).json({ error: 'Unauthorized' });
1462

@@ -57,4 +105,10 @@ function requireLocalhost(req, res, next) {
57105

58106
const { setCurrentSite, loadSites } = require('./currentSite');
59107

60-
module.exports = { requireAuth, requireAdmin, requireLocalhost, setCurrentSite, loadSites };
108+
module.exports = {
109+
requireAuth,
110+
requireAdmin,
111+
requireLocalhost,
112+
setCurrentSite,
113+
loadSites
114+
};
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
'use strict';
2+
3+
/** @type {import('sequelize-cli').Migration} */
4+
module.exports = {
5+
async up(queryInterface, Sequelize) {
6+
await queryInterface.createTable('Settings', {
7+
key: {
8+
type: Sequelize.STRING,
9+
primaryKey: true,
10+
allowNull: false,
11+
unique: true
12+
},
13+
value: {
14+
type: Sequelize.STRING,
15+
allowNull: false
16+
},
17+
createdAt: {
18+
allowNull: false,
19+
type: Sequelize.DATE
20+
},
21+
updatedAt: {
22+
allowNull: false,
23+
type: Sequelize.DATE
24+
}
25+
});
26+
},
27+
28+
async down(queryInterface, Sequelize) {
29+
await queryInterface.dropTable('Settings');
30+
}
31+
};
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
'use strict';
2+
3+
/** @type {import('sequelize-cli').Migration} */
4+
module.exports = {
5+
async up(queryInterface, Sequelize) {
6+
await queryInterface.createTable('ApiKeys', {
7+
id: {
8+
type: Sequelize.UUID,
9+
defaultValue: Sequelize.UUIDV4,
10+
primaryKey: true,
11+
allowNull: false
12+
},
13+
uidNumber: {
14+
type: Sequelize.INTEGER,
15+
allowNull: false,
16+
references: {
17+
model: 'Users',
18+
key: 'uidNumber'
19+
},
20+
onUpdate: 'CASCADE',
21+
onDelete: 'CASCADE'
22+
},
23+
keyPrefix: {
24+
type: Sequelize.STRING(8),
25+
allowNull: false,
26+
comment: 'First 8 characters of the API key for identification'
27+
},
28+
keyHash: {
29+
type: Sequelize.STRING(255),
30+
allowNull: false,
31+
comment: 'Argon2 hash of the full API key'
32+
},
33+
description: {
34+
type: Sequelize.STRING(255),
35+
allowNull: true,
36+
comment: 'User-provided description of the API key purpose'
37+
},
38+
lastUsedAt: {
39+
type: Sequelize.DATE,
40+
allowNull: true,
41+
comment: 'Timestamp of when this key was last used'
42+
},
43+
createdAt: {
44+
allowNull: false,
45+
type: Sequelize.DATE
46+
},
47+
updatedAt: {
48+
allowNull: false,
49+
type: Sequelize.DATE
50+
}
51+
});
52+
53+
// Add indexes for performance
54+
await queryInterface.addIndex('ApiKeys', ['uidNumber'], {
55+
name: 'apikeys_uidnumber_idx'
56+
});
57+
58+
await queryInterface.addIndex('ApiKeys', ['keyPrefix'], {
59+
name: 'apikeys_keyprefix_idx'
60+
});
61+
},
62+
63+
async down(queryInterface, Sequelize) {
64+
await queryInterface.dropTable('ApiKeys');
65+
}
66+
};
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
'use strict';
2+
3+
module.exports = {
4+
async up(queryInterface, Sequelize) {
5+
await queryInterface.createTable('PasswordResetTokens', {
6+
id: {
7+
type: Sequelize.UUID,
8+
defaultValue: Sequelize.UUIDV4,
9+
primaryKey: true,
10+
allowNull: false
11+
},
12+
uidNumber: {
13+
type: Sequelize.INTEGER,
14+
allowNull: false,
15+
references: {
16+
model: 'Users',
17+
key: 'uidNumber'
18+
},
19+
onUpdate: 'CASCADE',
20+
onDelete: 'CASCADE'
21+
},
22+
token: {
23+
type: Sequelize.STRING(64),
24+
allowNull: false,
25+
unique: true
26+
},
27+
expiresAt: {
28+
type: Sequelize.DATE,
29+
allowNull: false
30+
},
31+
used: {
32+
type: Sequelize.BOOLEAN,
33+
allowNull: false,
34+
defaultValue: false
35+
},
36+
createdAt: {
37+
allowNull: false,
38+
type: Sequelize.DATE
39+
},
40+
updatedAt: {
41+
allowNull: false,
42+
type: Sequelize.DATE
43+
}
44+
});
45+
46+
await queryInterface.addIndex('PasswordResetTokens', ['token']);
47+
await queryInterface.addIndex('PasswordResetTokens', ['uidNumber']);
48+
},
49+
50+
async down(queryInterface, Sequelize) {
51+
await queryInterface.dropTable('PasswordResetTokens');
52+
}
53+
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
'use strict';
2+
3+
module.exports = {
4+
async up(queryInterface, Sequelize) {
5+
const settingsTable = await queryInterface.describeTable('Settings');
6+
7+
// Check if smtp_url already exists
8+
if (!settingsTable.smtp_url) {
9+
await queryInterface.bulkInsert('Settings', [
10+
{
11+
key: 'smtp_url',
12+
value: '',
13+
createdAt: new Date(),
14+
updatedAt: new Date()
15+
}
16+
]);
17+
}
18+
19+
// Check if smtp_noreply_address already exists
20+
if (!settingsTable.smtp_noreply_address) {
21+
await queryInterface.bulkInsert('Settings', [
22+
{
23+
key: 'smtp_noreply_address',
24+
value: 'noreply@localhost',
25+
createdAt: new Date(),
26+
updatedAt: new Date()
27+
}
28+
]);
29+
}
30+
},
31+
32+
async down(queryInterface, Sequelize) {
33+
await queryInterface.bulkDelete('Settings', { key: 'smtp_url' });
34+
await queryInterface.bulkDelete('Settings', { key: 'smtp_noreply_address' });
35+
}
36+
};
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
'use strict';
2+
const {
3+
Model
4+
} = require('sequelize');
5+
const argon2 = require('argon2');
6+
7+
module.exports = (sequelize, DataTypes) => {
8+
class ApiKey extends Model {
9+
/**
10+
* Helper method for defining associations.
11+
* This method is not a part of Sequelize lifecycle.
12+
* The `models/index` file will call this method automatically.
13+
*/
14+
static associate(models) {
15+
ApiKey.belongsTo(models.User, {
16+
foreignKey: 'uidNumber',
17+
as: 'user'
18+
});
19+
}
20+
21+
/**
22+
* Validates a plaintext API key against the stored encrypted key
23+
* @param {string} plainKey - The plaintext API key to validate
24+
* @returns {boolean} - True if the key matches, false otherwise
25+
*/
26+
async validateKey(plainKey) {
27+
return await argon2.verify(this.keyHash, plainKey);
28+
}
29+
30+
/**
31+
* Updates the lastUsedAt timestamp
32+
*/
33+
async recordUsage() {
34+
this.lastUsedAt = new Date();
35+
await this.save({ fields: ['lastUsedAt'] });
36+
}
37+
}
38+
39+
ApiKey.init({
40+
id: {
41+
type: DataTypes.UUID,
42+
defaultValue: DataTypes.UUIDV4,
43+
primaryKey: true,
44+
allowNull: false
45+
},
46+
uidNumber: {
47+
type: DataTypes.INTEGER,
48+
allowNull: false,
49+
references: {
50+
model: 'Users',
51+
key: 'uidNumber'
52+
},
53+
onUpdate: 'CASCADE',
54+
onDelete: 'CASCADE'
55+
},
56+
keyPrefix: {
57+
type: DataTypes.STRING(8),
58+
allowNull: false,
59+
comment: 'First 8 characters of the API key for identification'
60+
},
61+
keyHash: {
62+
type: DataTypes.STRING(255),
63+
allowNull: false,
64+
comment: 'Argon2 hash of the full API key'
65+
},
66+
description: {
67+
type: DataTypes.STRING(255),
68+
allowNull: true,
69+
comment: 'User-provided description of the API key purpose'
70+
},
71+
lastUsedAt: {
72+
type: DataTypes.DATE,
73+
allowNull: true,
74+
comment: 'Timestamp of when this key was last used'
75+
}
76+
}, {
77+
sequelize,
78+
modelName: 'ApiKey',
79+
tableName: 'ApiKeys',
80+
timestamps: true,
81+
indexes: [
82+
{
83+
fields: ['uidNumber']
84+
},
85+
{
86+
fields: ['keyPrefix']
87+
}
88+
],
89+
hooks: {
90+
beforeCreate: async (apiKey, options) => {
91+
if (!apiKey.keyHash) {
92+
throw new Error('keyHash must be provided before creating an API key');
93+
}
94+
if (!apiKey.keyPrefix) {
95+
throw new Error('keyPrefix must be provided before creating an API key');
96+
}
97+
}
98+
}
99+
});
100+
101+
return ApiKey;
102+
};

0 commit comments

Comments
 (0)