feat: Add TOTP authentication adapter (#8457)
This commit is contained in:
@@ -21,7 +21,9 @@ export class AuthAdapter {
|
||||
* Usage policy
|
||||
* @type {AuthPolicy}
|
||||
*/
|
||||
this.policy = 'default';
|
||||
if (!this.policy) {
|
||||
this.policy = 'default';
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param appIds The specified app IDs in the configuration
|
||||
|
||||
@@ -9,6 +9,7 @@ const facebook = require('./facebook');
|
||||
const instagram = require('./instagram');
|
||||
const linkedin = require('./linkedin');
|
||||
const meetup = require('./meetup');
|
||||
import mfa from './mfa';
|
||||
const google = require('./google');
|
||||
const github = require('./github');
|
||||
const twitter = require('./twitter');
|
||||
@@ -44,6 +45,7 @@ const providers = {
|
||||
instagram,
|
||||
linkedin,
|
||||
meetup,
|
||||
mfa,
|
||||
google,
|
||||
github,
|
||||
twitter,
|
||||
@@ -75,7 +77,11 @@ function authDataValidator(provider, adapter, appIds, options) {
|
||||
if (appIds && typeof adapter.validateAppId === 'function') {
|
||||
await Promise.resolve(adapter.validateAppId(appIds, authData, options, requestObject));
|
||||
}
|
||||
if (adapter.policy && !authAdapterPolicies[adapter.policy]) {
|
||||
if (
|
||||
adapter.policy &&
|
||||
!authAdapterPolicies[adapter.policy] &&
|
||||
typeof adapter.policy !== 'function'
|
||||
) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OTHER_CAUSE,
|
||||
'AuthAdapter policy is not configured correctly. The value must be either "solo", "additional", "default" or undefined (will be handled as "default")'
|
||||
@@ -225,17 +231,20 @@ module.exports = function (authOptions = {}, enableAnonymousUsers = true) {
|
||||
if (!authAdapter) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
adapter: { afterFind },
|
||||
providerOptions,
|
||||
} = authAdapter;
|
||||
const { adapter, providerOptions } = authAdapter;
|
||||
const afterFind = adapter.afterFind;
|
||||
if (afterFind && typeof afterFind === 'function') {
|
||||
const requestObject = {
|
||||
ip: req.config.ip,
|
||||
user: req.auth.user,
|
||||
master: req.auth.isMaster,
|
||||
};
|
||||
const result = afterFind(requestObject, authData[provider], providerOptions);
|
||||
const result = afterFind.call(
|
||||
adapter,
|
||||
requestObject,
|
||||
authData[provider],
|
||||
providerOptions
|
||||
);
|
||||
if (result) {
|
||||
authData[provider] = result;
|
||||
}
|
||||
|
||||
212
src/Adapters/Auth/mfa.js
Normal file
212
src/Adapters/Auth/mfa.js
Normal file
@@ -0,0 +1,212 @@
|
||||
import { TOTP, Secret } from 'otpauth';
|
||||
import { randomString } from '../../cryptoUtils';
|
||||
import AuthAdapter from './AuthAdapter';
|
||||
class MFAAdapter extends AuthAdapter {
|
||||
validateOptions(opts) {
|
||||
const validOptions = opts.options;
|
||||
if (!Array.isArray(validOptions)) {
|
||||
throw 'mfa.options must be an array';
|
||||
}
|
||||
this.sms = validOptions.includes('SMS');
|
||||
this.totp = validOptions.includes('TOTP');
|
||||
if (!this.sms && !this.totp) {
|
||||
throw 'mfa.options must include SMS or TOTP';
|
||||
}
|
||||
const digits = opts.digits || 6;
|
||||
const period = opts.period || 30;
|
||||
if (typeof digits !== 'number') {
|
||||
throw 'mfa.digits must be a number';
|
||||
}
|
||||
if (typeof period !== 'number') {
|
||||
throw 'mfa.period must be a number';
|
||||
}
|
||||
if (digits < 4 || digits > 10) {
|
||||
throw 'mfa.digits must be between 4 and 10';
|
||||
}
|
||||
if (period < 10) {
|
||||
throw 'mfa.period must be greater than 10';
|
||||
}
|
||||
const sendSMS = opts.sendSMS;
|
||||
if (this.sms && typeof sendSMS !== 'function') {
|
||||
throw 'mfa.sendSMS callback must be defined when using SMS OTPs';
|
||||
}
|
||||
this.smsCallback = sendSMS;
|
||||
this.digits = digits;
|
||||
this.period = period;
|
||||
this.algorithm = opts.algorithm || 'SHA1';
|
||||
}
|
||||
validateSetUp(mfaData) {
|
||||
if (mfaData.mobile && this.sms) {
|
||||
return this.setupMobileOTP(mfaData.mobile);
|
||||
}
|
||||
if (this.totp) {
|
||||
return this.setupTOTP(mfaData);
|
||||
}
|
||||
throw 'Invalid MFA data';
|
||||
}
|
||||
async validateLogin(token, _, req) {
|
||||
const saveResponse = {
|
||||
doNotSave: true,
|
||||
};
|
||||
const auth = req.original.get('authData') || {};
|
||||
const { secret, recovery, mobile, token: saved, expiry } = auth.mfa || {};
|
||||
if (this.sms && mobile) {
|
||||
if (typeof token === 'boolean') {
|
||||
const { token: sendToken, expiry } = await this.sendSMS(mobile);
|
||||
auth.mfa.token = sendToken;
|
||||
auth.mfa.expiry = expiry;
|
||||
req.object.set('authData', auth);
|
||||
await req.object.save(null, { useMasterKey: true });
|
||||
throw 'Please enter the token';
|
||||
}
|
||||
if (!saved || token !== saved) {
|
||||
throw 'Invalid MFA token 1';
|
||||
}
|
||||
if (new Date() > expiry) {
|
||||
throw 'Invalid MFA token 2';
|
||||
}
|
||||
delete auth.mfa.token;
|
||||
delete auth.mfa.expiry;
|
||||
return {
|
||||
save: auth.mfa,
|
||||
};
|
||||
}
|
||||
if (this.totp) {
|
||||
if (typeof token !== 'string') {
|
||||
throw 'Invalid MFA token';
|
||||
}
|
||||
if (!secret) {
|
||||
return saveResponse;
|
||||
}
|
||||
if (recovery[0] === token || recovery[1] === token) {
|
||||
return saveResponse;
|
||||
}
|
||||
const totp = new TOTP({
|
||||
algorithm: this.algorithm,
|
||||
digits: this.digits,
|
||||
period: this.period,
|
||||
secret: Secret.fromBase32(secret),
|
||||
});
|
||||
const valid = totp.validate({
|
||||
token,
|
||||
});
|
||||
if (valid === null) {
|
||||
throw 'Invalid MFA token';
|
||||
}
|
||||
}
|
||||
return saveResponse;
|
||||
}
|
||||
validateUpdate(authData, _, req) {
|
||||
if (req.master) {
|
||||
return;
|
||||
}
|
||||
if (authData.mobile && this.sms) {
|
||||
if (!authData.token) {
|
||||
throw 'MFA is already set up on this account';
|
||||
}
|
||||
return this.confirmSMSOTP(authData, req.original.get('authData')?.mfa || {});
|
||||
}
|
||||
if (this.totp) {
|
||||
this.validateLogin(authData.old, null, req);
|
||||
return this.validateSetUp(authData);
|
||||
}
|
||||
throw 'Invalid MFA data';
|
||||
}
|
||||
afterFind(req, authData) {
|
||||
if (req.master) {
|
||||
return;
|
||||
}
|
||||
if (this.totp && authData.secret) {
|
||||
return {
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
if (this.sms && authData.mobile) {
|
||||
return {
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
policy(req, auth) {
|
||||
if (this.sms && auth?.pending && Object.keys(auth).length === 1) {
|
||||
return 'default';
|
||||
}
|
||||
return 'additional';
|
||||
}
|
||||
|
||||
async setupMobileOTP(mobile) {
|
||||
const { token, expiry } = await this.sendSMS(mobile);
|
||||
return {
|
||||
save: {
|
||||
pending: {
|
||||
[mobile]: {
|
||||
token,
|
||||
expiry,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async sendSMS(mobile) {
|
||||
if (!/^[+]*[(]{0,1}[0-9]{1,3}[)]{0,1}[-\s\./0-9]*$/g.test(mobile)) {
|
||||
throw 'Invalid mobile number.';
|
||||
}
|
||||
let token = '';
|
||||
while (token.length < this.digits) {
|
||||
token += randomString(10).replace(/\D/g, '');
|
||||
}
|
||||
token = token.substring(0, this.digits);
|
||||
await Promise.resolve(this.smsCallback(token, mobile));
|
||||
const expiry = new Date(new Date().getTime() + this.period * 1000);
|
||||
return { token, expiry };
|
||||
}
|
||||
|
||||
async confirmSMSOTP(inputData, authData) {
|
||||
const { mobile, token } = inputData;
|
||||
if (!authData.pending?.[mobile]) {
|
||||
throw 'This number is not pending';
|
||||
}
|
||||
const pendingData = authData.pending[mobile];
|
||||
if (token !== pendingData.token) {
|
||||
throw 'Invalid MFA token';
|
||||
}
|
||||
if (new Date() > pendingData.expiry) {
|
||||
throw 'Invalid MFA token';
|
||||
}
|
||||
delete authData.pending[mobile];
|
||||
authData.mobile = mobile;
|
||||
return {
|
||||
save: authData,
|
||||
};
|
||||
}
|
||||
|
||||
setupTOTP(mfaData) {
|
||||
const { secret, token } = mfaData;
|
||||
if (!secret || !token || secret.length < 20) {
|
||||
throw 'Invalid MFA data';
|
||||
}
|
||||
const totp = new TOTP({
|
||||
algorithm: this.algorithm,
|
||||
digits: this.digits,
|
||||
period: this.period,
|
||||
secret: Secret.fromBase32(secret),
|
||||
});
|
||||
const valid = totp.validate({
|
||||
token,
|
||||
});
|
||||
if (valid === null) {
|
||||
throw 'Invalid MFA token';
|
||||
}
|
||||
const recovery = [randomString(30), randomString(30)];
|
||||
return {
|
||||
response: { recovery },
|
||||
save: { secret, recovery },
|
||||
};
|
||||
}
|
||||
}
|
||||
export default new MFAAdapter();
|
||||
Reference in New Issue
Block a user