Introduction
Last month, I decided to install Hotwire (which is the combination of Turbo and Stimulus) to my Rails app. I thought it would be easy and quick to make those upgrades but I kind of underestimated the task.
Stimulus is really easy to learn if you already know javascript. Turbo is more abstract and can be difficult to fully understand how it works under the hood. In addition, Turbo is quite new (it is the evolution of Turbolinks) so it is not really well documented as of June 2021.
One of the challenge I had to face is to make Turbo work with Devise and to keep my ReCaptcha strategy working (First verify ReCaptcha v3 and then display ReCaptcha v2 if v3 failed). So let's dive in !
For the following, I assume you have a functioning Rails app with the following Gems installed: Devise, Stimulus-rails, turbo-rails and recaptcha
Handling Devise error messages
If you installed Turbo successfully, then you should have noticed that when submitting your Log in form it now renders as TURBO_STREAM
format (this is because Turbo applies to forms and not only to links like Turbolinks did):
Processing by PagesController#home as TURBO_STREAM
If you submit your Log in form with the correct information, everything should work as expected.
The problem is when you do not enter the correct information (unknown email address, wrong password...), the error messages do not appear anymore and this is not what we want.
For fixing this part, I was largely inspired by this excellent tutorial from Chris Oliver.
1 - Add Turbo Stream to devise formats
First we need to add Turbo Stream into devise navigational formats list. In the devise.rb file, uncomment the config.navigational_formats
line and add the turbo_stream
format:
# devise.rb
config.navigational_formats = ['*/*', :html, :turbo_stream]
2 - Override Devise Respond method
In order for devise to display errors to the user when the authentication failed, we need to partially override the respond method of the Devise::FailureApp
class by creating our own TurboFailureApp
. This will "treat" the turbo stream like it would for an HTML request by redirecting back to the Log in page in case of failure. You can add this class directly on top of the devise.rb file (out of the devise.config
block) since we will probably never use this new class in another context:
# devise.rb
class TurboFailureApp < Devise::FailureApp
def respond
if request_format == :turbo_stream
redirect
else
super
end
end
def skip_format?
%w[html turbo_stream */*].include? request_format.to_s
end
end
3 - Configure Warden
Finally, we need to tell Warden (The Rack-based middleware used by devise for authentication) to use our newly created class as the failure manager. In the devise.rb, uncomment the config.warden
block and replace the inner content like this:
# devise.rb
config.warden do |manager|
manager.failure_app = TurboFailureApp
end
And that's it, you can try it out and should see Devise error messages appear on authentication failure.
Handling ReCaptchas with Turbo and Stimulus
Now that devise authentication works correctly with Turbo, we want to implement a ReCaptcha protection to our form. The ReCaptcha strategy I use is quite classical as it consists of first rendering a ReCaptcha v3 (which is invisible and based on requests liability scores), and in case the score is lower than what is expected, we render a ReCaptcha v2 Checkbox so that the user can check it manually.
1 - Add the ReCaptcha V3 in your Log in form
Thanks to the recaptcha Gem we have helpers method to simplify the process. We will also play along with Turbo Frames to handle html replacement when ReCaptcha v3 is not correctly verified. In our sessions/new.html.erb file we can include the following code within the Log in form:
<!-- devise/sessions/new.html.erb -->
<%= turbo_frame_tag 'recaptchas' do %>
<%= recaptcha_v3(action: 'login', site_key: ENV['RECAPTCHA_SITE_KEY_V3']) %>
<% end %>
2 - Implement a method to verify the ReCaptchas
In our Sessions controller, we need to implement a method that will allow the ReCaptcha API to verify the request. We will use a prepend_before_action
callback on the create method, to verify the ReCaptchas requests even before devise user authentication.
# users/sessions_controller.rb
class Users::SessionsController < Devise::SessionsController
prepend_before_action :validate_recaptchas, only: [:create]
protected
def validate_recaptchas
v3_verify = verify_recaptcha(action: 'login',
minimum_score: 0.9,
secret_key: ENV['RECAPTCHA_SECRET_KEY_V3'])
return if v3_verify
self.resource = resource_class.new sign_in_params
respond_with_navigational(resource) { render :new }
end
end
The code above will work if the request score is 0.9 or higher, but if it is lower, it will render an error because Turbo will look for a partial with the turbo_stream
format. Plus we are only telling devise to render the form again without any improvement. We want to render the ReCaptcha v2 so that the user is not stuck on the same form with a low request score again and again.
3 - Create a turbo_stream view for ReCaptcha v2
Then let's create a view for our ReCaptcha v2 injection into the form. Since we want to replace the ReCaptcha v3 with the ReCaptcha v2 we will use the Turbo Stream helper turbo_stream.replace
to wrap the code we want to inject:
<!-- sessions/new.turbo_stream.erb -->
<%= turbo_stream.replace :recaptchas do %>
<% end %>
Note that the :recaptchas
attribute is the same as the turbo_frame_tag
id we set in our sessions/new.html.erb as a wrapper for our ReCaptcha v3. We will replace the content of the turbo_frame_tag 'recaptchas'
with the content of our turbo_stream
. Cool right ?
Now is the tricky part, you will probably be tempted to use the recpatchas Gem helper method to render our ReCaptcha v2 checkbox like:
<!-- sessions/new.turbo_stream.erb -->
# Do not do that !
<%= turbo_stream.replace :recaptchas do %>
<div class="mt-4">
<%= recaptcha_tags(site_key: ENV['RECAPTCHA_SITE_KEY_V2']) %>
</div>
<% end %>
This will not work because of Turbo. Indeed, since Turbo avoid the page refresh and is not evaluating inline scripts when rendering (It is a bug tracked here: hotwired/turbo#186), the ReCaptcha API will not be called again. We thus have to render the ReCaptcha v2 explicitly and this is where Stimulus comes in !
In the turbo_stream.replace
block from your new.turbo_stream.erb file, insert an empty DIV with the id recaptchaV2
and with the necessary attributes to be able to connect to our future Stimulus controller:
<!-- sessions/new.turbo_stream.erb -->
<%= turbo_stream.replace :recaptchas do %>
<div class='mt-4' id='recaptchaV2' data-controller='load-recaptcha-v2' data-load-recaptcha-v2-site-key-value=<%= ENV['RECAPTCHA_SITE_KEY_V2'] %>></div>
<% end %>
4 - Render ReCaptcha v2 explicitly from Stimulus
Create a Stimulus controller called load_recaptcha_v2_controller.js (It has to be the same name as the data-controller attribute from our recaptchaV2
DIV). In this controller we will fetch our ReCaptcha v2 sitekey value defined in our data-load-recaptcha-v2-site-key-value
attribute from our recaptchaV2
DIV and then we will inject the ReCaptcha V2 explicitely on initialization. So your load_recaptcha_v2 controller should look like this:
// load_recaptcha_v2_controller.js
import { Controller } from "stimulus"
export default class extends Controller {
static values = { siteKey: String }
initialize() {
grecaptcha.render("recaptchaV2", { sitekey: this.siteKeyValue } )
}
}
5 - Add the ReCaptcha v2 verification
We now need to add the ReCaptcha v2 verification in our Session controller. To do so is really simple, just modify your validate_recaptchas
method to include v2 validation like below:
def validate_recaptchas
v3_verify = verify_recaptcha(action: 'login',
minimum_score: 0.9,
secret_key: ENV['RECAPTCHA_SECRET_KEY_V3'])
v2_verify = verify_recaptcha(secret_key: ENV['RECAPTCHA_SECRET_KEY_V2'])
return if v3_verify || v2_verify
self.resource = resource_class.new sign_in_params
respond_with_navigational(resource) { render :new }
end
Now test it ! To verify if the ReCaptcha v2 is correctly rendered, set the minimum_score
of the ReCaptcha v3 verification helper to 1 which will cause the v3 to fail everytime and this is what we want in order to test it. Of course do not forget to put this value back to normal afterwards !
6 - Configure Flash messages
In case of ReCaptcha v3 validation failures, you probably want your user to know what is happening by displaying a flash message like "Please check the ReCaptcha to continue" or something like this.
Well, we need to make some small changes to the default flash messages settings because Flash messages will not work when Turbo (again !) do not redirect.
Go to your application.html.erb file and wrap the Flash messages partial into a Turbo Frame:
<!-- application.html.erb -->
<%= turbo_frame_tag :flashes do %>
<%= render 'shared/flashes' %>
<% end %>
Then all you have to do is update the Flash messages partial through a Turbo Stream in your sessions/new.turbo_stream.erb file which now looks like this:
<%= turbo_stream.replace :recaptchas do %>
<div class='mt-4' data-controller='load-recaptcha-v2' id='recaptchaV2' data-load-recaptcha-v2-site-key-value=<%= ENV['RECAPTCHA_SITE_KEY_V2'] %>></div>
<% end %>
<%= turbo_stream.update(:flashes, partial: 'shared/flashes', locals: { alert: 'Please check the ReCaptcha to continue' }) %>
And that is finally it ! You should have a functional Devise authentication form working with Turbo.
I hope this helped and of course if you have some doubts or even better, if you have improvements or refactoring to propose please feel free to contact me !
Top comments (5)
very helpful tutorial, much appreciated, got it working this afternoon in our rails 7 app
Great tutorial Francois, thanks! I'm having trouble integrating this in the sign up flow. any thoughts from your end as to the ideal setup for that?
Hi Pratik, thank you !
I guess this should work if you put the same logic in the Devise RegistrationsController and the Devise Registrations views (new.html.erb and new.turbo_stream.erb).
Although in this case it might be a good idea to refactor with shared view partial or put the
check_captcha
method into the private part of the main scoped devise controller.If you want to tell me what exactly you are having trouble with I'll try to help you out :)
Hi, thank you for the nice tutorial.
However my browser complains that grecaptcha is undefined in load_recaptcha_v2_controller.js
Have you experienced the same error?
Hi Zach, sorry for the late reply.
I hope you found your answer already ! Anyway here are some clues for troubleshooting this behaviour.
Context: In this tutorial, the grecaptcha variable is initialized from the script generated by the recaptcha_v3 tag on first page load.
Then, in case the Recaptcha V3 score is lower than expeted, the Recaptcha V2 is rendered through Turbo Stream, which allows the javascript from the recaptcha_v3 tag to remain available for the load_recaptcha_v2_controller.js and thus, access the grecaptcha variable.
Solution: If you are either rendering Recaptcha V2 without rendering Recaptcha V3 first, or if you do a full page reload when rendering Recaptcha V2, just add the Recaptcha JS API manually with adding the
<script src="https://www.recaptcha.net/recaptcha/api.js" async="" defer=""></script>
line in your HTML.I hope it helped !