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();
|
||||
22
src/Auth.js
22
src/Auth.js
@@ -407,6 +407,7 @@ const hasMutatedAuthData = (authData, userAuthData) => {
|
||||
};
|
||||
|
||||
const checkIfUserHasProvidedConfiguredProvidersForLogin = (
|
||||
req = {},
|
||||
authData = {},
|
||||
userAuthData = {},
|
||||
config
|
||||
@@ -430,7 +431,16 @@ const checkIfUserHasProvidedConfiguredProvidersForLogin = (
|
||||
|
||||
const additionProvidersNotFound = [];
|
||||
const hasProvidedAtLeastOneAdditionalProvider = savedUserProviders.some(provider => {
|
||||
if (provider && provider.adapter && provider.adapter.policy === 'additional') {
|
||||
let policy = provider.adapter.policy;
|
||||
if (typeof policy === 'function') {
|
||||
const requestObject = {
|
||||
ip: req.config.ip,
|
||||
user: req.auth.user,
|
||||
master: req.auth.isMaster,
|
||||
};
|
||||
policy = policy.call(provider.adapter, requestObject, userAuthData[provider.name]);
|
||||
}
|
||||
if (policy === 'additional') {
|
||||
if (authData[provider.name]) {
|
||||
return true;
|
||||
} else {
|
||||
@@ -467,14 +477,8 @@ const handleAuthDataValidation = async (authData, req, foundUser) => {
|
||||
await user.fetch({ useMasterKey: true });
|
||||
}
|
||||
|
||||
const { originalObject, updatedObject } = req.buildParseObjects();
|
||||
const requestObject = getRequestObject(
|
||||
undefined,
|
||||
req.auth,
|
||||
updatedObject,
|
||||
originalObject || user,
|
||||
req.config
|
||||
);
|
||||
const { updatedObject } = req.buildParseObjects();
|
||||
const requestObject = getRequestObject(undefined, req.auth, updatedObject, user, req.config);
|
||||
// Perform validation as step-by-step pipeline for better error consistency
|
||||
// and also to avoid to trigger a provider (like OTP SMS) if another one fails
|
||||
const acc = { authData: {}, authDataResponse: {} };
|
||||
|
||||
@@ -556,6 +556,7 @@ RestWrite.prototype.handleAuthData = async function (authData) {
|
||||
// we need to be sure that the user has provided
|
||||
// required authData
|
||||
Auth.checkIfUserHasProvidedConfiguredProvidersForLogin(
|
||||
{ config: this.config, auth: this.auth },
|
||||
authData,
|
||||
userResult.authData,
|
||||
this.config
|
||||
|
||||
@@ -189,7 +189,12 @@ export class UsersRouter extends ClassesRouter {
|
||||
const user = await this._authenticateUserFromRequest(req);
|
||||
const authData = req.body && req.body.authData;
|
||||
// Check if user has provided their required auth providers
|
||||
Auth.checkIfUserHasProvidedConfiguredProvidersForLogin(authData, user.authData, req.config);
|
||||
Auth.checkIfUserHasProvidedConfiguredProvidersForLogin(
|
||||
req,
|
||||
authData,
|
||||
user.authData,
|
||||
req.config
|
||||
);
|
||||
|
||||
let authDataResponse;
|
||||
let validatedAuthData;
|
||||
|
||||
@@ -7,7 +7,6 @@ import LRUCacheAdapter from './Adapters/Cache/LRUCache.js';
|
||||
import * as TestUtils from './TestUtils';
|
||||
import * as SchemaMigrations from './SchemaMigrations/Migrations';
|
||||
import AuthAdapter from './Adapters/Auth/AuthAdapter';
|
||||
|
||||
import { useExternal } from './deprecated';
|
||||
import { getLogger } from './logger';
|
||||
import { PushWorker } from './Push/PushWorker';
|
||||
|
||||
Reference in New Issue
Block a user