heartcombo / devise

Flexible authentication solution for Rails with Warden.
http://blog.plataformatec.com.br/tag/devise/
MIT License
23.89k stars 5.54k forks source link

Turbo ignores redirect callback, tries to render a page twice #5562

Closed jonathansimmons closed 1 year ago

jonathansimmons commented 1 year ago

Environment

Current behavior

The problem is that if we sign out and redirect a user in a custom callback before the devise authenticate_employee! fires, the redirect in our custom is being ignored, and the last redirect, in the case from devise authenticate_employee wins, which means our custom redirect/alert is lost.

More context

We're writing a quick single session-tracking function using the principles from this blog post: https://rubyyagi.com/devise-only-allow-one-session-per-user-at-the-same-time/

TLDR; the article describes a simple plan to track a unique token in session and on the user model during devise/sessions#create. We then have a callback that runs after authenticate_employee! that checks if the user signing in has a session token that matches what we've stored in the session. If not, we log that user out and redirect them to the login screen with a custom notice explaining why they were signed out.

This check and subsequent sign-out are both working, but are being overridden by the subsequent request.

Scenario 1 (working): User A is signed in, and User B signs in at their computer with User A's credentials. If User A refreshes their current page, they are signed out and see our custom notice.

Scenario 2 (not working): User A is signed in, and User B signs in at their computer with User A's credentials. If User A clicks a turbo-enabled link, they are signed out but redirected to the login screen with the default sign-in notice.

Expected behavior

Scenario 2 should function the same as Scenario 1.

Troubleshooting

After watching the logs during this flow, I noticed the following request flow for Scenario 2

  1. User B signs in, and the dashboard loads
  2. User A clicks a turbo-enabled sidebar link
  3. The check_concurrent_session fires, sees that User A's session token is invalid (because user B signed in last), and signs out User A. A redirect then fires to send the user to the sign-in page. Before that request finishes, the controller appears to process and authenticate_employee! because the requested page in step 2 is requested a second time. That second request then sends its own redirect but with the stock devise signed_out notice.

Example logs from this flow:

05:13:16 web.1  | Started GET "/forms" for ::1 at 2023-03-01 05:13:16 -0600
05:13:16 web.1  | Processing by Agencies::LinksController#index as HTML
05:13:16 web.1  |   Employee Load (1.2ms)  SELECT "employees".* FROM "employees" WHERE "employees"."id" = $1 ORDER BY "employees"."id" ASC LIMIT $2  [["id", 1], ["LIMIT", 1]]
05:13:16 web.1  |   ↳ app/controllers/application_controller.rb:178:in `check_concurrent_session'
05:13:16 web.1  |   Agency Load (1.2ms)  SELECT "agencies".* FROM "agencies" WHERE "agencies"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
05:13:16 web.1  |   ↳ app/models/employee.rb:203:in `block (2 levels) in <class:Employee>'
05:13:16 web.1  | Redirected to http://localhost:3000/sign-in
05:13:16 web.1  | Filter chain halted as :check_concurrent_session rendered or redirected
05:13:16 web.1  | Completed 302 Found in 20ms (ActiveRecord: 2.4ms | Allocations: 66859)
05:13:16 web.1  | 
05:13:16 web.1  | Started GET "/sign-in" for ::1 at 2023-03-01 05:13:16 -0600
05:13:16 web.1  | Processing by SessionsController#new as HTML
05:13:16 web.1  |   Rendering layout layouts/devise.html.erb
05:13:16 web.1  |   Rendering employees/sessions/new.html.erb within layouts/devise
05:13:16 web.1  |   Rendered employees/shared/_links.html.erb (Duration: 0.1ms | Allocations: 92)
05:13:16 web.1  |   Rendered employees/sessions/new.html.erb within layouts/devise (Duration: 3.0ms | Allocations: 6129)
05:13:16 web.1  |   Rendered layout layouts/devise.html.erb (Duration: 12.4ms | Allocations: 28422)
05:13:16 web.1  | Completed 200 OK in 14ms (Views: 12.9ms | ActiveRecord: 0.0ms | Allocations: 33057)
05:13:16 web.1  | 
05:13:16 web.1  | 
05:13:16 web.1  | Started GET "/forms" for ::1 at 2023-03-01 05:13:16 -0600
05:13:16 web.1  | Processing by Agencies::LinksController#index as HTML
05:13:16 web.1  | Completed 401 Unauthorized in 5ms (ActiveRecord: 0.0ms | Allocations: 18504)
05:13:16 web.1  |
05:13:16 web.1  | 
05:13:16 web.1  | Started GET "/sign-in" for ::1 at 2023-03-01 05:13:16 -0600
05:13:16 web.1  | Processing by SessionsController#new as HTML
05:13:16 web.1  |   Rendering layout layouts/devise.html.erb
05:13:16 web.1  |   Rendering employees/sessions/new.html.erb within layouts/devise
05:13:16 web.1  |   Rendered employees/shared/_links.html.erb (Duration: 0.1ms | Allocations: 85)
05:13:16 web.1  |   Rendered employees/sessions/new.html.erb within layouts/devise (Duration: 4.2ms | Allocations: 5872)
05:13:16 web.1  |   Rendered layout layouts/devise.html.erb (Duration: 10.5ms | Allocations: 16075)
05:13:16 web.1  | Completed 200 OK in 13ms (Views: 11.0ms | ActiveRecord: 0.0ms | Allocations: 20734)

As you can see, the /forms route is being requested twice.

Callback code

  def check_concurrent_session
    return unless employee_signed_in? && account_in_use?

    sign_out(:employee)
    redirect_to new_employee_session_path,
                       alert: {
                          title: "Custom alter title",
                          body: "custom sign out message",
                          timeout: 500
                        }
  end

  def account_in_use?
    current_employee && (session[:login_token] != current_employee.current_login_token)
  end

Troubleshooting

Undesired fix

So can anyone provide any insight into why turbo is still requesting the page dispute the check_concurrent_session redirecting elsewhere?

jonathansimmons commented 1 year ago

One additional note; while manually adding status: :see_other to the redirect does cause log the request as a 303, I noticed if I don't manually provide it the server logs a 302.

IF I've configured config.responder.redirect_status = :see_other shouldn't that 302 dynamically be a 303, or is devise doing something behind the scenes to treat 302's as 303's

jonathansimmons commented 1 year ago

If I disable :authenticate_employee!, the only thing that changes in the flow described above is that the second request to /forms fails to load because it's trying to call current_employee.

This leads me to believe that this is more of a turbo problem than Devise, but if anyone sees anything obviously wrong with my attempt to redirect, I'd love the help.

carlosantoniodasilva commented 1 year ago

I put the check_concurrent_session callback before the authenticate_employee!

I believe you want authenticate to come first, since if there's no one authenticated the other check probably doesn't matter.

Hard coded :see_other as the status if the check_concurrent_session redirect.

This should be what you want if you're implementing Turbo only. (it works with 302 but recommends 303)

One additional note; while manually adding status: :see_other to the redirect does cause log the request as a 303, I noticed if I don't manually provide it the server logs a 302.

IF I've configured config.responder.redirect_status = :see_other shouldn't that 302 dynamically be a 303, or is devise doing something behind the scenes to treat 302's as 303's

This is Rails behavior, 302 is the default in Rails... the configuration in Devise/Responders is for them alone, and/or if you use respond_with from Responders. Manual redirect_to still use Rails defaults, which is 302 for now (may change to 303 in the future)

If I disable :authenticate_employee!, the only thing that changes in the flow described above is that the second request to /forms fails to load because it's trying to call current_employee.

current_employee should force authentication I believe, but still you want the authenticate_employee! call there.


At a glance I don't see anything wrong with the setup. I tried setting that scenario up on a test app with Devise + Turbo I had here, and it worked:

https://user-images.githubusercontent.com/26328/222145313-499d0bcc-7b81-41cb-869a-e2eaf0287b09.mp4

As you can see, both when refreshing the page on another session, or clicking a link that should redirect me to an authenticated page, it redirected me back to the login page with a CUSTOM MESSAGE flash.

Here's my sample code based on that blog post and the snippet you shared: (note that I'm using a user model/resource)

class Users::SessionsController < Devise::SessionsController
  skip_before_action :check_concurrent_session

  def create
    super
    set_login_token
  end

  private

  def set_login_token
    token = Devise.friendly_token
    session[:login_token] = token
    current_user.current_login_token = token
    current_user.save
  end
end

class ApplicationController < ActionController::Base
  before_action :check_concurrent_session

  private

  def check_concurrent_session
    return unless user_signed_in? && account_in_use?

    sign_out(:user)
    redirect_to new_user_session_path, alert: "CUSTOM MESSAGE"
  end

  def account_in_use?
    current_user && !(session[:login_token] == current_user.current_login_token)
  end

  def after_sign_out_path_for(_)
    new_user_session_path
  end
end

Logs for the refresh: (GET /)

Started GET "/" for ::1 at 2023-03-01 09:55:11 -0300
Processing by HomeController#index as HTML
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? ORDER BY "users"."id" ASC LIMIT ?  [["id", 6], ["LIMIT", 1]]
  ↳ app/controllers/application_controller.rb:7:in `check_concurrent_session'
Redirected to http://localhost:3000/users/sign_in
Filter chain halted as :check_concurrent_session rendered or redirected
Completed 302 Found in 4ms (ActiveRecord: 0.2ms | Allocations: 1517)

Started GET "/users/sign_in" for ::1 at 2023-03-01 09:55:11 -0300
Processing by Users::SessionsController#new as HTML
  Rendering layout layouts/application.html.erb
  Rendering users/sessions/new.html.erb within layouts/application
  Rendered devise/shared/_links.html.erb (Duration: 0.5ms | Allocations: 467)
  Rendered users/sessions/new.html.erb within layouts/application (Duration: 2.1ms | Allocations: 1565)
  Rendered layout layouts/application.html.erb (Duration: 5.2ms | Allocations: 3708)
Completed 200 OK in 7ms (Views: 5.8ms | ActiveRecord: 0.0ms | Allocations: 4464)

Logs for the Turbo click: (GET /users/edit -- the link I clicked)

Started GET "/users/edit" for ::1 at 2023-03-01 09:55:17 -0300
Processing by Devise::RegistrationsController#edit as HTML
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? ORDER BY "users"."id" ASC LIMIT ?  [["id", 6], ["LIMIT", 1]]
Redirected to http://localhost:3000/users/sign_in
Filter chain halted as :check_concurrent_session rendered or redirected
Completed 302 Found in 5ms (ActiveRecord: 0.2ms | Allocations: 1899)

Started GET "/users/sign_in" for ::1 at 2023-03-01 09:55:17 -0300
Processing by Users::SessionsController#new as HTML
  Rendering layout layouts/application.html.erb
  Rendering users/sessions/new.html.erb within layouts/application
  Rendered devise/shared/_links.html.erb (Duration: 0.4ms | Allocations: 467)
  Rendered users/sessions/new.html.erb within layouts/application (Duration: 1.9ms | Allocations: 1565)
  Rendered layout layouts/application.html.erb (Duration: 4.0ms | Allocations: 3681)
Completed 200 OK in 6ms (Views: 4.6ms | ActiveRecord: 0.0ms | Allocations: 4352)

Also note that I didn't change the redirect to 303/see other but it works the same.

It doesn't seem to be a Devise specific issue from what I can tell. Using turbo-rails 1.3.3 if that helps.


One caveat I wanted to mention regarding that implementation: if you're using password resets, it automatically logs users in by default, and will skip your set_login_token logic. You might need to account for that in your implementation too, or disable sign_in_after_reset_password to prevent that and force users to login using the normal login form.

jonathansimmons commented 1 year ago

@carlosantoniodasilva Thank you so much for the investment in this. Your answers gave me the clarity and confirmation that I needed to keep digging further in a different space.

We are seeing a double load on links that don't have data: { turbo: false } which I don't think should be required but is not a devise problem as I first thought and we'll sort that out why Turbo is acting in such a way another day.

For now, your input helped me resolve my issue.

carlosantoniodasilva commented 1 year ago

Sounds good @jonathansimmons, glad it was helpful. Good luck!