Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Two Factor Authentication #6977

Closed
wants to merge 25 commits into from
Closed

Two Factor Authentication #6977

wants to merge 25 commits into from

Conversation

dblythy
Copy link
Member

@dblythy dblythy commented Oct 27, 2020

Hello again,

This PR creates a feature that has been long requested, and builds off #5306.

By setting

multiFactorAuth : {
enabled:true,
encryptionKey:'long string'
}

In your server settings, this PR will enforce mfa once users have signed up to it.

A couple of notes:

  • SDKs will need to be updated, functions in tests will need to be built out for the SDKs. Not sure how to do this myself to be honest.
  • enableMFA returns a QR code that can be displayed in apps / on sites. Users can then add it to Google Auth or whatever authenticator they use, and then they must pass the code to verifyMFA
  • Once validated, every login request will require a MFA code, which will be stored in db under _mfa (because Parse doesn't allow for numbers in keys). If multiFactorAuth.encryptionKey is set in the server settings, the secret keys will be encrypted on the server, using essentially the same algorithm that was built for the file encryption. Also sets MFAEnabled to true so that cloud code and app functionality can verify accordingly.
  • This function also returns 2 random 20 character long recovery keys that can be used to clear an accounts' MFA. Users should store this physically. It is also hashed and stored in the DB, effectively treated as passwords. In order to recover an account, a user must provide both tokens, a username, and valid password to /recoverMFA.

I imagine there will be a function user.enableMFA() and user.verifyMFA() in the future. I also think it would be cool if we could handle as much as the UI as possible.

Let me know what you think!

Closes #4024.

@codecov
Copy link

codecov bot commented Oct 27, 2020

Codecov Report

Merging #6977 (260aa24) into master (b13a6a4) will decrease coverage by 9.74%.
The diff coverage is 59.55%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #6977      +/-   ##
==========================================
- Coverage   93.87%   84.12%   -9.75%     
==========================================
  Files         169      169              
  Lines       12438    12570     +132     
==========================================
- Hits        11676    10575    -1101     
- Misses        762     1995    +1233     
Impacted Files Coverage Δ
src/Controllers/SchemaController.js 96.92% <ø> (-0.20%) ⬇️
src/Options/index.js 100.00% <ø> (ø)
...dapters/Storage/Postgres/PostgresStorageAdapter.js 2.40% <1.08%> (-93.55%) ⬇️
src/Adapters/Storage/Mongo/MongoTransform.js 88.48% <74.00%> (-0.34%) ⬇️
src/Routers/UsersRouter.js 94.69% <96.19%> (+0.32%) ⬆️
src/Config.js 91.25% <100.00%> (+0.77%) ⬆️
src/Controllers/DatabaseController.js 95.33% <100.00%> (+0.01%) ⬆️
src/Options/Definitions.js 100.00% <100.00%> (ø)
src/RestWrite.js 93.88% <100.00%> (+0.20%) ⬆️
src/Adapters/Storage/Postgres/PostgresClient.js 6.66% <0.00%> (-80.00%) ⬇️
... and 8 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update b13a6a4...260aa24. Read the comment docs.

@mtrezza
Copy link
Member

mtrezza commented Oct 27, 2020

Awesome, as I understand there are still some open questions regarding this PR, so let's continue the discussion in #4024 to see how to address the remaining obstacles, so the discussion is kept in one place. Once we have agreed on a solution, we can come back and review the updated PR.

Do you think you can post a short conceptual outline in #4024 about this PR and which parts of it still need to be solved? Maybe others from the thread have some input as well.

@dblythy
Copy link
Member Author

dblythy commented Oct 27, 2020

Thank you, I think i've covered most of the issues discussed in that thread. I was expecting changes to be made around the encryption as it's not something i've worked with before. If you wouldn't mind, could you let me know if there's anything I skimmed over, or misunderstood in the linked comment thread 😊. I'll just need to work on SDK endpoints, I'll get started on that when we're happy with the PR.

For reference, I was following this draft.

@mtrezza
Copy link
Member

mtrezza commented Oct 27, 2020

Can we call this MFA (multi-factor authentication) throughout the code, endpoints, DB fields and docs? That would make it easier for future contributors to read the code. In the docs it makes sense to additionally mention 2FA to make it easier for developers to understand / search for the feature.

Copy link
Member

@davimacedo davimacedo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the PR and the approach. I also don't see anything wrong in the code. Excellent job! @mtrezza @dplewis thoughts?

@mtrezza
Copy link
Member

mtrezza commented Oct 27, 2020

Just some thoughts:

  • Unify terminology for readability and maintainability; currently there are mfa, MFA, 2Fa, twoFactor, all referring to the same feature.
  • Consider for naming and structuring the Parse Server config option that another option to enable MFA for Parse Dashboard users may be added in the future (which I think would be important in light of the current discussion around Parse Server security)
  • Remove version wildcard from library reference
  • Are new Parse Server Error definitions for MFA required? If so, can we please see them listed to discuss their naming and error codes (they are later defined in the JS SDK).
  • In order to properly think this through, can you please describe the flows in numbered steps to
    • enable MFA
    • log in with MFA
  • Would it make sense to move the MFA logic into a new auth adapter?
  • Give others on Two-Factor Authentication #4024 some time for feedback

@dblythy
Copy link
Member Author

dblythy commented Oct 27, 2020

Thanks for the thoughts guys. I've replaced 2FA with MFA, and removed the wildcard import.

How would you best see the Parse Server config to be set up? I think from a security perspective we should strongly recommend multiFactorAuth.encryptionKey if multiFactorAuth.enabled.

New error codes:

210: MFA is not enabled. - MFA is not enabled in Parse.Server config. Happens if user.enableMFA() or user.veifyMFA() is called on a configuration that doesn’t require MFA.
211: Please provide your MFA token. - MFA is enabled on this account, so to login you need to provide a token. Called on login.
212: Invalid MFA token. - MFA token provided is invalid. Called on login and user.verifyMFA()
213: MFA is not enabled on this account. - user.enableMFA() must be called first. Called on user.verifyMFA()
214: MFA is already enabled - user is attempting to enrol in MFA but they've already set it up. Called on user.enableMFA()

Flows:

Enabling MFA:

  1. User calls endpoint /enableMFA. This returns a secret and a QR Code URL.
  2. SDKS render QRCode based on response (todo), or attempts to open otpauth:// URL if on mobile device.
  3. User scans QR code, either by long pressing on phone, or using camera. This will automatically add the QR code to their 2FA app (Google Authenticator, Authy), with the title appName and subtitle username.
  4. User is prompted to copy code from 2FA app to validate their MFA setup
  5. User passes code to endpoint /verifyMFA. Providing a correct code, this user is now enrolled in MFA, and logging in will require a MFA token. Endpoint will return 2 recovery tokens that can be used to recover the account in the case that the device is lost.

Logging in with MFA:

  1. Client calls logIn('username','password')
  2. Server responds error code 211. This is the trigger to show 'please enter your code' ui.
  3. User opens their 2FA app and copies the TOTP.
  4. Client calls logIn('username','password',token) with the provided TOTP.
  5. If the token is valid, normal login functionality resumes.

Recovering an account:

  1. Client calls recoverMFA('username','password',token1,token2)
  2. Username, password, and recovery tokens are validated
  3. Account is unlocked, user is logged in. MFA is now disabled.

EDIT: long pressing scanning on mobile only works in browser. The server would return an optauth:// URL, and if the user has Google Auth installed, opening the URL will add the token. If the user has no 2FA app installed we'd need seperate UI for that.

The URL looks like: otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example

@mtrezza
Copy link
Member

mtrezza commented Oct 27, 2020

Thanks, that was quick, can you please add the error codes into the flow at the respective steps where they may be returned? So we can check whether the error messages cover all scenarios or need rewording.

@dblythy
Copy link
Member Author

dblythy commented Oct 27, 2020

I’ve edited my comment above to provide that detail requested. I’ll also write some more tests around the expected errors.

@TomWFox
Copy link
Contributor

TomWFox commented Oct 27, 2020

This is a really exciting feature! Have you thought about generating & returning backup codes and the facility to provide one to disable MFA if required?

Not an expert myself but it seems to be what most services that have MFA do.

@dblythy
Copy link
Member Author

dblythy commented Oct 27, 2020

Thanks @TomWFox! I was expecting to add that in a future rollout. I was thinking for the initial version that could be provided via cloud code. But I don't think it would be too hard to implement on top of this feature.

@mtrezza
Copy link
Member

mtrezza commented Oct 27, 2020

Are recovery codes technically part of MFA or is that a technically separate feature?

@dblythy
Copy link
Member Author

dblythy commented Oct 27, 2020

Seperate feature I believe. From otplib:

OTP Backup Codes
It is common for services to also provide a set of backup codes to authenticate and bypass the OTP step in the event that you are not able to access your 2FA device or have misplaced the device.

As this process is separate from the specifications for OTP, this library does not provide any backup code related verification logic, and thus would have to be implemented separately.

So, I'd imagine the flow for recovery codes would be:

  1. On /verifyMFA success, create a couple of secure tokens and store in another protected field
  2. Prompt user to write down and store safely
  3. /recoverMFA is called with a secure token, which on validation disables MFA for that account.

Even when we build that out internally, I still think that developers that use our MFA would have to implement some sort of custom logic for users who lose their MFA device and their recovery tokens.

@cbaker6
Copy link
Contributor

cbaker6 commented Oct 27, 2020

This may cause an issue. encryptionKey was added in #6768. It's currently setup to encrypt files for the GridStoreAdatper (and FSAdapter). Setting it in the parse-server settings will automatically start encrypting files if the server admin is using one of the aforementioned adapters. The documentation for the adapters was recently updated (like a day ago) here: https://docs.parseplatform.org/parse-server/guide/#configuring-file-adapters

A fix would be to add an option that's not encryptionKey.

If encryptionKey is set in the server settings, the secret keys will be encrypted on the server, using essentially the same algorithm that was built for the file encryption. Also sets MFAEnabled to true so that cloud code and app functionality can verify accordingly.

Is the intention to enable file encryption and 2 authentication at the same time with one option?

@dblythy
Copy link
Member Author

dblythy commented Oct 27, 2020

Thanks for your thoughts @cbaker6. The encryptionKey for MFA is seperate to the encryptionKey for files:

encryptionKey : fileEncryptionKey,
multiFactorAuth: {
        enabled: true,
        encryptionKey: multiFactorAuthEncryptionKey,
},

The thought was to keep naming conventions consistent. Do you think this creates confusion?

Edit: I've updated the initial post to make it clearer that the PR uses multiFactorAuth.encryptionKey.

@cbaker6
Copy link
Contributor

cbaker6 commented Oct 27, 2020

The thought was to keep naming conventions consistent. However do you think this creates confusion?

Not sure, it may be okay and I may be wrong. I saw reconfigure server in the test cases, and thought the option was getting passed directly to the parse server

@cbaker6
Copy link
Contributor

cbaker6 commented Oct 27, 2020

I've updated the initial post to make it clearer that the PR uses multiFactorAuth.encryptionKey

This seems okay and shouldn't cause a conflict IMO

@cbaker6
Copy link
Contributor

cbaker6 commented Oct 27, 2020

When added to the docs, we should probably recommend that all keys (especially encryption) should be different

@dblythy
Copy link
Member Author

dblythy commented Oct 27, 2020

Not sure, it may be okay and I may be wrong. I saw reconfigure server in the test cases, and thought the option was getting passed directly to the parse server

Correct, but yeah it will never mutate encryptionKey, only multiFactorAuth.encryptionKey. I was thinking initially to use the file encryptionKey to encrypt the MFA secrets, but I haven't coded any functionality around rotation. That will have to be mentioned in the docs too, not to change multiFactorAuth.encryptionKey once set or existing MFA won't work.

I foresee these features being added in future iterations:

  • Recovery Tokens
  • Recover MFA endpoint
  • Ability to rotate multiFactorAuth.encryptionKey
  • Parse Server Passwordless multiFactorAuth.passwordless

@mtrezza
Copy link
Member

mtrezza commented Oct 27, 2020

The thought was to keep naming conventions consistent. However do you think this creates confusion?

I think it's fine to use the same name. I see the issue more in the fact that - thanks to our amazing contributors - the Parse Server feature set is constantly growing, but most options are still on the config root level. At some point it makes sense to restructure some options and group them by nesting, for better overview and reduce possible confusion.

Ability to rotate multiFactorAuth.encryptionKey

It may make sense to at least conceptualize rotation before the MFA feature is merged, so that we don't run into difficulties later on, when deployments already have existing MFA tokens.

@dblythy
Copy link
Member Author

dblythy commented Oct 27, 2020

It may make sense to at least conceptualize rotation before the MFA feature is merged, so that we don't run into difficulties later on, when deployments already have existing MFA tokens.

The decryption and encryption are already there for the MFA secret, so I don't think it would be too hard at all. I'm just not overly familiar with the best way to perform bulk updates internally.

Something like:

async rotateMFA(oldKey,newKey) {
    const users = await req.config.database.find('_User', {
      $exists: _mfa // not sure if this is the best way to get users with MFA
    });
    const saveAll = [];
    for (const user of users) {
      const mfaKey = user._mfa;
      const decrypted = await this.decryptMFAKey(mfaKey, oldKey);
      const encrypted = this.encryptMFAKey(decrypted, newKey);
      user._mfa = encrypted;
      saveAll.push(user);
    }
   // not what the best save call would be
    await Parse.User.saveAll(saveAll, {useMasterKey:true});
}

@mtrezza
Copy link
Member

mtrezza commented Oct 27, 2020

Thanks for the example code, I understand now what you mean by key rotation. You are right that bulk updating will be tricky because it needs to be scalable. Updating a collection with millions of users can take a substantial amount of time.

There are two aspects to consider:

  • How to bulk update the collection with a controllable performance impact on a live database
  • How to allow users to use MFA while the update is in progress

It seems like we need an update process that allows for two parallel keys to be used, like a mechanism, that tries either key to verify MFA. I guess due to this complexity, it would be hard to viably conceptualize it now, so we may as well ignore this for now.

@dblythy
Copy link
Member Author

dblythy commented Dec 4, 2020

I believe I have addressed your concerns @dplewis and @mtrezza.

I'll change the error codes to Parse.Error.*** once the JS SDK is updated.

Feel free to look back over in your own time. Thanks 🙏

@dblythy
Copy link
Member Author

dblythy commented Dec 4, 2020

Can't seem to get the WiredTrigger test to pass, this error is showing:

Failures:
1) ParseServerRESTController transactions should handle a batch request with transaction = true
  Message:
    Unhandled promise rejection: [object Object],[object Object]
  Stack:

I have tried running locally with:

npm test /parse-server/spec/ParseUser.MFA.spec.js MONGODB_VERSION=4.0.4 MONGODB_TOPOLOGY=replicaset MONGODB_STORAGE_ENGINE=wiredTiger NODE_VERSION=10

But the error isn't showing. Any pointers would be much appreciated.

@mtrezza
Copy link
Member

mtrezza commented Dec 4, 2020

I'll change the error codes to Parse.Error.*** once the JS SDK is updated.

That's fine, we'll just wait with merging this PR until the JS SDK PR that includes the errors is released.

The process when introducing new error codes is somewhat cumbersome unfortunately:

  1. Merge the Parse JS SDK PR
  2. Create new Parse JS SDK release
  3. (Prepare the Parse docs PR)
  4. Merge the Parse Server PR pointing to the new Parse JS SDK release
  5. Create Parse Server release
  6. (Merge the Parse docs PR)

Can't seem to get the WiredTrigger test to pass,

Could be a flaky test, you can try to close and re-open this PR to restart the CI.

@dplewis
Copy link
Member

dplewis commented Dec 4, 2020

Historically we wouldn’t wait for an SDK release, I would wait in this case. You will have to add the new endpoints to SDK as well. New features are great but require some work.

@mtrezza
Copy link
Member

mtrezza commented Dec 4, 2020

Just curious, how would we do it historically in other cases?

@dblythy
Copy link
Member Author

dblythy commented Dec 4, 2020

Historically we wouldn’t wait for an SDK release, I would wait in this case. You will have to add the new endpoints to SDK as well. New features are great but require some work.

Ok, no worries. Let me know what else needs to be done to merge.

So, if I understand correctly, wait until JS SDK update for error codes, then change this PR to include them, and then work on JS SDK endpoints, JS docs, etc?

@dplewis
Copy link
Member

dplewis commented Dec 4, 2020

Just curious, how would we do it historically in other cases?

If you look at most of the test cases they use the REST api instead of the SDK.

So, if I understand correctly, wait until JS SDK update for error codes, then change this PR to include them, and then work on JS SDK endpoints, JS docs, etc?

We have other SDKs too like PHP. It’s been a while since we’ve had a new feature that require both server and SDK updates.

@mtrezza
Copy link
Member

mtrezza commented Dec 4, 2020

If you look at most of the test cases they use the REST api instead of the SDK.

Yes, I was referring to the feature code itself. When Parse Server throws a Parse Error, these errors are defined in the JS SDK repo, not in the Parse Server repo as I understand, so also with the REST API, these error codes need to be defined in the JS SDK.

I think it makes sense to have a single definition of Parse Error codes. We should improve that at some point to also have the error messages defined there, so it cannot happen like in this PR review that there are different error messages for the same error code. Or maybe move the Parse Errors to a separate repo and reference that from every other repo. That would make introducing new Parse Errors less convoluted.

@dplewis
Copy link
Member

dplewis commented Dec 4, 2020

Sorry I gave a bad example. One new error code had been added in the last 2 years.

A SDK release is in the works with the new GitHub actions CI.

@dblythy
Copy link
Member Author

dblythy commented Dec 4, 2020

We have other SDKs too like PHP. It’s been a while since we’ve had a new feature that require both server and SDK updates.

I am happy to work on the JS SDK methods and potentially swift but the other sdk’s will probably depend on another contributor to create the functionality, which obviously will require a lot of work across all the PRs. I’m happy to have a crack at them but I’d assume I’d need support from the repo’s maintainers / you guys.

Could we potentially merge the feature undocumented/experimental to Parse Server and then “release” and document it when all the SDK methods are available? Considering this feature is likely to be widely adopted I’d think we’d need full SDK support. And it doesn’t really make sense to me to ask for help building the SDK for a feature based on a PR that may or may not be merged to the master.

@dplewis
Copy link
Member

dplewis commented Dec 4, 2020

Let’s focus on one feature at a time. We’ll figure it out in the end.

src/Options/docs.js Outdated Show resolved Hide resolved
@mtrezza
Copy link
Member

mtrezza commented Dec 6, 2020

I have opened issue parse-community/Parse-SDK-JS#1269 to demonstrate the current Parse Error inconsistencies and problems arising from there. I will introduce a process for how to add new error codes to avoid making the current mess even worse.

Therefore I suggest we wait for the release of the Parse JS SDK and replace error codes here with references to the error codes in the Parse JS SDK before merging this PR. We should establish a single source as reference to determine (un)used Parse Error codes, which is currently supposed to be the Parse JS SDK.

Copy link
Member

@Moumouls Moumouls left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since i'm gonna to work on the Webauthn system and after some check, i think here we should improve our AuthAdapter interface, since the MFA implementation should be feasible through the AuthAdapter. May be the MFA adapter could have some special handling into the code. But i'm in favor of improving AuthAdapter interface and triggers to allow this kind of addition touching as little as possible Routers Controllers.

Dependency injection and Adapter strategy is the source of the power of parse server and i think we should keep this strategy, by improving adapters capabilities.

@dblythy @davimacedo @dplewis ?
What do you think ?

@dblythy I can propose you to collaborate together on the new interface since we have 2 interesting case studies to manage MFA and Webauthn, then after the PR of the new interface we will be able to implement easily and quickly the new systems.

Comment on lines +145 to +159
for (const recToken of mfaRecTokens) {
const setAllowedFromMatch = async (recoveryKey, first) => {
const doesMatch = await passwordCrypto.compare(recoveryKey, recToken);
if (!doesMatch) {
return;
}
if (first) {
firstAllowed = true;
} else {
secondAllowed = true;
}
};
await setAllowedFromMatch(recoveryKeysStr.substring(0, 20), true);
await setAllowedFromMatch(recoveryKeysStr.substring(21, 41));
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should avoid for loop with await, use Promise.all or Promise.race

Comment on lines +364 to +368
const encryption = crypto
.createHash('sha256')
.update(String(encryptionKey))
.digest('base64')
.substr(0, 32);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

avoid duplicated code with encryptMfaKey, may be you should create a separate little util function

@Moumouls
Copy link
Member

Moumouls commented Dec 8, 2020

will probably need: #7050

@dblythy
Copy link
Member Author

dblythy commented Dec 12, 2020

I have closed this as @Moumouls is working on #7050 to be able to support more advanced auth adapters, and I think it makes more sense to handle MFA in an adapter as this could allow custom SMS / passwordless auth. I hope to work on a new MFA adapter that can be included as part of the master repo, after Antoine completes the new adapter handling.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Two-Factor Authentication
7 participants