Skip to content

Commit cc079a4

Browse files
authored
feat: Add TOTP authentication adapter (#8457)
1 parent 3ec3e40 commit cc079a4

File tree

10 files changed

+580
-19
lines changed

10 files changed

+580
-19
lines changed

package-lock.json

Lines changed: 34 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"mime": "3.0.0",
4949
"mongodb": "4.10.0",
5050
"mustache": "4.2.0",
51+
"otpauth": "9.0.2",
5152
"parse": "4.1.0",
5253
"path-to-regexp": "6.2.1",
5354
"pg-monitor": "2.0.0",

spec/AuthenticationAdapters.spec.js

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2406,3 +2406,298 @@ describe('facebook limited auth adapter', () => {
24062406
}
24072407
});
24082408
});
2409+
2410+
describe('OTP TOTP auth adatper', () => {
2411+
const headers = {
2412+
'Content-Type': 'application/json',
2413+
'X-Parse-Application-Id': 'test',
2414+
'X-Parse-REST-API-Key': 'rest',
2415+
};
2416+
beforeEach(async () => {
2417+
await reconfigureServer({
2418+
auth: {
2419+
mfa: {
2420+
enabled: true,
2421+
options: ['TOTP'],
2422+
algorithm: 'SHA1',
2423+
digits: 6,
2424+
period: 30,
2425+
},
2426+
},
2427+
});
2428+
});
2429+
2430+
it('can enroll', async () => {
2431+
const user = await Parse.User.signUp('username', 'password');
2432+
const OTPAuth = require('otpauth');
2433+
const secret = new OTPAuth.Secret();
2434+
const totp = new OTPAuth.TOTP({
2435+
algorithm: 'SHA1',
2436+
digits: 6,
2437+
period: 30,
2438+
secret,
2439+
});
2440+
const token = totp.generate();
2441+
await user.save(
2442+
{ authData: { mfa: { secret: secret.base32, token } } },
2443+
{ sessionToken: user.getSessionToken() }
2444+
);
2445+
const response = user.get('authDataResponse');
2446+
expect(response.mfa).toBeDefined();
2447+
expect(response.mfa.recovery).toBeDefined();
2448+
expect(response.mfa.recovery.length).toEqual(2);
2449+
await user.fetch();
2450+
expect(user.get('authData').mfa).toEqual({ enabled: true });
2451+
});
2452+
2453+
it('can login with valid token', async () => {
2454+
const user = await Parse.User.signUp('username', 'password');
2455+
const OTPAuth = require('otpauth');
2456+
const secret = new OTPAuth.Secret();
2457+
const totp = new OTPAuth.TOTP({
2458+
algorithm: 'SHA1',
2459+
digits: 6,
2460+
period: 30,
2461+
secret,
2462+
});
2463+
const token = totp.generate();
2464+
await user.save(
2465+
{ authData: { mfa: { secret: secret.base32, token } } },
2466+
{ sessionToken: user.getSessionToken() }
2467+
);
2468+
const response = await request({
2469+
headers,
2470+
method: 'POST',
2471+
url: 'http://localhost:8378/1/login',
2472+
body: JSON.stringify({
2473+
username: 'username',
2474+
password: 'password',
2475+
authData: {
2476+
mfa: totp.generate(),
2477+
},
2478+
}),
2479+
}).then(res => res.data);
2480+
expect(response.objectId).toEqual(user.id);
2481+
expect(response.sessionToken).toBeDefined();
2482+
expect(response.authData).toEqual({ mfa: { enabled: true } });
2483+
expect(Object.keys(response).sort()).toEqual(
2484+
[
2485+
'objectId',
2486+
'username',
2487+
'createdAt',
2488+
'updatedAt',
2489+
'authData',
2490+
'ACL',
2491+
'sessionToken',
2492+
'authDataResponse',
2493+
].sort()
2494+
);
2495+
});
2496+
2497+
it('can change OTP with valid token', async () => {
2498+
const user = await Parse.User.signUp('username', 'password');
2499+
const OTPAuth = require('otpauth');
2500+
const secret = new OTPAuth.Secret();
2501+
const totp = new OTPAuth.TOTP({
2502+
algorithm: 'SHA1',
2503+
digits: 6,
2504+
period: 30,
2505+
secret,
2506+
});
2507+
const token = totp.generate();
2508+
await user.save(
2509+
{ authData: { mfa: { secret: secret.base32, token } } },
2510+
{ sessionToken: user.getSessionToken() }
2511+
);
2512+
2513+
const new_secret = new OTPAuth.Secret();
2514+
const new_totp = new OTPAuth.TOTP({
2515+
algorithm: 'SHA1',
2516+
digits: 6,
2517+
period: 30,
2518+
secret: new_secret,
2519+
});
2520+
const new_token = new_totp.generate();
2521+
await user.save(
2522+
{
2523+
authData: { mfa: { secret: new_secret.base32, token: new_token, old: totp.generate() } },
2524+
},
2525+
{ sessionToken: user.getSessionToken() }
2526+
);
2527+
await user.fetch({ useMasterKey: true });
2528+
expect(user.get('authData').mfa.secret).toEqual(new_secret.base32);
2529+
});
2530+
2531+
it('future logins require TOTP token', async () => {
2532+
const user = await Parse.User.signUp('username', 'password');
2533+
const OTPAuth = require('otpauth');
2534+
const secret = new OTPAuth.Secret();
2535+
const totp = new OTPAuth.TOTP({
2536+
algorithm: 'SHA1',
2537+
digits: 6,
2538+
period: 30,
2539+
secret,
2540+
});
2541+
const token = totp.generate();
2542+
await user.save(
2543+
{ authData: { mfa: { secret: secret.base32, token } } },
2544+
{ sessionToken: user.getSessionToken() }
2545+
);
2546+
await expectAsync(Parse.User.logIn('username', 'password')).toBeRejectedWith(
2547+
new Parse.Error(Parse.Error.OTHER_CAUSE, 'Missing additional authData mfa')
2548+
);
2549+
});
2550+
2551+
it('future logins reject incorrect TOTP token', async () => {
2552+
const user = await Parse.User.signUp('username', 'password');
2553+
const OTPAuth = require('otpauth');
2554+
const secret = new OTPAuth.Secret();
2555+
const totp = new OTPAuth.TOTP({
2556+
algorithm: 'SHA1',
2557+
digits: 6,
2558+
period: 30,
2559+
secret,
2560+
});
2561+
const token = totp.generate();
2562+
await user.save(
2563+
{ authData: { mfa: { secret: secret.base32, token } } },
2564+
{ sessionToken: user.getSessionToken() }
2565+
);
2566+
await expectAsync(
2567+
request({
2568+
headers,
2569+
method: 'POST',
2570+
url: 'http://localhost:8378/1/login',
2571+
body: JSON.stringify({
2572+
username: 'username',
2573+
password: 'password',
2574+
authData: {
2575+
mfa: 'abcd',
2576+
},
2577+
}),
2578+
}).catch(e => {
2579+
throw e.data;
2580+
})
2581+
).toBeRejectedWith({ code: Parse.Error.SCRIPT_FAILED, error: 'Invalid MFA token' });
2582+
});
2583+
});
2584+
2585+
describe('OTP SMS auth adatper', () => {
2586+
const headers = {
2587+
'Content-Type': 'application/json',
2588+
'X-Parse-Application-Id': 'test',
2589+
'X-Parse-REST-API-Key': 'rest',
2590+
};
2591+
let code;
2592+
let mobile;
2593+
const mfa = {
2594+
enabled: true,
2595+
options: ['SMS'],
2596+
sendSMS(smsCode, number) {
2597+
expect(smsCode).toBeDefined();
2598+
expect(number).toBeDefined();
2599+
expect(smsCode.length).toEqual(6);
2600+
code = smsCode;
2601+
mobile = number;
2602+
},
2603+
digits: 6,
2604+
period: 30,
2605+
};
2606+
beforeEach(async () => {
2607+
code = '';
2608+
mobile = '';
2609+
await reconfigureServer({
2610+
auth: {
2611+
mfa,
2612+
},
2613+
});
2614+
});
2615+
2616+
it('can enroll', async () => {
2617+
const user = await Parse.User.signUp('username', 'password');
2618+
const sessionToken = user.getSessionToken();
2619+
const spy = spyOn(mfa, 'sendSMS').and.callThrough();
2620+
await user.save({ authData: { mfa: { mobile: '+11111111111' } } }, { sessionToken });
2621+
await user.fetch({ sessionToken });
2622+
expect(user.get('authData')).toEqual({ mfa: { enabled: false } });
2623+
expect(spy).toHaveBeenCalledWith(code, '+11111111111');
2624+
await user.fetch({ useMasterKey: true });
2625+
const authData = user.get('authData').mfa?.pending;
2626+
expect(authData).toBeDefined();
2627+
expect(authData['+11111111111']).toBeDefined();
2628+
expect(Object.keys(authData['+11111111111'])).toEqual(['token', 'expiry']);
2629+
2630+
await user.save({ authData: { mfa: { mobile, token: code } } }, { sessionToken });
2631+
await user.fetch({ sessionToken });
2632+
expect(user.get('authData')).toEqual({ mfa: { enabled: true } });
2633+
});
2634+
2635+
it('future logins require SMS code', async () => {
2636+
const user = await Parse.User.signUp('username', 'password');
2637+
const spy = spyOn(mfa, 'sendSMS').and.callThrough();
2638+
await user.save(
2639+
{ authData: { mfa: { mobile: '+11111111111' } } },
2640+
{ sessionToken: user.getSessionToken() }
2641+
);
2642+
2643+
await user.save(
2644+
{ authData: { mfa: { mobile, token: code } } },
2645+
{ sessionToken: user.getSessionToken() }
2646+
);
2647+
2648+
spy.calls.reset();
2649+
2650+
await expectAsync(Parse.User.logIn('username', 'password')).toBeRejectedWith(
2651+
new Parse.Error(Parse.Error.OTHER_CAUSE, 'Missing additional authData mfa')
2652+
);
2653+
const res = await request({
2654+
headers,
2655+
method: 'POST',
2656+
url: 'http://localhost:8378/1/login',
2657+
body: JSON.stringify({
2658+
username: 'username',
2659+
password: 'password',
2660+
authData: {
2661+
mfa: true,
2662+
},
2663+
}),
2664+
}).catch(e => e.data);
2665+
expect(res).toEqual({ code: Parse.Error.SCRIPT_FAILED, error: 'Please enter the token' });
2666+
expect(spy).toHaveBeenCalledWith(code, '+11111111111');
2667+
const response = await request({
2668+
headers,
2669+
method: 'POST',
2670+
url: 'http://localhost:8378/1/login',
2671+
body: JSON.stringify({
2672+
username: 'username',
2673+
password: 'password',
2674+
authData: {
2675+
mfa: code,
2676+
},
2677+
}),
2678+
}).then(res => res.data);
2679+
expect(response.objectId).toEqual(user.id);
2680+
expect(response.sessionToken).toBeDefined();
2681+
expect(response.authData).toEqual({ mfa: { enabled: true } });
2682+
expect(Object.keys(response).sort()).toEqual(
2683+
[
2684+
'objectId',
2685+
'username',
2686+
'createdAt',
2687+
'updatedAt',
2688+
'authData',
2689+
'ACL',
2690+
'sessionToken',
2691+
'authDataResponse',
2692+
].sort()
2693+
);
2694+
});
2695+
2696+
it('partially enrolled users can still login', async () => {
2697+
const user = await Parse.User.signUp('username', 'password');
2698+
await user.save({ authData: { mfa: { mobile: '+11111111111' } } });
2699+
const spy = spyOn(mfa, 'sendSMS').and.callThrough();
2700+
await Parse.User.logIn('username', 'password');
2701+
expect(spy).not.toHaveBeenCalled();
2702+
});
2703+
});

src/Adapters/Auth/AuthAdapter.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ export class AuthAdapter {
2121
* Usage policy
2222
* @type {AuthPolicy}
2323
*/
24-
this.policy = 'default';
24+
if (!this.policy) {
25+
this.policy = 'default';
26+
}
2527
}
2628
/**
2729
* @param appIds The specified app IDs in the configuration

0 commit comments

Comments
 (0)