-
Notifications
You must be signed in to change notification settings - Fork 120
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
Allow confirming email in a different browser #1265
Conversation
e907f8a
to
a75c49a
Compare
errors: @authorize_form.errors.messages) | ||
end | ||
|
||
def track_create_action_analytics(result) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this method extraction to reduce the ABC size of the #create
method?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct.
|
||
yield true if needs_idv | ||
|
||
yield false |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this will yield
twice when true, is that intentional?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
end | ||
|
||
def send_custom_confirmation_instructions(id = nil) | ||
generate_confirmation_token! unless @raw_confirmation_token |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where does @ raw_confirmation_token
get set? Where does generate_confirmation_token!
come from? Devise?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
@@ -0,0 +1,5 @@ | |||
class ServiceProviderRequest < ActiveRecord::Base | |||
def self.find_by(*args) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
end
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure.
@@ -99,16 +99,21 @@ | |||
|
|||
context 'user is not signed in' do | |||
it 'redirects to login' do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
update the comment?
it 'redirects to login with the request_id'
or something?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, will do.
|
||
enter_2fa_code | ||
|
||
# expect(page).to have_css('img[src*=sp-logos]') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this commented out on purpose?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
huh! create a ticket?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep. In my previous comment I said that I opened an issue already. https://github.com/18F/identity-private/issues/1692
|
||
return prompt_to_set_up_2fa unless current_user.two_factor_enabled? | ||
|
||
prompt_to_enter_otp |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this line is red from codeclimate, do we not have test coverage?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
link to commit with spec?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
darn looks like that was squashed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM!
b077dda
to
6e1ff63
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Left a round of feedback. Could probably comb through more but figured I'd submit my comments so you have time to respond :)
@@ -26,7 +26,7 @@ | |||
|
|||
expect(rendered). | |||
to have_link( | |||
t('links.create_account'), href: sign_up_email_path | |||
t('links.create_account'), href: sign_up_email_url(request_id: nil) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should an href be a _path
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just tried changing to _path
and it failed. _url
is required if you want to check for params as well.
|
||
enter_2fa_code | ||
|
||
# expect(page).to have_css('img[src*=sp-logos]') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
huh! create a ticket?
open_email(email) | ||
visit_in_email(t('mailer.confirmation_instructions.link_text')) | ||
|
||
expect(page).to have_css('img[src*=sp-logos]') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wouldn't necessarily expect to see expectations in a setup step like this.
@@ -213,5 +213,91 @@ def session_store | |||
config = Rails.application.config | |||
config.session_store.new({}, config.session_options) | |||
end | |||
|
|||
def sign_up_and_2fa_loa1_user_who_came_from_sp |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, I knew you would bring this up, but thought I'd get this out for review and then clean it up.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you know me soooo well 🥇
@@ -11,10 +11,10 @@ | |||
it { is_expected.to have_many(:events) } | |||
end | |||
|
|||
it 'should only send one email during creation' do | |||
it 'does not send an email when #create is called' do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
|
||
expect(current_path).to eq sign_up_completed_path | ||
|
||
click_on t('forms.buttons.continue_to', sp: 'Your friendly Government Agency') | ||
|
||
expect(current_url).to eq saml_authn_request | ||
expect(ServiceProviderRequest.from_uuid(sp_request_id)). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, yes, thanks for reminding me. I meant to add a step that deletes the session. Doing that now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in c630146
PTAL
|
||
t.timestamps null: false | ||
|
||
t.index :uuid |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
unique: true
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good call
@@ -92,13 +92,13 @@ | |||
get '/sign_up/email/confirm' => 'sign_up/email_confirmations#create', | |||
as: :sign_up_create_email_confirmation | |||
get '/sign_up/enter_email' => 'sign_up/registrations#new', as: :sign_up_email | |||
post '/sign_up/enter_email' => 'sign_up/registrations#create', as: :sign_up_register |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
@@ -40,8 +41,5 @@ | |||
p.mb2 = t('devise.registrations.start.bullet_5_html') | |||
p.mb3 = link_to \ | |||
t('devise.registrations.start.learn_more'), MarketingSite.help_url, target: '_blank' | |||
.center | |||
- ab_test(:demo) do |variant, _| |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
separate might be nice since this PR is quite large as-is, but up to you!
show_start_page: true } | ||
return if sp_session[:request_id] | ||
|
||
session[:sp] = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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_id
on every form and URL, and it would also mean a lot more DB lookups.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
@@ -257,6 +257,8 @@ def sign_up_and_2fa_loa1_user_who_came_from_sp | |||
expect(current_url).to eq sign_up_verify_email_url(request_id: sp_request_id, resend: true) | |||
expect(page).to have_css('img[src*=sp-logos]') | |||
|
|||
delete_sp_info_from_session_to_simulate_user_switching_browsers |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
needs_idv = identity_needs_verification? | ||
analytics.track_event(Analytics::SAML_AUTH, @result.to_h.merge(idv: needs_idv)) | ||
|
||
yield needs_idv |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because this method is meant to be called with a block so that the auth
method can return
if the user needs_idv
.
@@ -11,13 +11,16 @@ table.button.expanded.large.radius | |||
td | |||
center | |||
= link_to t('mailer.confirmation_instructions.link_text'), \ | |||
sign_up_create_email_confirmation_url(confirmation_token: @token), \ | |||
sign_up_create_email_confirmation_url(_request_id: \ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why are we naming this param _request_id
instead of request_id
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please read the initial commit message 😄
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
end | ||
|
||
def current_sp | ||
@_current_sp ||= ServiceProviderRequest.from_uuid(params[:_request_id]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
wondering if sp_request_id
would be a clearer param name?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, current_sp_request
. Good catch.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed here: cdf1da9
PTAL
@@ -18,6 +18,7 @@ def new | |||
@register_user_email_form = RegisterUserEmailForm.new | |||
session[:sign_up_init] = true | |||
analytics.track_event(Analytics::USER_REGISTRATION_ENTER_EMAIL_VISIT) | |||
render :new, locals: { request_id: nil } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
.
end | ||
|
||
def user | ||
@_user ||= (email.presence && User.find_with_email(email)) || NonexistentUser.new |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I always thought Nonexistent
was two words - TIL!
app/models/user.rb
Outdated
# no-op | ||
end | ||
|
||
# This is basically Devise's send_confirmation_instructions method copied |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thoughts on my comment about this comment, @monfresh ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, just remembered: 03a4683
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
looks good!
Thanks for the thorough review, @jessieay! Is this good to go now? |
@@ -213,5 +213,143 @@ def session_store | |||
config = Rails.application.config | |||
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I see you did break it up...WOW still so long.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💯 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/start
page, 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_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
parameterfrom 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'twant
request_id
to 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_action
s 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
url
field. 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
: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.