292 lines
8.2 KiB
JavaScript
292 lines
8.2 KiB
JavaScript
/**
|
|
* Parse Server authentication adapter for Multi-Factor Authentication (MFA).
|
|
*
|
|
* @class MFAAdapter
|
|
* @param {Object} options - The adapter options.
|
|
* @param {Array<String>} options.options - Supported MFA methods. Must include `"SMS"` or `"TOTP"`.
|
|
* @param {Number} [options.digits=6] - The number of digits for the one-time password (OTP). Must be between 4 and 10.
|
|
* @param {Number} [options.period=30] - The validity period of the OTP in seconds. Must be greater than 10.
|
|
* @param {String} [options.algorithm="SHA1"] - The algorithm used for TOTP generation. Defaults to `"SHA1"`.
|
|
* @param {Function} [options.sendSMS] - A callback function for sending SMS OTPs. Required if `"SMS"` is included in `options`.
|
|
*
|
|
* @description
|
|
* ## Parse Server Configuration
|
|
* To configure Parse Server for MFA, use the following structure:
|
|
* ```javascript
|
|
* {
|
|
* auth: {
|
|
* mfa: {
|
|
* options: ["SMS", "TOTP"],
|
|
* digits: 6,
|
|
* period: 30,
|
|
* algorithm: "SHA1",
|
|
* sendSMS: (token, mobile) => {
|
|
* // Send the SMS using your preferred SMS provider.
|
|
* console.log(`Sending SMS to ${mobile} with token: ${token}`);
|
|
* }
|
|
* }
|
|
* }
|
|
* }
|
|
* ```
|
|
*
|
|
* ## MFA Methods
|
|
* - **SMS**:
|
|
* - Requires a valid mobile number.
|
|
* - Sends a one-time password (OTP) via SMS for login or verification.
|
|
* - Uses the `sendSMS` callback for sending the OTP.
|
|
*
|
|
* - **TOTP**:
|
|
* - Requires a secret key for setup.
|
|
* - Validates the user's OTP against a time-based one-time password (TOTP) generated using the secret key.
|
|
* - Supports configurable digits, period, and algorithm for TOTP generation.
|
|
*
|
|
* ## MFA Payload
|
|
* The adapter requires the following `authData` fields:
|
|
* - **For SMS-based MFA**:
|
|
* - `mobile`: The user's mobile number (required for setup).
|
|
* - `token`: The OTP provided by the user for login or verification.
|
|
* - **For TOTP-based MFA**:
|
|
* - `secret`: The TOTP secret key for the user (required for setup).
|
|
* - `token`: The OTP provided by the user for login or verification.
|
|
*
|
|
* ## Example Payloads
|
|
* ### SMS Setup Payload
|
|
* ```json
|
|
* {
|
|
* "mobile": "+1234567890"
|
|
* }
|
|
* ```
|
|
*
|
|
* ### TOTP Setup Payload
|
|
* ```json
|
|
* {
|
|
* "secret": "BASE32ENCODEDSECRET",
|
|
* "token": "123456"
|
|
* }
|
|
* ```
|
|
*
|
|
* ### Login Payload
|
|
* ```json
|
|
* {
|
|
* "token": "123456"
|
|
* }
|
|
* ```
|
|
*
|
|
* @see {@link https://en.wikipedia.org/wiki/Time-based_One-Time_Password_algorithm Time-based One-Time Password Algorithm (TOTP)}
|
|
* @see {@link https://tools.ietf.org/html/rfc6238 RFC 6238: TOTP: Time-Based One-Time Password Algorithm}
|
|
*/
|
|
|
|
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(loginData, _, req) {
|
|
const saveResponse = {
|
|
doNotSave: true,
|
|
};
|
|
const token = loginData.token;
|
|
const auth = req.original.get('authData') || {};
|
|
const { secret, recovery, mobile, token: saved, expiry } = auth.mfa || {};
|
|
if (this.sms && mobile) {
|
|
if (token === 'request') {
|
|
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;
|
|
}
|
|
async 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) {
|
|
await this.validateLogin({ token: authData.old }, null, req);
|
|
return this.validateSetUp(authData);
|
|
}
|
|
throw 'Invalid MFA data';
|
|
}
|
|
afterFind(authData, options, req) {
|
|
if (req.master) {
|
|
return;
|
|
}
|
|
if (this.totp && authData.secret) {
|
|
return {
|
|
status: 'enabled',
|
|
};
|
|
}
|
|
if (this.sms && authData.mobile) {
|
|
return {
|
|
status: 'enabled',
|
|
};
|
|
}
|
|
return {
|
|
status: 'disabled',
|
|
};
|
|
}
|
|
|
|
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: recovery.join(', ') },
|
|
save: { secret, recovery },
|
|
};
|
|
}
|
|
}
|
|
export default new MFAAdapter();
|