Allow confirming email in a different browser#1265
Conversation
e907f8a to
a75c49a
Compare
There was a problem hiding this comment.
is this method extraction to reduce the ABC size of the #create method?
There was a problem hiding this comment.
this will yield twice when true, is that intentional?
There was a problem hiding this comment.
That was not intentional, but it doesn't seem to cause a problem. However, I'll fix it so that it reads yield needs_idv. That's what I get for coding late at night.
app/models/user.rb
Outdated
There was a problem hiding this comment.
Where does @ raw_confirmation_token get set? Where does generate_confirmation_token! come from? Devise?
There was a problem hiding this comment.
Yes, I copied Devise's send_confirmation_instructions with the only modification being the ability to pass in the request_id to the mailer. I'll add a comment.
There was a problem hiding this comment.
I think overriding find_by can lead to confusing behavior especially since will never return nil.
What about making a new method?
def self.find_by_or_null(*args)
find_by(*args) || NullServiceProviderRequest.new
endThere was a problem hiding this comment.
update the comment?
it 'redirects to login with the request_id' or something?
There was a problem hiding this comment.
is this commented out on purpose?
There was a problem hiding this comment.
Yes. I noticed that we currently do not preserve the branded experience on the recovery code page. I opened an issue asking if that was intentional or not but haven't heard back yet. I would assume that we would show the branded nav bar throughout the whole creation process.
There was a problem hiding this comment.
Yep. In my previous comment I said that I opened an issue already. https://github.com/18F/identity-private/issues/1692
There was a problem hiding this comment.
this line is red from codeclimate, do we not have test coverage?
There was a problem hiding this comment.
Probably not for this scenario, which is where a user makes a request from an SP while they are already signed in with email and password only and haven't entered their OTP yet. I'll add a test.
There was a problem hiding this comment.
link to commit with spec?
There was a problem hiding this comment.
darn looks like that was squashed?
There was a problem hiding this comment.
Here you go: d1ba3b9
Scroll down to loa1_sso_spec
|
Thanks for the feedback. I addressed it in second commit. PTAL. I'll also fix conflicts with master and push again. |
2f2231c to
b077dda
Compare
b077dda to
6e1ff63
Compare
jessieay
left a comment
There was a problem hiding this comment.
Left a round of feedback. Could probably comb through more but figured I'd submit my comments so you have time to respond :)
There was a problem hiding this comment.
should an href be a _path?
There was a problem hiding this comment.
I believe it needs to be _url to include params. Plus, I think we're moving to using _url exclusively. I would assume this would fail if it had to be _path.
There was a problem hiding this comment.
We are moving to _url for redirects since that is in line with the HTTP spec but we are not moving to _url for everything. There are some benefits to _path :)
AFAIK, you don't need _url to include params/
There was a problem hiding this comment.
my memory is that _url came up only because the HTTP spec requires it for Redirect responses. That's all. I agree that _path is fine for href values etc, since those are all client-side anyway.
There was a problem hiding this comment.
I just tried changing to _path and it failed. _url is required if you want to check for params as well.
There was a problem hiding this comment.
I wouldn't necessarily expect to see expectations in a setup step like this.
There was a problem hiding this comment.
this method is AWESOME and LONG - any way to break it up into smaller named chunks for easier reading?
There was a problem hiding this comment.
Yep, I knew you would bring this up, but thought I'd get this out for review and then clean it up.
spec/models/user_spec.rb
Outdated
There was a problem hiding this comment.
did we change this behavior intentionally? If so, seems potentially unrelated to moving SP info from the session into the DB
There was a problem hiding this comment.
It is very related, and it was intentional. As mentioned in my commit message, Devise automatically calls send_confirmation_instructions when the user is created (via a model callback). Unfortunately, Devise does not make it possible to send it additional parameters that can be included in the email. Therefore, I had to create a new send_custom_confirmation_instructions method that accepts an argument, and at the same time override Devise's send_confirmation_instructions to do nothing. Otherwise, the user would receive 2 emails.
spec/features/saml/loa1_sso_spec.rb
Outdated
There was a problem hiding this comment.
I wouldn't expect to see DB queries in a feature spec. My rule of thumb is that the expectations should be looking for things that the end user can see or experience, like page content or a redirect.
Any way to test this in a controller or other type of spec instead?
There was a problem hiding this comment.
Not that I can think of. I'm open to ideas if you have them. I want to be able to isolate the creation of the ServiceProviderRequest and then make sure it gets deleted. In a controller spec, the entire action will run in one step. AFAIK, I can't use a controller spec to verify that a particular ServiceProviderRequest was created since it will get deleted after calling the action. This controller is special in that it gets called twice, but we only want some of what it does to be called once. I think this is best tested with a feature spec.
There was a problem hiding this comment.
maybe a request spec? I usually think of feature specs as "end user" focused, so no database-related expectations. Request specs are more backend-y so perhaps this would work there?
There was a problem hiding this comment.
It might, but I'm not sure it's worth writing a whole new spec that in essence duplicates what the feature spec does, just to test a single expectation.
There was a problem hiding this comment.
I would expect to see a feature test using perform_in_browser to show the full end-to-end multiple browser interplay, from the user's perspective. Am I missing seeing that?
There was a problem hiding this comment.
Ah, yes, thanks for reminding me. I meant to add a step that deletes the session. Doing that now.
config/routes.rb
Outdated
There was a problem hiding this comment.
nice! glad we are removing this (assuming it's unused). Thoughts about removing in a separate PR for clarity?
There was a problem hiding this comment.
This is not removing the A/B testing entirely. It is removing a duplicate div that was causing the LOA3 spec to fail because it found 2 elements matching the "Get started" button. I suppose I could edit the spec to use the first match and then remove this in a separate PR. What do you think?
There was a problem hiding this comment.
separate might be nice since this PR is quite large as-is, but up to you!
There was a problem hiding this comment.
now that we have the sp data in the database, why do we need to store anything other than the UUI Din the session?
There was a problem hiding this comment.
Good question. For several reasons.
- We need to store the request URL to make sure that existing users who sign in are redirected back to the SP. It's easier and faster to query the session rather than the DB, and easier than making sure the request_id is available on all forms and URLs throughout the auth process.
- The branded nav bar depends on the presence of either the issuer in the session or the request_id in the params. In the scenario where a user is signing in, we don't include the request_id params throughout the sign in process because that's only necessary for scenarios where users are switching browsers. Therefore, we need the issuer in the session.
- We have several places that check for
sp_session[:loa3]in order to do stuff. In order to replace that, we'd need to pass in therequest_idon every form and URL, and it would also mean a lot more DB lookups.
There was a problem hiding this comment.
I think @monfresh has taken the right approach. Populating the session once from the db is cleaner, since there is already a single link between the session and the browser (the session cookie).
|
Hey @monfresh I just pulled this down locally to test. CHROME:
Firefox:
after this flow, I was redirected to my profile page rather than the SP, which makes me think that the SP-related info was not being saved properly in Firefox. Am I doing something wrong? |
|
@jessieay You need to come from the sp-rails app. The |
|
Here are the scenarios I could think of and wrote tests for. Please see if I missed any.
|
|
@jessieay I believe I addressed your feedback. We need to get this in to the RC so it can be deployed and tested. If you find anything else, let me know and I'll address it in a separate PR. Thanks! |
0191a25 to
c458433
Compare
There was a problem hiding this comment.
why not actually switch browsers? perform_in_browser lets you do that.
|
Used Perform in browser here: 4e61357 |
**Why**: Some people might visit the site via a Service Provider (SP) in one browser, but open the email confirmation link in a different browser. This means that anything that was previously stored in the session in the first browser won't be available in the second browser. The consequence is that the user won't be redirected back to the SP after they finish creating their account. **How**: - Add a new ServiceProviderRequest DB table When a request is made from an SP, whether it's via SAML or OpenID Connect, we create a new ServiceProviderRequest with the following attributes: the full request URL, a random unique UUID (because it's not guaranteed that separate requests will have a unique identifier attached to them), the issuer, and LOA. We also store these same attributes in the session to support the sign in scenario (as opposed to account creation). Then, we check to see if the user is fully authenticated, and if not, we redirect them to the `/sign_up/start` page, and include the request_id in the params, as well as in the links on that page that point to the account creation and sign in pages. Note that I moved the logic that determines whether or not the landing page should appear, from the sessions controller to the SAML and OpenID Connect controllers, since they are the ones that know where the user should go. This adheres to the Tell, Don't Ask principle, and allows us to remove the `show_start_page` key from the session. When the user chooses to create an account, the form contains the request_id in a hidden field so it can pass it on to the registrations controller. To include the request_id in the confirmation email, I had to create a new method based on the one Devise uses, but with the ability to pass in the request_id. Since Devise automatically sends the confirmation email when the user is created (via a callback), I had to override the original method to do nothing. By having the request_id in the email, that means that if the user visits the confirmation URL in a different browser, we can go back to storing the SP info in the session by looking up the ServiceProviderRequest whose uuid matches the `_request_id` parameter from the URL in the email. By going back to using the session from then on, it frees us from having to keep passing in the request_id in URLs and in forms. This is done in `EmailConfirmationsController#process_valid_confirmation_token`. Note that the request_id is prefixed with an underscore in the email to make sure that the URL ends with the confirmation token. By default, Rails sorts params in `link_to` helpers alphabetically, but we don't want `request_id` to come last because if the user chooses to copy the link instead of clicking it, and if they don't copy it correctly, the app won't find the ServiceProviderRequest, whereas an invalid confirmation token will display an error to the user. Once the user finishes creating their account, when they click the button to continue to the SP, the app makes the original SP request again, calling the `before_action`s in the SAML and OpenId Connect controllers a second time. However, we don't want to create a new ServiceProviderRequest because we want to be able to delete the original one after the user goes back to the SP. We don't want the ServiceProviderRequests table to grow indefinitely. The only way to know for sure whether or not a ServiceProviderRequest that matches the request URL already exists is to query on its `url` field. The problem is, given that we want to index that field since it will be queried on every SP request, the url field value is too big for the Postgres btree index. There are solutions to this problem (that I haven't implemented before), but I didn't feel it was worth the trouble when we can rely on the presence of `sp_session[:request_id]` to determine whether or not a new ServiceProviderRequest should be created or not. We also use the `sp_session[:request_id]` to determine which ServiceProviderRequest to delete by find a matching uuid. Then, we can safely delete the `:sp` Hash from the session. By keeping track of the SP via the request_id, we can also remove the session timeout code and flash message that dealt with the scenario where a user makes a request from an SP, but then remains on the sign in page for longer than 8 minutes. Note that all this does is preserve the branded experience on the sign in page. Allowing the SP info to be restored after sign in will be implemented in a separate PR. Note that this doesn't include the same fix for the scenario where a user opens the password reset link in a different browser. That will be in a separate PR.
4e61357 to
cc60390
Compare
There was a problem hiding this comment.
why are we calling yield here instead of just returning needs_idv? It looks like that is a boolean value?
There was a problem hiding this comment.
Because this method is meant to be called with a block so that the auth method can return if the user needs_idv.
There was a problem hiding this comment.
why are we naming this param _request_id instead of request_id?
There was a problem hiding this comment.
Please read the initial commit message 😄
There was a problem hiding this comment.
to recap for others:
Note that the request_id is prefixed with an underscore in the email to
make sure that the URL ends with the confirmation token. By default,
Rails sorts params in link_to helpers alphabetically, but we don't
want request_id to come last because if the user chooses to copy the
link instead of clicking it, and if they don't copy it correctly, the
app won't find the ServiceProviderRequest, whereas an invalid
confirmation token will display an error to the user.
I find this logic sensible, although I am wondering how we can communicate the reasoning for this underscore to future people / our future selves, since it does feel a bit mysterious when browsing the code.
There was a problem hiding this comment.
Good point. I typically use git log -S or I look up the file in question in GitHub and look at the history to find out which commit introduced something and then I look up the PR, assuming the commit message explains the change since we've been pretty good about doing that.
There was a problem hiding this comment.
Yeah I think that is ideal, but that cannot always be depended on. I will noodle on ways we can make this more obvious via a future PR, perhaps through some specs
There was a problem hiding this comment.
wondering if sp_request_id would be a clearer param name?
There was a problem hiding this comment.
Yes, current_sp_request. Good catch.
There was a problem hiding this comment.
I know that nil.to_s is empty string, but perhaps it would be clearer to just use '' here?
There was a problem hiding this comment.
Where does nil.to_s come from? I want to pass in a nil value to the form. When the form renders, the request_id hidden input field won't have a value at all.
There was a problem hiding this comment.
anything in the view is going to inherently have to_s called on it. So this will render in the hidden input field as '', I believe
There was a problem hiding this comment.
That doesn't seem to be the case for nil:
<input class="block col-12 field hidden" type="hidden" name="user[request_id]" id="user_request_id">It works either way, but I think nil conveys the intent better, which is that by default, we don't want any value at all, not even an empty string, because it can imply presence. For example, if we had a conditional in the view that asked if request_id, then it would pass with an empty string, but not if it was nil.
There was a problem hiding this comment.
I always thought Nonexistent was two words - TIL!
app/models/user.rb
Outdated
There was a problem hiding this comment.
This comment is very clear / accurate...what worries me about this comment is that what if we change the method and don't update the comment? I might just leave the comment as "Overrride send_confirmation_instructions from Devise" or no comment at all ;)
There was a problem hiding this comment.
thoughts on my comment about this comment, @monfresh ?
|
Thanks for the thorough review, @jessieay! Is this good to go now? |
| config.session_store.new({}, config.session_options) | ||
| end | ||
|
|
||
| def sign_up_user_from_sp_without_confirming_email(email) |
There was a problem hiding this comment.
so is the plan to break this up in a future PR? I commented about doing that before and I remember that you replied but the thread is gone now...
There was a problem hiding this comment.
Oh I see you did break it up...WOW still so long.
jessieay
left a comment
There was a problem hiding this comment.
💯 this is awesme - so glad we are going to fix this before release number 1!
* Allow confirming email in a different browser **Why**: Some people might visit the site via a Service Provider (SP) in one browser, but open the email confirmation link in a different browser. This means that anything that was previously stored in the session in the first browser won't be available in the second browser. The consequence is that the user won't be redirected back to the SP after they finish creating their account. **How**: - Add a new ServiceProviderRequest DB table When a request is made from an SP, whether it's via SAML or OpenID Connect, we create a new ServiceProviderRequest with the following attributes: the full request URL, a random unique UUID (because it's not guaranteed that separate requests will have a unique identifier attached to them), the issuer, and LOA. We also store these same attributes in the session to support the sign in scenario (as opposed to account creation). Then, we check to see if the user is fully authenticated, and if not, we redirect them to the `/sign_up/start` page, and include the request_id in the params, as well as in the links on that page that point to the account creation and sign in pages. Note that I moved the logic that determines whether or not the landing page should appear, from the sessions controller to the SAML and OpenID Connect controllers, since they are the ones that know where the user should go. This adheres to the Tell, Don't Ask principle, and allows us to remove the `show_start_page` key from the session. When the user chooses to create an account, the form contains the request_id in a hidden field so it can pass it on to the registrations controller. To include the request_id in the confirmation email, I had to create a new method based on the one Devise uses, but with the ability to pass in the request_id. Since Devise automatically sends the confirmation email when the user is created (via a callback), I had to override the original method to do nothing. By having the request_id in the email, that means that if the user visits the confirmation URL in a different browser, we can go back to storing the SP info in the session by looking up the ServiceProviderRequest whose uuid matches the `_request_id` parameter from the URL in the email. By going back to using the session from then on, it frees us from having to keep passing in the request_id in URLs and in forms. This is done in `EmailConfirmationsController#process_valid_confirmation_token`. Note that the request_id is prefixed with an underscore in the email to make sure that the URL ends with the confirmation token. By default, Rails sorts params in `link_to` helpers alphabetically, but we don't want `request_id` to come last because if the user chooses to copy the link instead of clicking it, and if they don't copy it correctly, the app won't find the ServiceProviderRequest, whereas an invalid confirmation token will display an error to the user. Once the user finishes creating their account, when they click the button to continue to the SP, the app makes the original SP request again, calling the `before_action`s in the SAML and OpenId Connect controllers a second time. However, we don't want to create a new ServiceProviderRequest because we want to be able to delete the original one after the user goes back to the SP. We don't want the ServiceProviderRequests table to grow indefinitely. The only way to know for sure whether or not a ServiceProviderRequest that matches the request URL already exists is to query on its `url` field. The problem is, given that we want to index that field since it will be queried on every SP request, the url field value is too big for the Postgres btree index. There are solutions to this problem (that I haven't implemented before), but I didn't feel it was worth the trouble when we can rely on the presence of `sp_session[:request_id]` to determine whether or not a new ServiceProviderRequest should be created or not. We also use the `sp_session[:request_id]` to determine which ServiceProviderRequest to delete by find a matching uuid. Then, we can safely delete the `:sp` Hash from the session. By keeping track of the SP via the request_id, we can also remove the session timeout code and flash message that dealt with the scenario where a user makes a request from an SP, but then remains on the sign in page for longer than 8 minutes. Note that all this does is preserve the branded experience on the sign in page. Allowing the SP info to be restored after sign in will be implemented in a separate PR. Note that this doesn't include the same fix for the scenario where a user opens the password reset link in a different browser. That will be in a separate PR.
**Why**: When we first introduced the `ServiceProvideRequests` table (#1265), it was to allow users to go through the account creation process across multiple browsers and still end up back at the SP. This initial implementation did not result in any stray entries in the `ServiceProvideRequests` table in the typical sign in scenario. However, we noticed a bug with this implementation that prevented users from creating multiple accounts during the same session, so we removed the guard clause in the SAML and OIDC controllers (#1542), resulting in stray entries being created, which we acknowledged in the PR commit message and proposed using a rake task to clean them up, but never followed up. Fast forward about a year later, and the table now was millions of entries and is causing DB errors and slowdowns. Upon revisiting the history of this table, and thinking about the problem some more, I found a solution that allows us to support all scenarios while also preventing stray entries. This solution also reduces the total amount of DB transactions and cuts writes in half. **Before:** 1. User (who is not signed in) makes request A from SP 2. Request A is stored in the DB and in the session by the controller (this requires a DB read) 3. User signs in + 2FA 4. ApplicationController looks up Request A from the session to redirect the user back to the entry point controller 5. The controller stores Request A again as a new entry in the DB, which we'll call Request B. The info is stored in the session again as well, resulting in another DB read. 6. User is redirected back to SP, and Request B is deleted from the DB, but Request A remains in the DB. Total DB transactions: 2 writes, 2 reads, 1 delete **After:** 1. User (who is not signed in) makes request A from SP 2. Request A is stored in the DB and in the session by the controller (this requires a DB read) 3. User signs in + 2FA 4. ApplicationController looks up Request A from the session to redirect the user back to the entry point controller 5. The controller compares the request stored in the session (DB read) with the one that was just received by the controller and sees that they are both from the same SP, and does create a new entry in the DB 6. User is redirected back to SP, and Request A is deleted from the DB. Total DB transactions: 1 write, 2 reads, 1 delete **How**: Instead of using the presence of the request_id in the session as the guard clause, compare the SP that made the last request with the one stored in the session. If they're the same, don't store a new entry in the DB, otherwise, delete the entry that matches the one in the session, and then create a new entry.
* LG-667 Don't create stray ServiceProvideRequests **Why**: When we first introduced the `ServiceProvideRequests` table (#1265), it was to allow users to go through the account creation process across multiple browsers and still end up back at the SP. This initial implementation did not result in any stray entries in the `ServiceProvideRequests` table in the typical sign in scenario. However, we noticed a bug with this implementation that prevented users from creating multiple accounts during the same session, so we removed the guard clause in the SAML and OIDC controllers (#1542), resulting in stray entries being created, which we acknowledged in the PR commit message and proposed using a rake task to clean them up, but never followed up. Fast forward about a year later, and the table now was millions of entries and is causing DB errors and slowdowns. Upon revisiting the history of this table, and thinking about the problem some more, I found a solution that allows us to support all scenarios while also preventing stray entries. This solution also reduces the total amount of DB transactions and cuts writes in half. **Before:** 1. User (who is not signed in) makes request A from SP 2. Request A is stored in the DB and in the session by the controller (this requires a DB read) 3. User signs in + 2FA 4. ApplicationController looks up Request A from the session to redirect the user back to the entry point controller 5. The controller stores Request A again as a new entry in the DB, which we'll call Request B. The info is stored in the session again as well, resulting in another DB read. 6. User is redirected back to SP, and Request B is deleted from the DB, but Request A remains in the DB. Total DB transactions: 2 writes, 2 reads, 1 delete **After:** 1. User (who is not signed in) makes request A from SP 2. Request A is stored in the DB and in the session by the controller (this requires a DB read) 3. User signs in + 2FA 4. ApplicationController looks up Request A from the session to redirect the user back to the entry point controller 5. The controller compares the request stored in the session (DB read) with the one that was just received by the controller and sees that they are both from the same SP, and does create a new entry in the DB 6. User is redirected back to SP, and Request A is deleted from the DB. Total DB transactions: 1 write, 2 reads, 1 delete **How**: Instead of using the presence of the request_id in the session as the guard clause, compare the SP that made the last request with the one stored in the session. If they're the same, don't store a new entry in the DB, otherwise, delete the entry that matches the one in the session, and then create a new entry.
Why: Some people might visit the site via a Service Provider (SP)
in one browser, but open the email confirmation link in a different
browser. This means that anything that was previously stored in the
session in the first browser won't be available in the second browser.
The consequence is that the user won't be redirected back to the SP
after they finish creating their account.
How:
When a request is made from an SP, whether it's via SAML or OpenID
Connect, we create a new ServiceProviderRequest with the following
attributes: the full request URL, a random unique UUID (because it's
not guaranteed that separate requests will have a unique identifier
attached to them), the issuer, and LOA. We also store these same
attributes in the session to support the sign in scenario (as opposed
to account creation).
Then, we check to see if the user is fully authenticated, and if not,
we redirect them to the
/sign_up/startpage, and include therequest_id in the params, as well as in the links on that page that
point to the account creation and sign in pages. Note that I moved the
logic that determines whether or not the landing page should appear,
from the sessions controller to the SAML and OpenID Connect controllers,
since they are the ones that know where the user should go. This adheres
to the Tell, Don't Ask principle, and allows us to remove the
show_start_pagekey from the session.When the user chooses to create an account, the form contains the
request_id in a hidden field so it can pass it on to the registrations
controller. To include the request_id in the confirmation email, I had
to create a new method based on the one Devise uses, but with the
ability to pass in the request_id. Since Devise automatically sends the
confirmation email when the user is created (via a callback), I had to
override the original method to do nothing.
By having the request_id in the email, that means that if the user
visits the confirmation URL in a different browser, we can go back to
storing the SP info in the session by looking up the
ServiceProviderRequest whose uuid matches the
_request_idparameterfrom the URL in the email. By going back to using the session from then
on, it frees us from having to keep passing in the request_id in URLs
and in forms. This is done in
EmailConfirmationsController#process_valid_confirmation_token.Note that the request_id is prefixed with an underscore in the email to
make sure that the URL ends with the confirmation token. By default,
Rails sorts params in
link_tohelpers alphabetically, but we don'twant
request_idto come last because if the user chooses to copy thelink instead of clicking it, and if they don't copy it correctly, the
app won't find the ServiceProviderRequest, whereas an invalid
confirmation token will display an error to the user.
Once the user finishes creating their account, when they click the
button to continue to the SP, the app makes the original SP request
again, calling the
before_actions in the SAML and OpenId Connectcontrollers a second time. However, we don't want to create a new
ServiceProviderRequest because we want to be able to delete the
original one after the user goes back to the SP. We don't want the
ServiceProviderRequests table to grow indefinitely. The only way
to know for sure whether or not a ServiceProviderRequest that matches
the request URL already exists is to query on its
urlfield. Theproblem is, given that we want to index that field since it will be
queried on every SP request, the url field value is too big for the
Postgres btree index. There are solutions to this problem (that I
haven't implemented before), but I didn't feel it was worth the
trouble when we can rely on the presence of
sp_session[:request_id]to determine whether or not a new ServiceProviderRequest should be
created or not. We also use the
sp_session[:request_id]to determinewhich ServiceProviderRequest to delete by find a matching uuid. Then,
we can safely delete the
:spHash from the session.By keeping track of the SP via the request_id, we can also remove the
session timeout code and flash message that dealt with the scenario
where a user makes a request from an SP, but then remains on the
sign in page for longer than 8 minutes. Note that all this does is
preserve the branded experience on the sign in page. Allowing the
SP info to be restored after sign in will be implemented in a
separate PR.
Note that this doesn't include the same fix for the scenario where a
user opens the password reset link in a different browser. That will be
in a separate PR.