Set password with token, disable password changes via patch/replace#3350
Set password with token, disable password changes via patch/replace#3350
Conversation
common/models/user.js
Outdated
| * @promise | ||
| */ | ||
| User.setPassword = function(userId, newPassword, options, cb) { | ||
| assert(!!userId, 'resetToken is a required argument'); |
There was a problem hiding this comment.
The argument name should be resetToken instead of userId.
|
|
||
| // Make sure to use the constructor of the (sub)class | ||
| // where the method is invoked from (`this` instead of `User`) | ||
| this.findById(userId, options, (err, inst) => { |
There was a problem hiding this comment.
IIUC, userId should be resetToken.userId, right?
raymondfeng
left a comment
There was a problem hiding this comment.
Please address my comments.
25d1ce5 to
b0de97d
Compare
ebarault
left a comment
There was a problem hiding this comment.
Very nice first PR leveraging new token's access scopes 👍 .
This looks good to me in general, see my comments.
common/models/user.js
Outdated
| ttl: ttl, | ||
| scopes: ['reset-password'], | ||
| }; | ||
| user.createAccessToken(params, onTokenCreated); |
There was a problem hiding this comment.
i find the createAccessToken() method signature confusing with regard to how it's called here: see here in this file
User.prototype.createAccessToken = function(ttl, options, cb) {...}
At least the jsdoc does not refer to ttl in the options argument, neither does it states the ttl argument as optional.
Also this reference to singular scope in method's options seems to be older than the scopes we are referring to here (refers to scope.js) and will confuse people.
Considering that we might want to use this options (i don't see it right now) argument to forward remote context downstream, it adds to the underlying complexity (non explicit magic under the hood) IMO (where is the appId param of options argument handled btw?)
At the minimum it should be addressed in the jsdoc.
common/models/user.js
Outdated
| // to turn off this password-change check | ||
|
|
||
| // Verify that only the password is changed and nothing more or less. | ||
| const data = ctx.data || ctx.instance; |
There was a problem hiding this comment.
suggestion: group ctx.data || ctx.instance as a shared data const at the beginning of the hook as it is also used few lines above
common/models/user.js
Outdated
| // The password can be always set when creating a new User instance | ||
| return next(); | ||
| } | ||
| const isPasswordChange = 'password' in (ctx.data || ctx.instance); |
There was a problem hiding this comment.
curiosity : is it more/less efficient than doing a string search?.
There was a problem hiding this comment.
What do you mean by string search? Could you paste a code snippet showing what do you have in mind?
test/authorization-scopes.test.js
Outdated
| } | ||
|
|
||
| function givenRemoteMethodWithCustomScope() { | ||
| // Delete any previosly registered instance of the method "scoped" |
There was a problem hiding this comment.
I am afraid I don't see the typo, could you please be more specific?
common/models/user.js
Outdated
|
|
||
| if (ctx.options.setPassword) { | ||
| // This is the option used by `setPassword()` API | ||
| // to turn off this password-change check |
There was a problem hiding this comment.
i find the comment ambiguous
why not something like
"This is the option set by setPassword() API when calling this.patchAttritubes() to change user's password" ?
test/user-password.test.js
Outdated
| }); | ||
| }); | ||
|
|
||
| function givenAppWithModernFlow() { |
There was a problem hiding this comment.
Can we find something more explicit than Modern vs. Legacy? (what if we change again)
There was a problem hiding this comment.
I see your point. Do you have any proposal in mind?
Note that the User setting is called legacyPasswordFlow right now. This setting controls whether:
POST /api/users/reset-passwordis scoped to['reset-password']- The token created by
POST /api/users/resetis granted['reset-password']scope - User's "before save" hooks rejects attempts to change the password via patch/replace
I have difficulties coming with a descriptive name that would cover all of that.
There was a problem hiding this comment.
we had a similar discussion a few month ago in another PR
Do you have arguments against naming such options and tests according to versioning and add descriptions in the changes log? (a dedicated page on options is required at this point IMO)
e.g. passwordFlow@2.y.z, passwordFlow@3.6.0 (the version being the first LB version introducing the feature)
There was a problem hiding this comment.
Do you have arguments against naming such options and tests according to versioning and add descriptions in the changes log? (a dedicated page on options is required at this point IMO)
e.g.passwordFlow@2.y.z,passwordFlow@3.6.0(the version being the first LB version introducing the feature)
I am not sure if I understand you correctly. What is the model setting name and what are the values that you are proposing?
In general, I prefer feature flags that describe the behaviour of the application. If we cannot come up with a descriptive flag name for this situation, then I guess the second best option is to introduce two feature flags:
scopePasswordResetTokencontrolling the behaviour ofPOST /api/users/reset-passwordandPOST /api/users/resetrejectPasswordChangesViaPatchOrReplaceor perhapsallowInsecurePasswordUpdatesto enable the "before save" hook
Thoughts?
| .set('Authorization', info.accessToken.id) | ||
| .expect(200)); | ||
| }); | ||
|
|
There was a problem hiding this comment.
IIUC these tests are for the legacy call flow.
I don't see the two opposite tests for the new call flow that
- creates token that prevent patching User with new password
- creates token that allow calling setPassword
- creates token that prevent calling other methods than setPassword
There was a problem hiding this comment.
these tests are for the legacy call flow.
Yes, I am keeping them here to ensure backward-compatibility is preserved. The test cases you have pointed out are covered by test/user-password.test.js:
creates token that prevent patching User with new password
See
loopback/test/user-password.test.js
Lines 30 to 32 in 745d20a
creates token that allow calling setPassword
loopback/test/user-password.test.js
Lines 58 to 60 in 745d20a
creates token that prevent calling other methods than setPassword
loopback/test/user-password.test.js
Lines 46 to 56 in 745d20a
There was a problem hiding this comment.
ok, thanks for pointing out these tests
to ease further reading, could you add context blocks to segregate tests with "legacy" password flow and tests with "modern" password flow?, as in user-password.test.js
There was a problem hiding this comment.
Well, I consider the tests in user.test.js as verifying the "default" password flow. In that light, do you still think I should move them to a context('default password flow') block?
test/user-password.test.js
Outdated
| // test passed | ||
| }); | ||
| }); | ||
| }); |
| }); | ||
|
|
||
| it('injects reset password options from remoting context', function() { | ||
| const User = app.models.User; |
There was a problem hiding this comment.
forwards vs. injects? (we shall decide which term to use)
There was a problem hiding this comment.
In the light of the discussion under #3350 (comment), maybe a better test name would be:
it('configures "options" argument to be injected from remoting context'Thoughts?
There was a problem hiding this comment.
i reviewed your other comment, let's keep it simple: i agree with inject for the root call, and forwards for downstream calls
| }); | ||
| }); | ||
|
|
||
| it('forwards the "options" argument', () => { |
There was a problem hiding this comment.
see my previous comment, here it's: forwards
There was a problem hiding this comment.
I am afraid I don't understand. Which previous comment do you mean?
There was a problem hiding this comment.
I think I found the other comment: #3350 (comment)
Let me explain why I think we should keep using both "forwards" and "injects":
The test it forwards the "options" argument verifies that the implementation of User.setPassword is forwarding theoptions argument whenever calling other User and DAO methods, to ensure that context can be correctly propagated.
The second test, "it injects reset password options from remoting context" verifies that remoting metadata of setPassword provides correct configuration for the options argument, a configuration instructing loopback/strong-remoting to inject options built from the request context.
In my mind, these two test cases are different, and therefore it makes sense to me to use different verbs to describe them.
Good point 💯 I am proposing the change the signature of createAccessToken(ttl, cb)
createAccessToken(ttl, options, cb);
createAccessToken(options, cb);and add a new form that accepts a data object as the first arg: createAccessToken(data, options, cb);thoughts? |
|
@ebarault thanks for your thoughtful review! I have addressed the first two comments, PTAL. I'll work on the remaining comments later. Most likely I won't get to this patch until next week, because Good Friday is a Public Holiday here in CZ. |
Fix the code builing a scoped method to correctly handle the case when the setup method is called twice and the previously defined method has to be overriden with new remoting metadata.
745d20a to
a5ea90e
Compare
|
@ebarault I addressed all of your review comments, either by changing the relevant code, or by starting a discussion. Could you PTAL again? |
There was a problem hiding this comment.
@bajtos : i generally approve your changes. Please see some further comments below.
Options naming convention (modern, legacy, ...) would maybe require further discussion. I let up to you whether you'd like to wait for my feedback or land with a chosen convention of your taste.
common/models/user.js
Outdated
| * or an object with token properties to set (see below). | ||
| * @property {Number} [ttl] The requested ttl | ||
| * @property {String[]} [scopes] The access scopes granted to the token. | ||
| * @param {Object} [properties] Additional options (e.g. the context). |
There was a problem hiding this comment.
LGTM 👍
regarding @param {Object} [properties] Additional options (e.g. the context).
-
it should be @param {Object} [options] ...
-
we should come up with a final (unique) description for the options object where it's meant to possibly convey remote context options. Maybe something like:
Additional options including remoting context
common/models/user.js
Outdated
| * | ||
| * @param {*} userId Id of the user changing the password | ||
| * @param {string} newPassword The new password to use. | ||
| * @param {object} [options] |
There was a problem hiding this comment.
missing description for options object
common/models/user.js
Outdated
| * current password or a password-reset token. | ||
| * | ||
| * @param {string} newPassword The new password to use. | ||
| * @param {object} [options] |
There was a problem hiding this comment.
missing description for options object
|
|
||
| // We need to modify options passed to patchAttributes, but we don't want | ||
| // to modify the original options object passed to us by setPassword caller | ||
| options = Object.assign({}, options); |
There was a problem hiding this comment.
i'm probably missing something here, but as you assign the result of Object.assign({}, options) back to options, i don't see how this does not modify the original options object.
There was a problem hiding this comment.
Consider the following code:
const options = { foo: 'bar' };
user.setPassword('pass', options, cb);
console.log(options);In my proposed implementation, the snippet prints { foo: 'bar' }.
Without the Object.assign call, the snippet would print { foo: 'bar', setPassword: true }.
In other words, the code inside setPassword is changing the value of the options argument (the reference to the options object), but it does not mutate the original object referenced by the initial options value.
I hope my comment have clarified the problem, please do keep asking if there is anything still not clear yet.
test/user-password.test.js
Outdated
| }); | ||
| }); | ||
|
|
||
| function givenAppWithModernFlow() { |
There was a problem hiding this comment.
we had a similar discussion a few month ago in another PR
Do you have arguments against naming such options and tests according to versioning and add descriptions in the changes log? (a dedicated page on options is required at this point IMO)
e.g. passwordFlow@2.y.z, passwordFlow@3.6.0 (the version being the first LB version introducing the feature)
| }); | ||
|
|
||
| it('injects reset password options from remoting context', function() { | ||
| const User = app.models.User; |
There was a problem hiding this comment.
i reviewed your other comment, let's keep it simple: i agree with inject for the root call, and forwards for downstream calls
| }); | ||
| }); | ||
|
|
||
| it('forwards the "options" argument', () => { |
| .set('Authorization', info.accessToken.id) | ||
| .expect(200)); | ||
| }); | ||
|
|
There was a problem hiding this comment.
ok, thanks for pointing out these tests
to ease further reading, could you add context blocks to segregate tests with "legacy" password flow and tests with "modern" password flow?, as in user-password.test.js
|
@ebarault thank you for another review, I think we are getting close :) AFAICT, there are three outstanding discussion points:
|
my proposal was to version the features such as password flow such as:
what about restrictResetPasswordTokenScope ? at least something with an explicit verb
rejectPasswordChangesViaPatchOrReplace 👍 |
hmm, so when the default behavior comes to be the feature flagged one ("modern password flow") we should review the code in user.test.js to match the correct behavior? I believe i'm not aware enough of the logic that leads to gather some tests in user.test.js, compared to dedicating other files to specific features. My only concern is about code maintenance when going to next major release. I leave this to you to choose the approach that minimize the code maintenance IYO. |
I see your point, but i'd need to do some tests on my side to be 100% convinced that assigning the options var inside the function block does not modify the original options arg. |
|
Feature flag naming
Done in 3dc012d Context blocks in user.test.js
Yes.
Well, I don't think there is much logic here. I am keeping existing tests to ensure full backwards compatibility is preserved.
I think we have two options:
Let's leave test cleanup for later then. Object.assign confusion
Sounds good 👍 @ebarault Could you please review the latest changes in 3dc012d? I think that's the last step before this patch can be landed. |
|
@slnode test please |
oh gosh, let's hope we'll not end there !! ;-) |
|
@slnode test please |
superkhau
left a comment
There was a problem hiding this comment.
LGTM with some minor nits.
| var loopback = require('../'); | ||
| const Promise = require('bluebird'); | ||
| var request = require('supertest'); | ||
| const waitForEvent = require('./helpers/wait-for-event'); |
There was a problem hiding this comment.
Why vars? Seems like we can use all consts.
test/user-password.test.js
Outdated
| } | ||
| }); | ||
|
|
||
| context('rejectPasswordChangesViaPatchOrReplace', () => { |
There was a problem hiding this comment.
normal sentence for consistency with context names below? 'reject password changes via patchOrReplace'?
test/user-password.test.js
Outdated
|
|
||
| let app, User, testUser, regularToken, resetToken; | ||
|
|
||
| context('restrictResetPasswordTokenScope', () => { |
There was a problem hiding this comment.
'restrict reset password token scope'?
test/user-password.test.js
Outdated
| } | ||
| }); | ||
|
|
||
| context('no limitations', () => { |
There was a problem hiding this comment.
with no restrictions/limitations? (consistent wording with line 100)
There was a problem hiding this comment.
The idea is that this context disables all new feature flags. I am going to use context('all feature flags disabled')
test/user-password.test.js
Outdated
| context('no limitations', () => { | ||
| beforeEach(givenAppWithNoRestrictions); | ||
|
|
||
| context('with regular access token', () => { |
There was a problem hiding this comment.
using regular access tokens
test/user.integration.js
Outdated
| }); | ||
| }); | ||
|
|
||
| it('resets user\'s password', function() { |
test/user.integration.js
Outdated
| .then(isMatch => expect(isMatch, 'user has new password').to.be.true()); | ||
| }); | ||
|
|
||
| it('rejects unauthenticated reset password request', function() { |
There was a problem hiding this comment.
rejects unauthenticated password reset requests
test/user.integration.js
Outdated
| .expect(401); | ||
| }); | ||
|
|
||
| it('injects reset password options from remoting context', function() { |
There was a problem hiding this comment.
uses password reset options provided by the remoting context
test/user.integration.js
Outdated
| const User = app.models.User; | ||
| const credentials = {email: 'inject-reset@example.com', password: 'pass'}; | ||
|
|
||
| let injectedOptions; |
There was a problem hiding this comment.
replace injected with remotingContextOptions? and below
There was a problem hiding this comment.
Let's use observedOptions.
test/user.test.js
Outdated
| }); | ||
| }); | ||
|
|
||
| it('fails with 401 for unknown user', () => { |
|
linting issues should be fixed now on |
Thanks! 🙇 |
|
@superkhau thank you for the detailed review, I addressed your comments in b1aeb8c. |
Implement a new method for changing user password with password-reset
token but without the old password.
REST API
POST /api/users/reset-password
Authorization: your-password-reset-token-id
Content-Type: application/json
{"newPassword": "new-pass"}
JavaScript API
User.setPassword(userId, newPassword[, cb])
userInstance.setPassword(newPassword[, cb])
Note: the new REST endpoint is not protected by scopes yet, therefore
any valid access token can invoke it (similarly to how any valid access
token can change the password via PATCH /api/users/:id).
b1aeb8c to
662b288
Compare
Improve the flow for setting/changing/resetting User password to make
it more secure.
1. Modify `User.resetPassword` to create a token scoped to allow
invocation of a single remote method: `User.setPassword`.
2. Scope the method `User.setPassword` so that regular tokens created
by `User.login` are not allowed to execute it.
For backwards compatibility, this new mode (flow) is enabled only
when User model setting `restrictResetPasswordTokenScope` is set to
`true`.
3. Changing the password via `User.prototype.patchAttributes`
(and similar DAO methods) is no longer allowed. Applications
must call `User.changePassword` and ask the user to provide
the current (old) password.
For backwards compatibility, this new mode (flow) is enabled only
when User model setting `rejectPasswordChangesViaPatchOrReplace` is set
to `true`.
662b288 to
c5ca2e1
Compare
That was a (temporary?) CI issue: |
|
Landed. Thank you everybody for keeping the fast pace, allowing me to iterate on your feedback and get this complex change landed in less than two weeks 🎉 |
Description
Add
User.setPassword(id, new, cb)Implement a new method for changing user password with password-reset token but without the old password.
REST API
JavaScript API
Implement more secure password flow
Improve the flow for setting/changing/resetting User password to make
it more secure.
Modify
User.resetPasswordto create a token scoped to allowinvocation of a single remote method:
User.setPassword.Scope the method
User.setPasswordso that regular tokens createdby
User.loginare not allowed to execute it.For backwards compatibility, this new mode (flow) is enabled only
when User model setting
restrictResetPasswordTokenScopeis set totrue.User.prototype.patchAttributes(and similar DAO methods) is no longer allowed. Applications
must call
User.changePasswordand ask the user to providethe current (old) password.
For backwards compatibility, this new mode (flow) is enabled only
when User model setting
rejectPasswordChangesViaPatchOrReplaceis set totrue.Related issues
Checklist
guide