Skip to content

Commit 145dc81

Browse files
committed
feat: email otp login functionality
1 parent 7b353c0 commit 145dc81

File tree

1 file changed

+98
-4
lines changed

1 file changed

+98
-4
lines changed

src/Adapters/Auth/mfa.js

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* @param {Array<String>} options.options - Supported MFA methods. Must include `"SMS"` or `"TOTP"`.
77
* @param {Number} [options.digits=6] - The number of digits for the one-time password (OTP). Must be between 4 and 10.
88
* @param {Number} [options.period=30] - The validity period of the OTP in seconds. Must be greater than 10.
9+
* @param {Number} [options.emailExpiry=5*60] - The validity period of the email OTP in seconds. Must be greater than 10.
910
* @param {String} [options.algorithm="SHA1"] - The algorithm used for TOTP generation. Defaults to `"SHA1"`.
1011
* @param {Function} [options.sendSMS] - A callback function for sending SMS OTPs. Required if `"SMS"` is included in `options`.
1112
*
@@ -87,8 +88,9 @@ class MFAAdapter extends AuthAdapter {
8788
}
8889
this.sms = validOptions.includes('SMS');
8990
this.totp = validOptions.includes('TOTP');
90-
if (!this.sms && !this.totp) {
91-
throw 'mfa.options must include SMS or TOTP';
91+
this.email = validOptions.includes('EMAIL');
92+
if (!this.sms && !this.totp && !this.email) {
93+
throw 'mfa.options must include SMS or TOTP or EMAIL';
9294
}
9395
const digits = opts.digits || 6;
9496
const period = opts.period || 30;
@@ -104,11 +106,21 @@ class MFAAdapter extends AuthAdapter {
104106
if (period < 10) {
105107
throw 'mfa.period must be greater than 10';
106108
}
109+
if(this.email){
110+
if(this.emailOTPExpiry < 60){
111+
throw 'mfa.emailExpiry must be greater than 60 seconds';
112+
}
113+
}
107114
const sendSMS = opts.sendSMS;
115+
const sendEmail = opts.sendEmail;
116+
if (this.email && typeof sendEmail !== 'function') {
117+
throw 'mfa.sendEmail callback must be defined when using EMAIL OTPs';
118+
}
108119
if (this.sms && typeof sendSMS !== 'function') {
109120
throw 'mfa.sendSMS callback must be defined when using SMS OTPs';
110121
}
111122
this.smsCallback = sendSMS;
123+
this.emailCallback = sendEmail;
112124
this.digits = digits;
113125
this.period = period;
114126
this.algorithm = opts.algorithm || 'SHA1';
@@ -120,6 +132,9 @@ class MFAAdapter extends AuthAdapter {
120132
if (this.totp) {
121133
return this.setupTOTP(mfaData);
122134
}
135+
if(mfaData.email && this.email){
136+
return this.setupEmailOTP(mfaData.email);
137+
}
123138
throw 'Invalid MFA data';
124139
}
125140
async validateLogin(loginData, _, req) {
@@ -128,7 +143,28 @@ class MFAAdapter extends AuthAdapter {
128143
};
129144
const token = loginData.token;
130145
const auth = req.original.get('authData') || {};
131-
const { secret, recovery, mobile, token: saved, expiry } = auth.mfa || {};
146+
const { secret, recovery, mobile, email, token: saved, expiry } = auth.mfa || {};
147+
if (this.email && email) {
148+
if (token === 'request') {
149+
const { token: sendToken, expiry } = await this.sendEmail(email);
150+
auth.mfa.token = sendToken;
151+
auth.mfa.expiry = expiry;
152+
req.object.set('authData', auth);
153+
await req.object.save(null, { useMasterKey: true });
154+
throw 'Please enter the token';
155+
}
156+
if (!saved || token !== saved) {
157+
throw 'Invalid MFA token 1';
158+
}
159+
if (new Date() > expiry) {
160+
throw 'Invalid MFA token 2';
161+
}
162+
delete auth.mfa.token;
163+
delete auth.mfa.expiry;
164+
return {
165+
save: auth.mfa,
166+
};
167+
}
132168
if (this.sms && mobile) {
133169
if (token === 'request') {
134170
const { token: sendToken, expiry } = await this.sendSMS(mobile);
@@ -185,6 +221,13 @@ class MFAAdapter extends AuthAdapter {
185221
}
186222
return this.confirmSMSOTP(authData, req.original.get('authData')?.mfa || {});
187223
}
224+
225+
if (authData.email && this.email) {
226+
if (!authData.token) {
227+
throw 'MFA is already set up on this account';
228+
}
229+
return this.confirmEmailOTP(authData, req.original.get('authData')?.mfa || {});
230+
}
188231
if (this.totp) {
189232
await this.validateLogin({ token: authData.old }, null, req);
190233
return this.validateSetUp(authData);
@@ -205,6 +248,11 @@ class MFAAdapter extends AuthAdapter {
205248
status: 'enabled',
206249
};
207250
}
251+
if (this.email && authData.email) {
252+
return {
253+
status: 'enabled',
254+
};
255+
}
208256
return {
209257
status: 'disabled',
210258
};
@@ -231,6 +279,20 @@ class MFAAdapter extends AuthAdapter {
231279
};
232280
}
233281

282+
async setupEmailOTP(email) {
283+
const { token, expiry } = await this.sendEmail(email);
284+
return {
285+
save: {
286+
pending: {
287+
[email]: {
288+
token,
289+
expiry,
290+
},
291+
},
292+
},
293+
};
294+
}
295+
234296
async sendSMS(mobile) {
235297
if (!/^[+]*[(]{0,1}[0-9]{1,3}[)]{0,1}[-\s\./0-9]*$/g.test(mobile)) {
236298
throw 'Invalid mobile number.';
@@ -245,7 +307,20 @@ class MFAAdapter extends AuthAdapter {
245307
return { token, expiry };
246308
}
247309

248-
async confirmSMSOTP(inputData, authData) {
310+
async sendEmail(email) {
311+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
312+
throw 'Invalid email address.';
313+
}
314+
let token = '';
315+
while (token.length < this.digits) {
316+
token += randomString(10).replace(/\D/g, '');
317+
}
318+
token = token.substring(0, this.digits);
319+
await Promise.resolve(this.emailCallback(token, email));
320+
const expiry = new Date(new Date().getTime() + this.emailOTPExpiry * 1000);
321+
return { token, expiry };
322+
}
323+
async confirmSMSOTP(inputData, authData) {
249324
const { mobile, token } = inputData;
250325
if (!authData.pending?.[mobile]) {
251326
throw 'This number is not pending';
@@ -264,6 +339,25 @@ class MFAAdapter extends AuthAdapter {
264339
};
265340
}
266341

342+
async confirmEmailOTP(inputData, authData) {
343+
const { email, token } = inputData;
344+
if (!authData.pending?.[email]) {
345+
throw 'This email is not pending';
346+
}
347+
const pendingData = authData.pending[email];
348+
if (token !== pendingData.token) {
349+
throw 'Invalid MFA token';
350+
}
351+
if (new Date() > pendingData.expiry) {
352+
throw 'Invalid MFA token';
353+
}
354+
delete authData.pending[email];
355+
authData.email = email;
356+
return {
357+
save: authData,
358+
};
359+
}
360+
267361
setupTOTP(mfaData) {
268362
const { secret, token } = mfaData;
269363
if (!secret || !token || secret.length < 20) {

0 commit comments

Comments
 (0)