feat: Add TOTP authentication adapter (#8457)

This commit is contained in:
Daniel
2023-06-24 01:57:57 +10:00
committed by GitHub
parent 3ec3e40dc8
commit cc079a40f6
10 changed files with 580 additions and 19 deletions

View File

@@ -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

View File

@@ -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
View 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();

View File

@@ -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: {} };

View File

@@ -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

View File

@@ -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;

View File

@@ -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';