DEV Community

Cover image for Rails Devise and ReCaptcha with Hotwire (Turbo and Stimulus)
Francois Adrien
Francois Adrien

Posted on • Edited on

Rails Devise and ReCaptcha with Hotwire (Turbo and Stimulus)

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

Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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 } )
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 %>

Enter fullscreen mode Exit fullscreen mode

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' }) %>
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
jontyd profile image
Jonty Davis

very helpful tutorial, much appreciated, got it working this afternoon in our rails 7 app

Collapse
 
pratikborole profile image
Pratik Borole

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?

Collapse
 
fadrien profile image
Francois Adrien

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 :)

Collapse
 
zachgl profile image
zachGl

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?

Collapse
 
fadrien profile image
Francois Adrien • Edited

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 !