Skip to content

Commit

Permalink
feat: Add TOTP authentication adapter (#8457)
Browse files Browse the repository at this point in the history
  • Loading branch information
dblythy committed Jun 23, 2023
1 parent 3ec3e40 commit cc079a4
Show file tree
Hide file tree
Showing 10 changed files with 580 additions and 19 deletions.
35 changes: 34 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Expand Up @@ -48,6 +48,7 @@
"mime": "3.0.0",
"mongodb": "4.10.0",
"mustache": "4.2.0",
"otpauth": "9.0.2",
"parse": "4.1.0",
"path-to-regexp": "6.2.1",
"pg-monitor": "2.0.0",
Expand Down
295 changes: 295 additions & 0 deletions spec/AuthenticationAdapters.spec.js
Expand Up @@ -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();
});
});
4 changes: 3 additions & 1 deletion src/Adapters/Auth/AuthAdapter.js
Expand Up @@ -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
Expand Down

0 comments on commit cc079a4

Please sign in to comment.