feat: Add TOTP authentication adapter (#8457)
This commit is contained in:
@@ -2406,3 +2406,298 @@ describe('facebook limited auth adapter', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('OTP TOTP auth adatper', () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
beforeEach(async () => {
|
||||
await reconfigureServer({
|
||||
auth: {
|
||||
mfa: {
|
||||
enabled: true,
|
||||
options: ['TOTP'],
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('can enroll', async () => {
|
||||
const user = await Parse.User.signUp('username', 'password');
|
||||
const OTPAuth = require('otpauth');
|
||||
const secret = new OTPAuth.Secret();
|
||||
const totp = new OTPAuth.TOTP({
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret,
|
||||
});
|
||||
const token = totp.generate();
|
||||
await user.save(
|
||||
{ authData: { mfa: { secret: secret.base32, token } } },
|
||||
{ sessionToken: user.getSessionToken() }
|
||||
);
|
||||
const response = user.get('authDataResponse');
|
||||
expect(response.mfa).toBeDefined();
|
||||
expect(response.mfa.recovery).toBeDefined();
|
||||
expect(response.mfa.recovery.length).toEqual(2);
|
||||
await user.fetch();
|
||||
expect(user.get('authData').mfa).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('can login with valid token', async () => {
|
||||
const user = await Parse.User.signUp('username', 'password');
|
||||
const OTPAuth = require('otpauth');
|
||||
const secret = new OTPAuth.Secret();
|
||||
const totp = new OTPAuth.TOTP({
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret,
|
||||
});
|
||||
const token = totp.generate();
|
||||
await user.save(
|
||||
{ authData: { mfa: { secret: secret.base32, token } } },
|
||||
{ sessionToken: user.getSessionToken() }
|
||||
);
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/login',
|
||||
body: JSON.stringify({
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
authData: {
|
||||
mfa: totp.generate(),
|
||||
},
|
||||
}),
|
||||
}).then(res => res.data);
|
||||
expect(response.objectId).toEqual(user.id);
|
||||
expect(response.sessionToken).toBeDefined();
|
||||
expect(response.authData).toEqual({ mfa: { enabled: true } });
|
||||
expect(Object.keys(response).sort()).toEqual(
|
||||
[
|
||||
'objectId',
|
||||
'username',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'authData',
|
||||
'ACL',
|
||||
'sessionToken',
|
||||
'authDataResponse',
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it('can change OTP with valid token', async () => {
|
||||
const user = await Parse.User.signUp('username', 'password');
|
||||
const OTPAuth = require('otpauth');
|
||||
const secret = new OTPAuth.Secret();
|
||||
const totp = new OTPAuth.TOTP({
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret,
|
||||
});
|
||||
const token = totp.generate();
|
||||
await user.save(
|
||||
{ authData: { mfa: { secret: secret.base32, token } } },
|
||||
{ sessionToken: user.getSessionToken() }
|
||||
);
|
||||
|
||||
const new_secret = new OTPAuth.Secret();
|
||||
const new_totp = new OTPAuth.TOTP({
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: new_secret,
|
||||
});
|
||||
const new_token = new_totp.generate();
|
||||
await user.save(
|
||||
{
|
||||
authData: { mfa: { secret: new_secret.base32, token: new_token, old: totp.generate() } },
|
||||
},
|
||||
{ sessionToken: user.getSessionToken() }
|
||||
);
|
||||
await user.fetch({ useMasterKey: true });
|
||||
expect(user.get('authData').mfa.secret).toEqual(new_secret.base32);
|
||||
});
|
||||
|
||||
it('future logins require TOTP token', async () => {
|
||||
const user = await Parse.User.signUp('username', 'password');
|
||||
const OTPAuth = require('otpauth');
|
||||
const secret = new OTPAuth.Secret();
|
||||
const totp = new OTPAuth.TOTP({
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret,
|
||||
});
|
||||
const token = totp.generate();
|
||||
await user.save(
|
||||
{ authData: { mfa: { secret: secret.base32, token } } },
|
||||
{ sessionToken: user.getSessionToken() }
|
||||
);
|
||||
await expectAsync(Parse.User.logIn('username', 'password')).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.OTHER_CAUSE, 'Missing additional authData mfa')
|
||||
);
|
||||
});
|
||||
|
||||
it('future logins reject incorrect TOTP token', async () => {
|
||||
const user = await Parse.User.signUp('username', 'password');
|
||||
const OTPAuth = require('otpauth');
|
||||
const secret = new OTPAuth.Secret();
|
||||
const totp = new OTPAuth.TOTP({
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret,
|
||||
});
|
||||
const token = totp.generate();
|
||||
await user.save(
|
||||
{ authData: { mfa: { secret: secret.base32, token } } },
|
||||
{ sessionToken: user.getSessionToken() }
|
||||
);
|
||||
await expectAsync(
|
||||
request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/login',
|
||||
body: JSON.stringify({
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
authData: {
|
||||
mfa: 'abcd',
|
||||
},
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw e.data;
|
||||
})
|
||||
).toBeRejectedWith({ code: Parse.Error.SCRIPT_FAILED, error: 'Invalid MFA token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('OTP SMS auth adatper', () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
let code;
|
||||
let mobile;
|
||||
const mfa = {
|
||||
enabled: true,
|
||||
options: ['SMS'],
|
||||
sendSMS(smsCode, number) {
|
||||
expect(smsCode).toBeDefined();
|
||||
expect(number).toBeDefined();
|
||||
expect(smsCode.length).toEqual(6);
|
||||
code = smsCode;
|
||||
mobile = number;
|
||||
},
|
||||
digits: 6,
|
||||
period: 30,
|
||||
};
|
||||
beforeEach(async () => {
|
||||
code = '';
|
||||
mobile = '';
|
||||
await reconfigureServer({
|
||||
auth: {
|
||||
mfa,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('can enroll', async () => {
|
||||
const user = await Parse.User.signUp('username', 'password');
|
||||
const sessionToken = user.getSessionToken();
|
||||
const spy = spyOn(mfa, 'sendSMS').and.callThrough();
|
||||
await user.save({ authData: { mfa: { mobile: '+11111111111' } } }, { sessionToken });
|
||||
await user.fetch({ sessionToken });
|
||||
expect(user.get('authData')).toEqual({ mfa: { enabled: false } });
|
||||
expect(spy).toHaveBeenCalledWith(code, '+11111111111');
|
||||
await user.fetch({ useMasterKey: true });
|
||||
const authData = user.get('authData').mfa?.pending;
|
||||
expect(authData).toBeDefined();
|
||||
expect(authData['+11111111111']).toBeDefined();
|
||||
expect(Object.keys(authData['+11111111111'])).toEqual(['token', 'expiry']);
|
||||
|
||||
await user.save({ authData: { mfa: { mobile, token: code } } }, { sessionToken });
|
||||
await user.fetch({ sessionToken });
|
||||
expect(user.get('authData')).toEqual({ mfa: { enabled: true } });
|
||||
});
|
||||
|
||||
it('future logins require SMS code', async () => {
|
||||
const user = await Parse.User.signUp('username', 'password');
|
||||
const spy = spyOn(mfa, 'sendSMS').and.callThrough();
|
||||
await user.save(
|
||||
{ authData: { mfa: { mobile: '+11111111111' } } },
|
||||
{ sessionToken: user.getSessionToken() }
|
||||
);
|
||||
|
||||
await user.save(
|
||||
{ authData: { mfa: { mobile, token: code } } },
|
||||
{ sessionToken: user.getSessionToken() }
|
||||
);
|
||||
|
||||
spy.calls.reset();
|
||||
|
||||
await expectAsync(Parse.User.logIn('username', 'password')).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.OTHER_CAUSE, 'Missing additional authData mfa')
|
||||
);
|
||||
const res = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/login',
|
||||
body: JSON.stringify({
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
authData: {
|
||||
mfa: true,
|
||||
},
|
||||
}),
|
||||
}).catch(e => e.data);
|
||||
expect(res).toEqual({ code: Parse.Error.SCRIPT_FAILED, error: 'Please enter the token' });
|
||||
expect(spy).toHaveBeenCalledWith(code, '+11111111111');
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/login',
|
||||
body: JSON.stringify({
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
authData: {
|
||||
mfa: code,
|
||||
},
|
||||
}),
|
||||
}).then(res => res.data);
|
||||
expect(response.objectId).toEqual(user.id);
|
||||
expect(response.sessionToken).toBeDefined();
|
||||
expect(response.authData).toEqual({ mfa: { enabled: true } });
|
||||
expect(Object.keys(response).sort()).toEqual(
|
||||
[
|
||||
'objectId',
|
||||
'username',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'authData',
|
||||
'ACL',
|
||||
'sessionToken',
|
||||
'authDataResponse',
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it('partially enrolled users can still login', async () => {
|
||||
const user = await Parse.User.signUp('username', 'password');
|
||||
await user.save({ authData: { mfa: { mobile: '+11111111111' } } });
|
||||
const spy = spyOn(mfa, 'sendSMS').and.callThrough();
|
||||
await Parse.User.logIn('username', 'password');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user