12

I need to write a log when somebody failes to log in to my app (to track bruteforce attempts). Also I decided to log successful authentications. So I created a SessionsController < Devise::SessionsController and tried to override the sessions#create method like that: https://gist.github.com/3884693

The first part works perfectly, but when the auth failes rails throws some kind of an exception and never reaches the if statement. So I don't know what to do.

Almaron
  • 4,127
  • 6
  • 26
  • 48

6 Answers6

27

This answer to a previous SO question - Devise: Registering log in attempts has the answer.

The create action in the devise controller calls warden.authenticate!, which attempts to authenticate the user with the supplied params. If authentication fails then authenticate! will call the devise failure app, which then runs the SessionsController#new action. Note, any filters you have for the create action will not run if authentication fails.

So the solution is to add a filter after the new action which checks the contents of env["warden.options"] and takes the appropriate action.

I tried out the suggestion, and was able to log both the successful & failed login attempts. Here is the relevant controller code:

class SessionsController < Devise::SessionsController
  after_filter :log_failed_login, :only => :new

  def create
    super
    ::Rails.logger.info "\n***\nSuccessful login with email_id : #{request.filtered_parameters["user"]}\n***\n"
  end

  private
  def log_failed_login
    ::Rails.logger.info "\n***\nFailed login with email_id : #{request.filtered_parameters["user"]}\n***\n" if failed_login?
  end 

  def failed_login?
    (options = env["warden.options"]) && options[:action] == "unauthenticated"
  end 
end

The log has the following entries:

For a successful login

Started POST "/users/sign_in"
...
...
***
Successful login with email_id : {"email"=>...
***
...
...
Completed 302 Found

For a failed login

Started POST "/users/sign_in"
...
...
Completed 401 Unauthorized 
Processing by SessionsController#new as HTML
...
...
***
Failed login with email_id : {"email"=>...
***
...
...
Completed 302 Found
Community
  • 1
  • 1
Prakash Murthy
  • 12,923
  • 3
  • 46
  • 74
  • 1
    I also followed the Devise guide on [Configuring Controllers](https://github.com/plataformatec/devise#configuring-controllers) to get the rest of the code working. – claptimes Jul 31 '14 at 15:47
  • I had to change this to a symbol: options[:action] == :unauthenticated – pixelearth Mar 04 '17 at 06:03
  • Is there also a way to capture failed login attempts due to a user trying to login with an unconfirmed account? – bo-oz Mar 10 '20 at 08:44
9

Prakash's answer is helpful, but it's not ideal to rely on SessionsController#new to be run as a side effect. I believe this is cleaner:

class LogAuthenticationFailure < Devise::FailureApp
  def respond
    if request.env.dig('warden.options', :action) == 'unauthenticated'
      Rails.logger.info('...')
    end
    super
  end
end

...

Devise.setup do |config|

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

Check out Graeme's answer if you'd prefer to hook into Warden's callbacks (Devise is implemented using Warden).

Max Wallace
  • 3,609
  • 31
  • 42
  • 1
    One thing to consider with this approach is that it also logs accessing unauthenticated routes... you can add another filter to ensure it's only on login attempts: `if env.dig("warden.options", :action) == "unauthenticated" && env.dig("warden.options", :message) == :invalid` – flynfish May 28 '19 at 18:08
  • In Rails 5.1 I'm getting `DEPRECATION WARNING: env is deprecated and will be removed from Rails 5.1` – Purplejacket Jul 22 '19 at 21:14
  • @Purplejacket thanks for the comment! I updated `env` to `request.env` per https://stackoverflow.com/a/34471522/1067145. Let me know if you still see the deprecation or if there's any other issues. – Max Wallace Jul 23 '19 at 21:57
  • Are there any parameters passed to `Devise::FailureApp`. In other words, am I able to pull some info in the user that tried to login? – bo-oz Mar 10 '20 at 07:11
  • @bo-oz I haven't used this code in a long time (can't verify this) but try digging into the request.env? You should be able to get the session info. – Max Wallace Mar 11 '20 at 15:05
  • This triggers an auth failure on logout, is it possible to avoid this? – fatfrog Aug 25 '20 at 18:48
8

I had the same question but was unable to resolve it using the "warden.options" since, in my case, these were being cleared before redirecting to the sessions#new action. After looking into a few alternatives that I judged to be too brittle (because they involved extending some Devise classes and aliasing existing methods), I wound up using some callbacks provided by Warden. It works better for me because the callback is invoked inside the current request-response cycle and the parameters are all preserved in the env object.

These callbacks are named and appear to be designed to solve this and related problems. And they are documented!

Warden supports the following callbacks as of warden-1.2.3:

  • after_set_user
  • after_authentication (useful for logging successful sign ins)
  • after_fetch (alias for after_set_user)
  • before_failure (useful for logging failed sign ins - example below)
  • after_failed_fetch
  • before_logout
  • on_request

Each callback is set directly on the Warden::Manager class (may be inside config/initializers/devise.rb). To track a failed authentication attempt I added this:

Warden::Manager.before_failure do |env, opts|
  email = env["action_dispatch.request.request_parameters"][:user] &&
          env["action_dispatch.request.request_parameters"][:user][:email]
  # unfortunately, the User object has been lost by the time 
  # we get here; so we take a db hit because I care to see 
  # if the email matched a user account in our system
  user_exists = User.where(email: email).exists?

  if opts[:message] == :unconfirmed
    # this is a special case for me because I'm using :confirmable
    # the login was correct, but the user hasn't confirmed their 
    # email address yet
    ::Rails.logger.info "*** Login Failure: unconfirmed account access: #{email}"
  elsif opts[:action] == "unauthenticated"
    # "unauthenticated" indicates a login failure
    if !user_exists
      # bad email:
      # no user found by this email address
      ::Rails.logger.info "*** Login Failure: bad email address given: #{email}"
    else
      # the user exists in the db, must have been a bad password
      ::Rails.logger.info "*** Login Failure: email-password mismatch: #{email}"
    end
  end
end

I expect that you could use the before_logout callback to track logout actions as well, but I haven't tested it. There appear to be prepend_ variants of the callbacks as well.

Jai Chauhan
  • 4,035
  • 3
  • 36
  • 62
Graeme
  • 970
  • 8
  • 16
  • It seems the extra DB hit is no longer necessary. When user is not found in the database, the `opts[:message]` is set to `:not_found_in_database` – bo-oz Mar 10 '20 at 07:41
2

I've found another way to do this, if you want, for example, display a custom message if login fails.

In my job, if login fails we check the activity status (custom logic) and display a message, no matter if the login was correct or not.

After debug a little bit and read warden docs I know this now: Warden executes a throw(:warden, opts), so, according to ruby docs, a throw must be captured inside a catch block.

def create
  flash.clear
  login_result = catch(:warden) { super }
  return unless login_failed?(login_result)

  email = params[:user][:email]
  flash[:alert] = # here I call my service that calculates the message
  redirect_to new_user_session_path
end

def login_failed?(login_result)
  login_result.is_a?(Hash) && login_result.key?(:scope) && login_result.key?(:recall)
end

throw docs: https://ruby-doc.org/core-2.6.3/Kernel.html#method-i-throw

catch docs: https://ruby-doc.org/core-2.6.3/Kernel.html#method-i-catch

Juan Furattini
  • 776
  • 6
  • 9
1

For logout logging, you need to catch the destroy event, so add the following to the Session controller (from the above answer):

before_filter :log_logout, :only => :destroy  #add this at the top with the other filters

def log_logout
     ::Rails.logger.info "*** Logging out : #{current_user.email} ***\n"  
end
DeeY
  • 922
  • 1
  • 8
  • 15
0

Building on Prakash Murty's answer, I think the approach in this answer (https://stackoverflow.com/a/34816998/891359) is a cleaner way to log a succesfull login attempt. Instead of calling super, Devise offers a way to pass a block that is yielded before the view is rendered.

So instead of doing this:

class SessionsController < Devise::SessionsController
  def create
    super
    ::Rails.logger.info "\n***\nSuccessful login with email_id : #{request.filtered_parameters["user"]}\n***\n"
  end
end

It is cleaner to do:

class SessionsController < Devise::SessionsController
  def create
    super do |user|
      ::Rails.logger.info "\n***\nSuccessful login with email_id : #{user.email}\n***\n"
    end
  end
end
bo-oz
  • 2,842
  • 2
  • 24
  • 44