Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Return 422 status when login fails + treat turbo_stream as a navigational format #5340

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,33 @@
### 4.8.1 - 2021-12-16

* enhancements
* Add support for Rails 7.0. Please note that Turbo integration is not fully supported by Devise yet.
* Add support for Rails 7.0.
* Devise now treats `:turbo_stream` as a navigational format.
- For Turbo Drive users this ensures that Devise will return a redirect, rather than a `401`, where appropriate (this may be a **breaking change**).
- This has no impact if you are not using [turbo-rails](https://github.com/hotwired/turbo-rails) or declaring an equivalent MIME type.
* Devise `FailureApp` now returns a `422` status when running the `recall` flow.
- This, combined with https://github.com/heartcombo/responders/pull/223, enables Devise to work correctly with the [new Rails defaults](https://github.com/rails/rails/pull/41026) for form errors, as well as with new libraries like Turbo.
- By default, the `recall` flow only runs on the [`SessionsController`](https://github.com/heartcombo/devise/blob/v4.7.3/app/controllers/devise/sessions_controller.rb#L48), but you should check if it is required in other flows too.
- This may be a **breaking change**.
- If you want to keep the old behavior, or return a different code, you can override `recall_response_code` in a custom failure app:

```ruby
# config/initializers/devise.rb
class MyFailureApp < Devise::FailureApp
def recall_response_code(app_response_code)
# Return any HTTP status code you like, or return `app_response_code` to keep whatever your app returned and not override it.
# By default this method returns `422`.
app_response_code
end
end
Devise.setup do |config|
# ...
config.warden do |manager|
manager.failure_app = MyFailureApp
end
# ...
end
```

### 4.8.0 - 2021-04-29

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/devise/passwords_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def update
respond_with resource, location: after_resetting_password_path_for(resource)
else
set_minimum_password_length
respond_with resource
respond_with resource, status: :unprocessable_entity

Choose a reason for hiding this comment

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

Were you having any issue with responders alone here, to have to enforce a status?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@baarkerlounger made that change in ghiculescu@eef20a3

The discussion is here #5340 (comment), I don't really remember any context beyond that.

Choose a reason for hiding this comment

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

Gotcha, thanks. I think those should be handled by default with responders now 👍

end
end

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/devise/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def respond_to_on_destroy
# support returning empty response on GET request
respond_to do |format|
format.all { head :no_content }
format.any(*navigational_formats) { redirect_to after_sign_out_path_for(resource_name) }
format.any(*navigational_formats) { redirect_to after_sign_out_path_for(resource_name), status: :see_other }

Choose a reason for hiding this comment

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

Note: responders now has a configuration redirect_status that allows us to override this across the board.

This status is also going to be necessary on the registrations controller destroy action:

respond_with_navigational(resource){ redirect_to after_sign_out_path_for(resource_name) }

end
end
end
2 changes: 1 addition & 1 deletion lib/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ module Test

# Which formats should be treated as navigational.
mattr_accessor :navigational_formats
@@navigational_formats = ["*/*", :html]
@@navigational_formats = ["*/*", :html, :turbo_stream]

# When set to true, signing out a user signs out all other scopes.
mattr_accessor :sign_out_all_scopes
Expand Down
10 changes: 8 additions & 2 deletions lib/devise/failure_app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ def recall
end

flash.now[:alert] = i18n_message(:invalid) if is_flashing_format?
self.response = recall_app(warden_options[:recall]).call(request.env)
response_from_app = recall_app(warden_options[:recall]).call(request.env)
response_from_app[0] = recall_response_code(response_from_app[0])
self.response = response_from_app

Choose a reason for hiding this comment

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

👍 this is kind of what I had in mind too, change the status code here for failed login attempts.

I thought we could use responders though since we now have this configured there, so I went with that. This way, it can be overridden across the board and not only for this particular response.

end

def redirect
Expand All @@ -89,6 +91,10 @@ def redirect

protected

def recall_response_code(_original_response_code)
422
end

def i18n_options(options)
options
end
Expand Down Expand Up @@ -167,7 +173,7 @@ def scope_url
end

def skip_format?
%w(html */*).include? request_format.to_s
%w(html turbo_stream */*).include? request_format.to_s
end

# Choose whether we should respond in an HTTP authentication fashion,
Expand Down
4 changes: 2 additions & 2 deletions lib/generators/templates/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -256,14 +256,14 @@

# ==> Navigation configuration
# Lists the formats that should be treated as navigational. Formats like
# :html, should redirect to the sign in page when the user does not have
# :html should redirect to the sign in page when the user does not have
# access, but formats like :xml or :json, should return 401.
#
# If you have any extra navigational formats, like :iphone or :mobile, you
# should add them to the navigational formats lists.
#
# The "*/*" below is required to match Internet Explorer requests.
# config.navigational_formats = ['*/*', :html]
# config.navigational_formats = ['*/*', :html, :turbo_stream]

# The default HTTP method used to sign out a resource. Default is :delete.
config.sign_out_via = :delete
Expand Down
6 changes: 3 additions & 3 deletions test/controllers/sessions_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class SessionsControllerTest < Devise::ControllerTestCase
password: "wrongpassword"
}
}
assert_equal 200, @response.status
assert_equal 422, @response.status
ensure
ActiveSupport::Notifications.unsubscribe(subscriber)
end
Expand All @@ -29,7 +29,7 @@ class SessionsControllerTest < Devise::ControllerTestCase
swap Devise, scoped_views: true do
request.env["devise.mapping"] = Devise.mappings[:user]
post :create
assert_equal 200, @response.status
assert_equal 422, @response.status
assert_template "users/sessions/new"
end
end
Expand Down Expand Up @@ -70,7 +70,7 @@ class SessionsControllerTest < Devise::ControllerTestCase
password: "wevdude"
}
}
assert_equal 200, @response.status
assert_equal 422, @response.status
assert_template "devise/sessions/new"
end

Expand Down
16 changes: 16 additions & 0 deletions test/failure_app_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,20 @@ def call_failure(env_params = {})
test 'redirects the correct format if it is a non-html format request' do
swap Devise, navigational_formats: [:js] do
call_failure('formats' => Mime[:js])
assert_equal 302, @response.first
assert_equal 'http://test.host/users/sign_in.js', @response.second["Location"]
end
end

test 'redirects turbo_stream correctly' do
Mime::Type.register "text/vnd.turbo-stream.html", :turbo_stream

swap Devise, navigational_formats: [:html, :turbo_stream] do
call_failure('formats' => Mime[:turbo_stream])
assert_equal 302, @response.first
assert_equal 'http://test.host/users/sign_in', @response.second["Location"]
end
end
end

context 'For HTTP request' do
Expand Down Expand Up @@ -325,6 +336,7 @@ def call_failure(env_params = {})
"warden" => stub_everything
}
call_failure(env)
assert_equal 422, @response.first
assert_includes @response.third.body, '<h2>Log in</h2>'
assert_includes @response.third.body, 'Invalid Email or password.'
end
Expand All @@ -336,6 +348,7 @@ def call_failure(env_params = {})
"warden" => stub_everything
}
call_failure(env)
assert_equal 422, @response.first
assert_includes @response.third.body, '<h2>Log in</h2>'
assert_includes @response.third.body, 'You have to confirm your email address before continuing.'
end
Expand All @@ -347,6 +360,7 @@ def call_failure(env_params = {})
"warden" => stub_everything
}
call_failure(env)
assert_equal 422, @response.first
assert_includes @response.third.body, '<h2>Log in</h2>'
assert_includes @response.third.body, 'Your account is not activated yet.'
end
Expand All @@ -360,6 +374,7 @@ def call_failure(env_params = {})
"warden" => stub_everything
}
call_failure(env)
assert_equal 422, @response.first
assert_includes @response.third.body, '<h2>Log in</h2>'
assert_includes @response.third.body, 'Invalid Email or password.'
assert_equal '/sample', @request.env["SCRIPT_NAME"]
Expand All @@ -374,6 +389,7 @@ def call_failure(env_params = {})
assert_equal "yes it does", Devise::FailureApp.new.lazy_loading_works?
end
end

context "Without Flash Support" do
test "returns to the default redirect location without a flash message" do
call_failure request_klass: RequestWithoutFlashSupport
Expand Down
4 changes: 2 additions & 2 deletions test/integration/authenticatable_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ class AuthenticationSanityTest < Devise::IntegrationTest

delete destroy_admin_session_path
assert_response :redirect
assert_redirected_to root_path
assert_response :see_other

get root_path
assert_contain 'Signed out successfully'
Expand All @@ -129,7 +129,7 @@ class AuthenticationSanityTest < Devise::IntegrationTest
test 'unauthenticated admin set message on sign out' do
delete destroy_admin_session_path
assert_response :redirect
assert_redirected_to root_path
assert_response :see_other

get root_path
assert_contain 'Signed out successfully'
Expand Down
6 changes: 3 additions & 3 deletions test/integration/recoverable_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ def reset_password(options = {}, &block)
user = create_user
reset_password reset_password_token: 'invalid_reset_password'

assert_response :success
assert_response :unprocessable_entity
assert_current_url '/users/password'
assert_have_selector '#error_explanation'
assert_contain %r{Reset password token(.*)invalid}
Expand All @@ -170,7 +170,7 @@ def reset_password(options = {}, &block)
fill_in 'Confirm new password', with: 'other_password'
end

assert_response :success
assert_response :unprocessable_entity
assert_current_url '/users/password'
assert_have_selector '#error_explanation'
assert_contain "Password confirmation doesn't match Password"
Expand All @@ -192,7 +192,7 @@ def reset_password(options = {}, &block)
request_forgot_password

reset_password { fill_in 'Confirm new password', with: 'other_password' }
assert_response :success
assert_response :unprocessable_entity
assert_have_selector '#error_explanation'
refute user.reload.valid_password?('987654321')

Expand Down
2 changes: 1 addition & 1 deletion test/integration/timeoutable_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def last_request_at
delete destroy_user_session_path

assert_response :redirect
assert_redirected_to root_path
assert_response :see_other
follow_redirect!
assert_contain 'Signed out successfully'
end
Expand Down