Finish MFA implementation

This commit is contained in:
Julian Gassner
2025-02-05 07:05:15 +00:00
parent 35938db24b
commit 8aa173a732
20 changed files with 7840 additions and 1504 deletions

75
backend/internal/mfa.js Normal file
View File

@@ -0,0 +1,75 @@
const authModel = require('../models/auth');
const error = require('../lib/error');
const speakeasy = require('speakeasy');
module.exports = {
validateMfaTokenForUser: (userId, token) => {
return authModel
.query()
.where('user_id', userId)
.first()
.then((auth) => {
if (!auth || !auth.mfa_enabled) {
throw new error.AuthError('MFA is not enabled for this user.');
}
const verified = speakeasy.totp.verify({
secret: auth.mfa_secret,
encoding: 'base32',
token: token,
window: 2
});
if (!verified) {
throw new error.AuthError('Invalid MFA token.');
}
return true;
});
},
isMfaEnabledForUser: (userId) => {
return authModel
.query()
.where('user_id', userId)
.first()
.then((auth) => {
if (!auth) {
throw new error.AuthError('User not found.');
}
return auth.mfa_enabled === 1;
});
},
createMfaSecretForUser: (userId) => {
const secret = speakeasy.generateSecret({ length: 20 });
console.log(secret);
return authModel
.query()
.where('user_id', userId)
.update({
mfa_secret: secret.base32
})
.then(() => secret);
},
enableMfaForUser: (userId, token) => {
return authModel
.query()
.where('user_id', userId)
.first()
.then((auth) => {
if (!auth || !auth.mfa_secret) {
throw new error.AuthError('MFA is not set up for this user.');
}
const verified = speakeasy.totp.verify({
secret: auth.mfa_secret,
encoding: 'base32',
token: token,
window: 2
});
if (!verified) {
throw new error.AuthError('Invalid MFA token.');
}
return authModel
.query()
.where('user_id', userId)
.update({ mfa_enabled: 1 })
.then(() => true);
});
},
};

View File

@@ -4,6 +4,7 @@ const userModel = require('../models/user');
const authModel = require('../models/auth');
const helpers = require('../lib/helpers');
const TokenModel = require('../models/token');
const mfa = require('../internal/mfa'); // <-- added MFA import
const ERROR_MESSAGE_INVALID_AUTH = 'Invalid email or password';
@@ -21,6 +22,8 @@ module.exports = {
getTokenFromEmail: (data, issuer) => {
let Token = new TokenModel();
console.log(data);
data.scope = data.scope || 'user';
data.expiry = data.expiry || '1d';
@@ -41,34 +44,66 @@ module.exports = {
.then((auth) => {
if (auth) {
return auth.verifyPassword(data.secret)
.then((valid) => {
.then(async (valid) => {
if (valid) {
if (data.scope !== 'user' && _.indexOf(user.roles, data.scope) === -1) {
// The scope requested doesn't exist as a role against the user,
// you shall not pass.
throw new error.AuthError('Invalid scope: ' + data.scope);
}
return await mfa.isMfaEnabledForUser(user.id)
.then((mfaEnabled) => {
if (mfaEnabled) {
if (!data.mfa_token) {
throw new error.AuthError('MFA token required');
}
console.log(data.mfa_token);
return mfa.validateMfaTokenForUser(user.id, data.mfa_token)
.then((mfaValid) => {
if (!mfaValid) {
throw new error.AuthError('Invalid MFA token');
}
// Create a moment of the expiry expression
let expiry = helpers.parseDatePeriod(data.expiry);
if (expiry === null) {
throw new error.AuthError('Invalid expiry time: ' + data.expiry);
}
// Create a moment of the expiry expression
let expiry = helpers.parseDatePeriod(data.expiry);
if (expiry === null) {
throw new error.AuthError('Invalid expiry time: ' + data.expiry);
}
return Token.create({
iss: issuer || 'api',
attrs: {
id: user.id
},
scope: [data.scope],
expiresIn: data.expiry
})
.then((signed) => {
return {
token: signed.token,
expires: expiry.toISOString()
};
});
});
} else {
// Create a moment of the expiry expression
let expiry = helpers.parseDatePeriod(data.expiry);
if (expiry === null) {
throw new error.AuthError('Invalid expiry time: ' + data.expiry);
}
return Token.create({
iss: issuer || 'api',
attrs: {
id: user.id
},
scope: [data.scope],
expiresIn: data.expiry
})
.then((signed) => {
return {
token: signed.token,
expires: expiry.toISOString()
};
return Token.create({
iss: issuer || 'api',
attrs: {
id: user.id
},
scope: [data.scope],
expiresIn: data.expiry
})
.then((signed) => {
return {
token: signed.token,
expires: expiry.toISOString()
};
});
}
});
} else {
throw new error.AuthError(ERROR_MESSAGE_INVALID_AUTH);

View File

@@ -7,8 +7,6 @@ const authModel = require('../models/auth');
const gravatar = require('gravatar');
const internalToken = require('./token');
const internalAuditLog = require('./audit-log');
const authenticator = require('authenticator');
const qrcode = require('qrcode');
function omissions () {
return ['is_deleted'];
@@ -511,35 +509,6 @@ const internalUser = {
});
},
createMFAKey: (access, data) => {
return access.can('users:activate_mfa', data.id)
.then(() => {
return internalUser.get(access, {id: data.id});
})
.then((user) => {
let secret = authenticator.generateKey();
return userModel
.query()
.patchAndFetchById(user.id, { mfa_key: secret })
.then(() => {
let uri = authenticator.generateTotpUri(secret, user.email, 'NginxProxyManager');
return qrcode.toDataURL(uri);
})
.then((qrCode) => {
return { user, qrCode };
});
})
.then(({ user, qrCode }) => {
return internalAuditLog.add(access, {
action: 'updated',
object_type: 'user',
object_id: user.id,
meta: data
})
.then(() => ({ user, qrCode }));
});
}
};
module.exports = internalUser;