Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Getting Devise AJAX sign in working with confirmable

We're trying to extend Devise (3.1.1) signin/signup methods to handle AJAX requests, but are getting stuck with the confirmable logic. Normally, if a user signs in to Devise before confirming their account, they'll get redirected to the login screen with the flash message: "You have to confirm your account before continuing." We can't figure out where Devise is checking for confirmation and making the decision to redirect.

Here's our extended sessions_controller code. It works fine for successful and failed login attempts:

  # CUSTOM (Mix of actual devise controller method and ajax customization from http://natashatherobot.com/devise-rails-sign-in/):
  def create
    # (NOTE: If user is not confirmed, execution will never get this far...)
    respond_to do |format|
      format.html do 
        # Copied from original create method:
        self.resource = warden.authenticate!(auth_options)
        set_flash_message(:notice, :signed_in) if is_navigational_format?
        sign_in(resource_name, resource)
        respond_with resource, :location => after_sign_in_path_for(resource)
      end                       
      format.js  do
        # Derived from Natasha AJAX recipe:
        self.resource = warden.authenticate!(:scope => resource_name, :recall => "#{controller_path}#failure")
        sign_in(resource_name, resource)
        return render :json => {:success => true, :token => form_authenticity_token() }, content_type: "application/json" # Need to explicitely set content type to JSON, otherwise it gets set as application/javascript and success handler never gets hit.
      end          
    end

  end

  def failure
    return render :json => {:success => false, :errors => ["Login failed."]}
  end

The problem is, if a user is unconfirmed, the create method never gets hit. The redirection happens somewhere before, which means we can't handle it in a JS friendly manner. But looking through the source I can't find any before filter that does a confirmable check. Where is the confirmable check happening and how can we intercept it?

like image 394
Yarin Avatar asked Oct 31 '13 13:10

Yarin


4 Answers

What's happening is that the sign_in method is breaking you out of the normal flow by throwing a warden error, which will call the failure app.

If you look at the definition of sign_in in lib/devise/controllers/helpers.rb, you'll see that in a normal flow where you're signing in a user for the first time, you wind up calling

warden.set_user(resource, options.merge!(:scope => scope)

warden is a reference to a Warden::Proxy object, and if you look at what set_user does (you can see that at warden/lib/warden/proxy.rb:157-182), you'll see that after serializing the user into the session it runs any after_set_user callbacks.

Devise defines a bunch of these in lib/devise/hooks/, and the particular one we're interested is in lib/devise/hooks/activatable.rb:

Warden::Manager.after_set_user do |record, warden, options|
  if record && record.respond_to?(:active_for_authentication?) && !record.active_for_authentication?
    scope = options[:scope]
    warden.logout(scope)
    throw :warden, :scope => scope, :message => record.inactive_message
  end
end

As you can see, if the record is not active_for_authentication?, then we throw. This is what is happening in your case -- active_for_authentication? returns false for a confirmable resource that is not yet confirmed (see lib/devise/models/confirmable.rb:121-127).

And when we throw :warden, we end up calling the devise failure_app. So that's what's happening, and why you're breaking out of the normal control flow for your controller.

(Actually the above is talking about the normal sessions controller flow. I think your js block is actually redundant -- calling warden.authenticate! will set the user as well, so I think you're throwing before you even get to sign_in.)

To answer your second question, one possible way of handling this is to create your own failure app. By default devise sets warden's failure_app to Devise::Delegator, which allows you to specify different failure apps for different devise models, but defaults to Devise::FailureApp if nothing has been configured. You could either customize the existing failure app, replace it with your own failure app by configuring warden, or you could customize the delegator to use the default failure app for html requests and delegate to a different failure app for json.

like image 163
gregates Avatar answered Nov 14 '22 16:11

gregates


I met same problem before. Actually it can be solved very simply.

Go to config/initializers/devise.rb, find the following line

# ==> Configuration for :confirmable
# A period that the user is allowed to access the website even without
# confirming his account. For instance, if set to 2.days, the user will be
# able to access the website for two days without confirming his account,
# access will be blocked just in the third day. Default is 0.days, meaning
# the user cannot access the website without confirming his account.
config.allow_unconfirmed_access_for = 0.days

Activate the last line and set the period as what you like, say 7.days

This period is a grace period, once set, you can expect

  1. User can be signed in automatically after signing up, no more redirection to confirmation page.

  2. Within this period, unconfirmed user can be remembered, and signed in without problem.

  3. After the period expired, unconfirmed user will be redirect to confirmation page and must confirm password before continuing.

With :confirmable enabled, I'm afraid the grace period is the most user friendly. If you are even not satisfied with that, there is little points to use :confirmable :)

Side notes about JSON response

I just noticed you used JSON response when I read Rich Peck's comment. I would suggest you not to use JSON response, instead, a simpler approach to use an Ajax/Plain JS form as login form, and expect Devise to do normal page redirect. The reason is there would be too much resources to update if using JSON response, for example, csrf_token, cookie, existing authorization etc. See my other answer about similar topic: https://stackoverflow.com/a/19538974/1721198

like image 25
Billy Chan Avatar answered Nov 14 '22 15:11

Billy Chan


@gregates gets credit for figuring this out, but I wanted to show how I actually got this working:

The problem was you can't check for or intercept an unconfirmed user at the controller level because it gets checked at the model level with active_for_authentication?, and then gets handled by Warden's failure app immediately. So any reference to current_user will short-circuit whatever controller logic you've got going on. The solution is to introduce your own custom FailureApp, and handle things as you see fit. In our case, that meant raising a special error for AJAX auth errors. Here's how we did it:

First, create a custom failure app, as described here:
How To: Redirect to a specific page when the user can not be authenticated · plataformatec/devise Wiki

lib/custom_failure.rb:

class CustomFailure < Devise::FailureApp

  def respond

    # We override Devise's handling to throw our own custom errors on AJAX auth failures,
    # because Devise provides no easy way to deal with them:

    if request.xhr? && warden_message == :unconfirmed
      raise MyCustomErrors::AjaxAuthError.new("Confirmation required. Check your inbox.")
    end

    # Original code:

    if http_auth?
      http_auth
    elsif warden_options[:recall]
      recall
    else
      redirect
    end
  end

end

And then tell Warden to use the custom failure app:

config/initializers/devise.rb:

  config.warden do |manager|
    manager.failure_app = CustomFailure
  end

And then you just handle your custom error however you like. In our case, we had our ErrorController return a 401 JSON response.

like image 3
Yarin Avatar answered Nov 14 '22 16:11

Yarin


To add to @gregates answer, try overriding the method active_for_authentication? in your User(resource) model like this:

def active_for_authentication?
  true
end

If you are able to login, then the issue has to be with your sessions controller. Make sure you havent missed anything obvious like specfying the route to your new sessions controller

like image 1
Anurag Abbott Avatar answered Nov 14 '22 17:11

Anurag Abbott