├── lib ├── auth_trail │ ├── version.rb │ ├── geocode_job.rb │ └── manager.rb ├── generators │ └── authtrail │ │ ├── templates │ │ ├── model_none.rb.tt │ │ ├── model_lockbox.rb.tt │ │ ├── model_activerecord.rb.tt │ │ ├── initializer.rb.tt │ │ └── login_activities_migration.rb.tt │ │ └── install_generator.rb └── authtrail.rb ├── test ├── internal │ ├── config │ │ ├── routes.rb │ │ └── database.yml │ ├── app │ │ ├── models │ │ │ ├── user.rb │ │ │ └── login_activity.rb │ │ └── controllers │ │ │ └── application_controller.rb │ └── db │ │ └── schema.rb ├── test_helper.rb ├── install_generator_test.rb └── authtrail_test.rb ├── .gitignore ├── gemfiles ├── rails72.gemfile ├── rails80.gemfile └── rails71.gemfile ├── Rakefile ├── Gemfile ├── authtrail.gemspec ├── .github └── workflows │ └── build.yml ├── LICENSE.txt ├── CHANGELOG.md └── README.md /lib/auth_trail/version.rb: -------------------------------------------------------------------------------- 1 | module AuthTrail 2 | VERSION = "0.7.0" 3 | end 4 | -------------------------------------------------------------------------------- /test/internal/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | devise_for :users 3 | end 4 | -------------------------------------------------------------------------------- /test/internal/config/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: sqlite3 3 | database: db/combustion_test.sqlite 4 | -------------------------------------------------------------------------------- /test/internal/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | devise :database_authenticatable 3 | end 4 | -------------------------------------------------------------------------------- /test/internal/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/internal/app/models/login_activity.rb: -------------------------------------------------------------------------------- 1 | class LoginActivity < ActiveRecord::Base 2 | belongs_to :user, polymorphic: true, optional: true 3 | end 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | *.lock 10 | *.log 11 | *.sqlite* 12 | -------------------------------------------------------------------------------- /lib/generators/authtrail/templates/model_none.rb.tt: -------------------------------------------------------------------------------- 1 | class LoginActivity < ApplicationRecord 2 | belongs_to :user, polymorphic: true, optional: true 3 | end 4 | -------------------------------------------------------------------------------- /gemfiles/rails72.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rake" 6 | gem "minitest" 7 | gem "combustion" 8 | gem "rails", "~> 7.2.0" 9 | gem "sqlite3" 10 | gem "devise" 11 | -------------------------------------------------------------------------------- /gemfiles/rails80.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rake" 6 | gem "minitest" 7 | gem "combustion" 8 | gem "rails", "~> 8.0.0" 9 | gem "sqlite3" 10 | gem "devise" 11 | -------------------------------------------------------------------------------- /gemfiles/rails71.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rake" 6 | gem "minitest" 7 | gem "combustion" 8 | gem "rails", "~> 7.1.0" 9 | gem "sqlite3", "< 2" 10 | gem "devise" 11 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new do |t| 5 | t.test_files = FileList["test/**/*_test.rb"] 6 | t.warning = false # for devise 7 | end 8 | 9 | task default: :test 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "rake" 6 | gem "minitest" 7 | gem "combustion" 8 | gem "rails", "~> 8.1.0" 9 | gem "sqlite3", platform: :ruby 10 | gem "sqlite3-ffi", platform: :jruby 11 | gem "devise" 12 | -------------------------------------------------------------------------------- /lib/generators/authtrail/templates/model_lockbox.rb.tt: -------------------------------------------------------------------------------- 1 | class LoginActivity < ApplicationRecord 2 | belongs_to :user, polymorphic: true, optional: true 3 | 4 | <%= lockbox_method %> :identity, :ip 5 | blind_index :identity, :ip 6 | 7 | before_save :reduce_precision 8 | 9 | # reduce precision to city level to protect IP 10 | def reduce_precision 11 | self.latitude = latitude&.round(1) if try(:latitude_changed?) 12 | self.longitude = longitude&.round(1) if try(:longitude_changed?) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/generators/authtrail/templates/model_activerecord.rb.tt: -------------------------------------------------------------------------------- 1 | class LoginActivity < ApplicationRecord 2 | belongs_to :user, polymorphic: true, optional: true 3 | 4 | encrypts :identity, deterministic: true 5 | encrypts :ip, deterministic: true 6 | 7 | before_save :reduce_precision 8 | 9 | # reduce precision to city level to protect IP 10 | def reduce_precision 11 | self.latitude = latitude&.round(1) if try(:latitude_changed?) 12 | self.longitude = longitude&.round(1) if try(:longitude_changed?) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/generators/authtrail/templates/initializer.rb.tt: -------------------------------------------------------------------------------- 1 | # set to true for geocoding (and add the geocoder gem to your Gemfile) 2 | # we recommend configuring local geocoding as well 3 | # see https://github.com/ankane/authtrail#geocoding 4 | AuthTrail.geocode = false 5 | 6 | # add or modify data 7 | # AuthTrail.transform_method = lambda do |data, request| 8 | # data[:request_id] = request.request_id 9 | # end 10 | 11 | # exclude certain attempts from tracking 12 | # AuthTrail.exclude_method = lambda do |data| 13 | # data[:identity] == "capybara@example.org" 14 | # end 15 | -------------------------------------------------------------------------------- /authtrail.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/auth_trail/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "authtrail" 5 | spec.version = AuthTrail::VERSION 6 | spec.summary = "Track Devise login activity" 7 | spec.homepage = "https://github.com/ankane/authtrail" 8 | spec.license = "MIT" 9 | 10 | spec.author = "Andrew Kane" 11 | spec.email = "andrew@ankane.org" 12 | 13 | spec.files = Dir["*.{md,txt}", "{lib}/**/*"] 14 | spec.require_path = "lib" 15 | 16 | spec.required_ruby_version = ">= 3.2" 17 | 18 | spec.add_dependency "railties", ">= 7.1" 19 | spec.add_dependency "warden" 20 | end 21 | -------------------------------------------------------------------------------- /test/internal/db/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | create_table :login_activities do |t| 3 | t.string :scope 4 | t.string :strategy 5 | t.string :identity 6 | t.boolean :success 7 | t.string :failure_reason 8 | t.references :user, polymorphic: true 9 | t.string :context 10 | t.string :ip 11 | t.text :user_agent 12 | t.text :referrer 13 | t.string :city 14 | t.string :region 15 | t.string :country 16 | t.float :latitude 17 | t.float :longitude 18 | t.datetime :created_at 19 | 20 | # app-specific fields 21 | t.string :request_id 22 | end 23 | 24 | create_table :users do |t| 25 | t.string :email 26 | t.string :encrypted_password 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/generators/authtrail/templates/login_activities_migration.rb.tt: -------------------------------------------------------------------------------- 1 | class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %> 2 | def change 3 | create_table :login_activities<%= primary_key_type %> do |t| 4 | t.string :scope 5 | t.string :strategy 6 | <%= identity_column %> 7 | t.boolean :success 8 | t.string :failure_reason 9 | t.references :user<%= foreign_key_type %>, polymorphic: true 10 | t.string :context 11 | <%= ip_column %> 12 | t.text :user_agent 13 | t.text :referrer 14 | t.string :city 15 | t.string :region 16 | t.string :country 17 | t.float :latitude 18 | t.float :longitude 19 | t.datetime :created_at 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | include: 10 | - ruby: 3.4 11 | gemfile: Gemfile 12 | - ruby: 3.4 13 | gemfile: gemfiles/rails80.gemfile 14 | - ruby: 3.3 15 | gemfile: gemfiles/rails72.gemfile 16 | - ruby: 3.2 17 | gemfile: gemfiles/rails71.gemfile 18 | env: 19 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 20 | steps: 21 | - uses: actions/checkout@v5 22 | - uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | bundler-cache: true 26 | - run: bundle exec rake test 27 | -------------------------------------------------------------------------------- /lib/auth_trail/geocode_job.rb: -------------------------------------------------------------------------------- 1 | module AuthTrail 2 | class GeocodeJob < ActiveJob::Base 3 | # default queue is used if queue_as returns nil 4 | # Rails has a test for this 5 | queue_as { AuthTrail.job_queue } 6 | 7 | def perform(login_activity) 8 | result = 9 | begin 10 | Geocoder.search(login_activity.ip).first 11 | rescue NameError 12 | raise "Add the geocoder gem to your Gemfile to use geocoding" 13 | rescue => e 14 | Rails.logger.info "Geocode failed: #{e.message}" 15 | nil 16 | end 17 | 18 | if result 19 | attributes = { 20 | city: result.try(:city), 21 | region: result.try(:state), 22 | country: result.try(:country), 23 | country_code: result.try(:country_code), 24 | latitude: result.try(:latitude), 25 | longitude: result.try(:longitude) 26 | } 27 | attributes.each do |k, v| 28 | login_activity.try("#{k}=", v.presence) 29 | end 30 | login_activity.save! 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2025 Andrew Kane 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/auth_trail/manager.rb: -------------------------------------------------------------------------------- 1 | module AuthTrail 2 | module Manager 3 | class << self 4 | def after_set_user(user, auth, opts) 5 | request = ActionDispatch::Request.new(auth.env) 6 | 7 | AuthTrail.track( 8 | strategy: detect_strategy(auth), 9 | scope: opts[:scope].to_s, 10 | identity: AuthTrail.identity_method.call(request, opts, user), 11 | success: true, 12 | request: request, 13 | user: user 14 | ) 15 | end 16 | 17 | def before_failure(env, opts) 18 | request = ActionDispatch::Request.new(env) 19 | 20 | AuthTrail.track( 21 | strategy: detect_strategy(env["warden"]), 22 | scope: opts[:scope].to_s, 23 | identity: AuthTrail.identity_method.call(request, opts, nil), 24 | success: false, 25 | request: request, 26 | failure_reason: opts[:message].to_s 27 | ) 28 | end 29 | 30 | private 31 | 32 | def detect_strategy(auth) 33 | strategy = auth.env["omniauth.auth"]["provider"] if auth.env["omniauth.auth"] 34 | strategy ||= auth.winning_strategy.class.name.split("::").last.underscore if auth.winning_strategy 35 | strategy ||= "database_authenticatable" 36 | strategy 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | Bundler.require 3 | require "minitest/autorun" 4 | 5 | Devise.setup do |config| 6 | require "devise/orm/active_record" 7 | 8 | config.warden do |manager| 9 | manager.failure_app = ->(env) { [401, {"Content-Type" => "text/html"}, ["Unauthorized"]] } 10 | end 11 | end 12 | 13 | Combustion.path = "test/internal" 14 | Combustion.initialize! :active_record, :action_controller, :active_job do 15 | config.load_defaults Rails::VERSION::STRING.to_f 16 | config.action_dispatch.show_exceptions = :none 17 | config.active_job.queue_adapter = :test 18 | 19 | logger = ActiveSupport::Logger.new(ENV["VERBOSE"] ? STDOUT : nil) 20 | config.action_controller.logger = logger 21 | config.active_record.logger = logger 22 | config.active_job.logger = logger 23 | end 24 | 25 | class Minitest::Test 26 | def with_options(options) 27 | previous_options = {} 28 | options.each_key do |k| 29 | previous_options[k] = AuthTrail.send(k) 30 | end 31 | begin 32 | options.each do |k, v| 33 | AuthTrail.send("#{k}=", v) 34 | end 35 | yield 36 | ensure 37 | previous_options.each do |k, v| 38 | AuthTrail.send("#{k}=", v) 39 | end 40 | end 41 | end 42 | end 43 | 44 | # https://github.com/rails/rails/issues/54595 45 | if RUBY_ENGINE == "jruby" && Rails::VERSION::MAJOR >= 8 46 | Rails.application.reload_routes_unless_loaded 47 | end 48 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.7.0 (2025-05-04) 2 | 3 | - Removed support for Rails < 7.1 and Ruby < 3.2 4 | 5 | ## 0.6.0 (2024-11-11) 6 | 7 | - Improved generator for Active Record encryption and MySQL 8 | - Removed support for Rails < 7 and Ruby < 3.1 9 | 10 | ## 0.5.0 (2023-07-02) 11 | 12 | - Made Active Record and Active Job optional 13 | - Removed support for Rails < 6.1 and Ruby < 3 14 | 15 | ## 0.4.3 (2022-06-12) 16 | 17 | - Updated install generator for Lockbox 1.0 18 | 19 | ## 0.4.2 (2021-12-13) 20 | 21 | - Added experimental support for Active Record encryption 22 | - Fixed error with Rails 7 rc1 23 | 24 | ## 0.4.1 (2021-08-14) 25 | 26 | - Improved error message when `geocoder` gem not installed 27 | 28 | ## 0.4.0 (2021-08-13) 29 | 30 | - Disabled geocoding by default (this was already the case for new installations with 0.3.0+) 31 | - Made the `geocoder` gem an optional dependency 32 | - Added `country_code` to geocoding 33 | 34 | ## 0.3.1 (2021-03-03) 35 | 36 | - Added `--lockbox` option to install generator 37 | 38 | ## 0.3.0 (2021-03-01) 39 | 40 | - Disabled geocoding by default for new installations 41 | - Raise an exception instead of logging when auditing fails 42 | - Removed support for Rails < 5.2 and Ruby < 2.6 43 | 44 | ## 0.2.2 (2020-11-21) 45 | 46 | - Added `transform_method` option 47 | 48 | ## 0.2.1 (2020-08-17) 49 | 50 | - Added `job_queue` option 51 | 52 | ## 0.2.0 (2019-06-23) 53 | 54 | - Added latitude and longitude 55 | - `AuthTrail::GeocodeJob` now inherits from `ActiveJob::Base` instead of `ApplicationJob` 56 | - Removed support for Rails 4.2 57 | 58 | ## 0.1.3 (2018-09-27) 59 | 60 | - Added support for Rails 4.2 61 | 62 | ## 0.1.2 (2018-07-30) 63 | 64 | - Added `identity_method` option 65 | - Fixed geocoding 66 | 67 | ## 0.1.1 (2018-07-13) 68 | 69 | - Improved strategy detection for failures 70 | - Fixed migration for MySQL 71 | 72 | ## 0.1.0 (2017-11-07) 73 | 74 | - First release 75 | -------------------------------------------------------------------------------- /test/install_generator_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | require "rails/generators/test_case" 4 | require "generators/authtrail/install_generator" 5 | 6 | class InstallGeneratorTest < Rails::Generators::TestCase 7 | tests Authtrail::Generators::InstallGenerator 8 | destination File.expand_path("../tmp", __dir__) 9 | setup :prepare_destination 10 | 11 | def test_encryption_lockbox 12 | run_generator ["--encryption=lockbox"] 13 | assert_file "config/initializers/authtrail.rb", /AuthTrail.geocode = false/ 14 | assert_file "app/models/login_activity.rb", /has_encrypted :identity, :ip/ 15 | assert_migration "db/migrate/create_login_activities.rb", /t.text :identity_ciphertext/ 16 | end 17 | 18 | def test_encryption_activerecord 19 | run_generator ["--encryption=activerecord"] 20 | assert_file "config/initializers/authtrail.rb", /AuthTrail.geocode = false/ 21 | assert_file "app/models/login_activity.rb", /encrypts :identity, deterministic: true/ 22 | assert_migration "db/migrate/create_login_activities.rb", /t.string :identity, index: true/ 23 | end 24 | 25 | def test_encryption_none 26 | run_generator ["--encryption=none"] 27 | assert_file "config/initializers/authtrail.rb", /AuthTrail.geocode = false/ 28 | assert_file "app/models/login_activity.rb", /LoginActivity < ApplicationRecord/ 29 | assert_migration "db/migrate/create_login_activities.rb", /t.string :identity, index: true/ 30 | end 31 | 32 | def test_primary_key_type 33 | with_generator_options({active_record: {primary_key_type: :uuid}}) do 34 | run_generator ["--encryption=lockbox"] 35 | end 36 | assert_migration "db/migrate/create_login_activities.rb", /id: :uuid/ 37 | assert_migration "db/migrate/create_login_activities.rb", /type: :uuid/ 38 | end 39 | 40 | private 41 | 42 | def with_generator_options(value) 43 | previous_value = Rails.configuration.generators.options 44 | begin 45 | Rails.configuration.generators.options = value 46 | yield 47 | ensure 48 | Rails.configuration.generators.options = previous_value 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/authtrail.rb: -------------------------------------------------------------------------------- 1 | # dependencies 2 | require "warden" 3 | 4 | # modules 5 | require_relative "auth_trail/manager" 6 | require_relative "auth_trail/version" 7 | 8 | module AuthTrail 9 | autoload :GeocodeJob, "auth_trail/geocode_job" 10 | 11 | class << self 12 | attr_accessor :exclude_method, :geocode, :track_method, :identity_method, :job_queue, :transform_method 13 | end 14 | self.geocode = false 15 | self.track_method = lambda do |data| 16 | login_activity = LoginActivity.new 17 | data.each do |k, v| 18 | login_activity.try("#{k}=", v) 19 | end 20 | login_activity.save! 21 | AuthTrail::GeocodeJob.perform_later(login_activity) if AuthTrail.geocode 22 | end 23 | self.identity_method = lambda do |request, opts, user| 24 | if user 25 | user.try(:email) 26 | else 27 | scope = opts[:scope] 28 | request.params[scope] && request.params[scope][:email] rescue nil 29 | end 30 | end 31 | 32 | def self.track(strategy:, scope:, identity:, success:, request:, user: nil, failure_reason: nil) 33 | data = { 34 | strategy: strategy, 35 | scope: scope, 36 | identity: identity, 37 | success: success, 38 | failure_reason: failure_reason, 39 | user: user, 40 | ip: request.remote_ip, 41 | user_agent: request.user_agent, 42 | referrer: request.referrer 43 | } 44 | 45 | if request.params[:controller] 46 | data[:context] = "#{request.params[:controller]}##{request.params[:action]}" 47 | end 48 | 49 | # add request data before exclude_method since exclude_method doesn't have access to request 50 | # could also add 2nd argument to exclude_method when arity > 1 51 | AuthTrail.transform_method.call(data, request) if AuthTrail.transform_method 52 | 53 | # if exclude_method throws an exception, default to not excluding 54 | exclude = AuthTrail.exclude_method && AuthTrail.safely(default: false) { AuthTrail.exclude_method.call(data) } 55 | 56 | unless exclude 57 | AuthTrail.track_method.call(data) 58 | end 59 | end 60 | 61 | def self.safely(default: nil) 62 | begin 63 | yield 64 | rescue => e 65 | warn "[authtrail] #{e.class.name}: #{e.message}" 66 | default 67 | end 68 | end 69 | end 70 | 71 | Warden::Manager.after_set_user except: :fetch do |user, auth, opts| 72 | AuthTrail::Manager.after_set_user(user, auth, opts) 73 | end 74 | 75 | Warden::Manager.before_failure do |env, opts| 76 | AuthTrail::Manager.before_failure(env, opts) if opts[:message] 77 | end 78 | -------------------------------------------------------------------------------- /lib/generators/authtrail/install_generator.rb: -------------------------------------------------------------------------------- 1 | require "rails/generators/active_record" 2 | 3 | module Authtrail 4 | module Generators 5 | class InstallGenerator < Rails::Generators::Base 6 | include ActiveRecord::Generators::Migration 7 | source_root File.join(__dir__, "templates") 8 | 9 | class_option :encryption, type: :string, required: true 10 | 11 | def copy_migration 12 | encryption # ensure valid 13 | migration_template "login_activities_migration.rb", "db/migrate/create_login_activities.rb", migration_version: migration_version 14 | end 15 | 16 | def copy_templates 17 | template "initializer.rb", "config/initializers/authtrail.rb" 18 | end 19 | 20 | def generate_model 21 | case encryption 22 | when "lockbox" 23 | template "model_lockbox.rb", "app/models/login_activity.rb", lockbox_method: lockbox_method 24 | when "activerecord" 25 | template "model_activerecord.rb", "app/models/login_activity.rb" 26 | else 27 | template "model_none.rb", "app/models/login_activity.rb" 28 | end 29 | end 30 | 31 | def migration_version 32 | "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]" 33 | end 34 | 35 | def identity_column 36 | case encryption 37 | when "lockbox" 38 | "t.text :identity_ciphertext\n t.string :identity_bidx, index: true" 39 | else 40 | if encryption == "activerecord" && mysql? 41 | "t.string :identity, limit: 510, index: true" 42 | else 43 | "t.string :identity, index: true" 44 | end 45 | end 46 | end 47 | 48 | def ip_column 49 | case encryption 50 | when "lockbox" 51 | "t.text :ip_ciphertext\n t.string :ip_bidx, index: true" 52 | else 53 | "t.string :ip, index: true" 54 | end 55 | end 56 | 57 | def encryption 58 | case options[:encryption] 59 | when "lockbox", "activerecord", "none" 60 | options[:encryption] 61 | else 62 | abort "Error: encryption must be lockbox, activerecord, or none" 63 | end 64 | end 65 | 66 | def lockbox_method 67 | if defined?(Lockbox::VERSION) && Lockbox::VERSION.to_i < 1 68 | "encrypts" 69 | else 70 | "has_encrypted" 71 | end 72 | end 73 | 74 | def mysql? 75 | adapter =~ /mysql|trilogy/i 76 | end 77 | 78 | def adapter 79 | ActiveRecord::Base.connection_db_config.adapter.to_s 80 | end 81 | 82 | def primary_key_type 83 | ", id: :#{key_type}" if key_type 84 | end 85 | 86 | def foreign_key_type 87 | ", type: :#{key_type}" if key_type 88 | end 89 | 90 | def key_type 91 | Rails.configuration.generators.options.dig(:active_record, :primary_key_type) 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /test/authtrail_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class AuthTrailTest < ActionDispatch::IntegrationTest 4 | def setup 5 | User.delete_all 6 | LoginActivity.delete_all 7 | end 8 | 9 | def test_success 10 | user = User.create!(email: "test@example.org", password: "secret") 11 | post user_session_url, params: {user: {email: "test@example.org", password: "secret"}} 12 | assert_response :found 13 | 14 | assert_equal 1, LoginActivity.count 15 | login_activity = LoginActivity.last 16 | assert_equal "user", login_activity.scope 17 | assert_equal "database_authenticatable", login_activity.strategy 18 | assert_equal "test@example.org", login_activity.identity 19 | assert login_activity.success 20 | assert_nil login_activity.failure_reason 21 | assert_equal user, login_activity.user 22 | assert_equal "devise/sessions#create", login_activity.context 23 | end 24 | 25 | def test_failure 26 | post user_session_url, params: {user: {email: "test@example.org", password: "bad"}} 27 | assert_response :unauthorized 28 | 29 | assert_equal 1, LoginActivity.count 30 | login_activity = LoginActivity.last 31 | assert_equal "user", login_activity.scope 32 | assert_equal "database_authenticatable", login_activity.strategy 33 | assert_equal "test@example.org", login_activity.identity 34 | refute login_activity.success 35 | assert_equal "not_found_in_database", login_activity.failure_reason 36 | assert_nil login_activity.user 37 | assert_equal "devise/sessions#create", login_activity.context 38 | end 39 | 40 | def test_exclude_method 41 | with_options(exclude_method: ->(data) { data[:identity] == "exclude@example.org" }) do 42 | post user_session_url, params: {user: {email: "exclude@example.org", password: "secret"}} 43 | assert_empty LoginActivity.all 44 | 45 | post user_session_url, params: {user: {email: "test@example.org", password: "secret"}} 46 | assert_equal 1, LoginActivity.count 47 | end 48 | end 49 | 50 | # error reported to safely but doesn't bubble up and doesn't exclude 51 | def test_exclude_method_error 52 | with_options(exclude_method: ->(data) { raise "Bad" }) do 53 | assert_output(nil, "[authtrail] RuntimeError: Bad\n") do 54 | post user_session_url, params: {user: {email: "test@example.org", password: "secret"}} 55 | end 56 | assert_equal 1, LoginActivity.count 57 | end 58 | end 59 | 60 | def test_track_method_error 61 | with_options(track_method: ->(data) { raise "Bad" }) do 62 | error = assert_raises do 63 | post user_session_url, params: {user: {email: "test@example.org", password: "secret"}} 64 | end 65 | assert_equal "Bad", error.message 66 | end 67 | end 68 | 69 | def test_geocode_true 70 | with_options(geocode: true) do 71 | assert_enqueued_with(job: AuthTrail::GeocodeJob, queue: "default") do 72 | post user_session_url, params: {user: {email: "test@example.org", password: "secret"}} 73 | end 74 | end 75 | end 76 | 77 | def test_geocode_false 78 | with_options(geocode: false) do 79 | post user_session_url, params: {user: {email: "test@example.org", password: "secret"}} 80 | assert_equal 0, enqueued_jobs.size 81 | end 82 | end 83 | 84 | def test_geocode_default 85 | post user_session_url, params: {user: {email: "test@example.org", password: "secret"}} 86 | assert_equal 0, enqueued_jobs.size 87 | end 88 | 89 | def test_job_queue 90 | with_options(geocode: true, job_queue: :low_priority) do 91 | assert_enqueued_with(job: AuthTrail::GeocodeJob, queue: "low_priority") do 92 | post user_session_url, params: {user: {email: "test@example.org", password: "secret"}} 93 | end 94 | end 95 | end 96 | 97 | def test_transform_method 98 | with_options(transform_method: ->(data, request) { data[:request_id] = request.uuid }) do 99 | post user_session_url, params: {user: {email: "exclude@example.org", password: "secret"}} 100 | assert LoginActivity.last.request_id 101 | end 102 | end 103 | 104 | def test_transform_method_exclude 105 | options = { 106 | transform_method: ->(data, request) { data[:exclude] = true }, 107 | exclude_method: ->(data) { data[:exclude] } 108 | } 109 | with_options(**options) do 110 | post user_session_url, params: {user: {email: "test@example.org", password: "secret"}} 111 | assert_empty LoginActivity.all 112 | end 113 | end 114 | 115 | def test_transform_method_error 116 | with_options(transform_method: ->(data, request) { raise "Bad" }) do 117 | error = assert_raises do 118 | post user_session_url, params: {user: {email: "test@example.org", password: "secret"}} 119 | end 120 | assert_equal "Bad", error.message 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AuthTrail 2 | 3 | Track Devise login activity 4 | 5 | :tangerine: Battle-tested at [Instacart](https://www.instacart.com/opensource) 6 | 7 | [![Build Status](https://github.com/ankane/authtrail/actions/workflows/build.yml/badge.svg)](https://github.com/ankane/authtrail/actions) 8 | 9 | ## Installation 10 | 11 | Add this line to your application’s Gemfile: 12 | 13 | ```ruby 14 | gem "authtrail" 15 | ``` 16 | 17 | To encrypt email and IP addresses with Lockbox, install [Lockbox](https://github.com/ankane/lockbox) and [Blind Index](https://github.com/ankane/blind_index) and run: 18 | 19 | ```sh 20 | rails generate authtrail:install --encryption=lockbox 21 | rails db:migrate 22 | ``` 23 | 24 | To use Active Record encryption, run: 25 | 26 | ```sh 27 | rails generate authtrail:install --encryption=activerecord 28 | rails db:migrate 29 | ``` 30 | 31 | If you prefer not to encrypt data, run: 32 | 33 | ```sh 34 | rails generate authtrail:install --encryption=none 35 | rails db:migrate 36 | ``` 37 | 38 | To enable geocoding, see the [Geocoding section](#geocoding). 39 | 40 | ## How It Works 41 | 42 | A `LoginActivity` record is created every time a user tries to login. You can then use this information to detect suspicious behavior. Data includes: 43 | 44 | - `scope` - Devise scope 45 | - `strategy` - Devise strategy 46 | - `identity` - email address 47 | - `success` - whether the login succeeded 48 | - `failure_reason` - if the login failed 49 | - `user` - the user if the login succeeded 50 | - `context` - controller and action 51 | - `ip` - IP address 52 | - `user_agent` and `referrer` - from browser 53 | - `city`, `region`, `country`, `latitude`, and `longitude` - from IP 54 | - `created_at` - time of event 55 | 56 | ## Features 57 | 58 | Exclude certain attempts from tracking - useful if you run acceptance tests 59 | 60 | ```ruby 61 | AuthTrail.exclude_method = lambda do |data| 62 | data[:identity] == "capybara@example.org" 63 | end 64 | ``` 65 | 66 | Add or modify data - also add new fields to the `login_activities` table if needed 67 | 68 | ```ruby 69 | AuthTrail.transform_method = lambda do |data, request| 70 | data[:request_id] = request.request_id 71 | end 72 | ``` 73 | 74 | Store the user on failed attempts 75 | 76 | ```ruby 77 | AuthTrail.transform_method = lambda do |data, request| 78 | data[:user] ||= User.find_by(email: data[:identity]) 79 | end 80 | ``` 81 | 82 | Write data somewhere other than the `login_activities` table 83 | 84 | ```ruby 85 | AuthTrail.track_method = lambda do |data| 86 | # code 87 | end 88 | ``` 89 | 90 | Use a custom identity method 91 | 92 | ```ruby 93 | AuthTrail.identity_method = lambda do |request, opts, user| 94 | if user 95 | user.email 96 | else 97 | request.params.dig(opts[:scope], :email) 98 | end 99 | end 100 | ``` 101 | 102 | Associate login activity with your user model 103 | 104 | ```ruby 105 | class User < ApplicationRecord 106 | has_many :login_activities, as: :user # use :user no matter what your model name 107 | end 108 | ``` 109 | 110 | The `LoginActivity` model uses a [polymorphic association](https://guides.rubyonrails.org/association_basics.html#polymorphic-associations) so it can be associated with different user models. 111 | 112 | ## Geocoding 113 | 114 | AuthTrail uses [Geocoder](https://github.com/alexreisner/geocoder) for geocoding. We recommend configuring [local geocoding](#local-geocoding) or [load balancer geocoding](#load-balancer-geocoding) so IP addresses are not sent to a 3rd party service. If you do use a 3rd party service and adhere to GDPR, be sure to add it to your subprocessor list. 115 | 116 | To enable geocoding, add this line to your application’s Gemfile: 117 | 118 | ```ruby 119 | gem "geocoder" 120 | ``` 121 | 122 | And update `config/initializers/authtrail.rb`: 123 | 124 | ```ruby 125 | AuthTrail.geocode = true 126 | ``` 127 | 128 | Geocoding is performed in a background job so it doesn’t slow down web requests. Set the job queue with: 129 | 130 | ```ruby 131 | AuthTrail.job_queue = :low_priority 132 | ``` 133 | 134 | ### Local Geocoding 135 | 136 | For privacy and performance, we recommend geocoding locally. 137 | 138 | For city-level geocoding, download the [GeoLite2 City database](https://dev.maxmind.com/geoip/geoip2/geolite2/). 139 | 140 | Add this line to your application’s Gemfile: 141 | 142 | ```ruby 143 | gem "maxminddb" 144 | ``` 145 | 146 | And create `config/initializers/geocoder.rb` with: 147 | 148 | ```ruby 149 | Geocoder.configure( 150 | ip_lookup: :geoip2, 151 | geoip2: { 152 | file: "path/to/GeoLite2-City.mmdb" 153 | } 154 | ) 155 | ``` 156 | 157 | For country-level geocoding, install the `geoip-database` package. It’s preinstalled on Heroku. For Ubuntu, use: 158 | 159 | ```sh 160 | sudo apt-get install geoip-database 161 | ``` 162 | 163 | Add this line to your application’s Gemfile: 164 | 165 | ```ruby 166 | gem "geoip" 167 | ``` 168 | 169 | And create `config/initializers/geocoder.rb` with: 170 | 171 | ```ruby 172 | Geocoder.configure( 173 | ip_lookup: :maxmind_local, 174 | maxmind_local: { 175 | file: "/usr/share/GeoIP/GeoIP.dat", 176 | package: :country 177 | } 178 | ) 179 | ``` 180 | 181 | ### Load Balancer Geocoding 182 | 183 | Some load balancers can add geocoding information to request headers. 184 | 185 | - [nginx](https://nginx.org/en/docs/http/ngx_http_geoip_module.html) 186 | - [Google Cloud](https://cloud.google.com/load-balancing/docs/custom-headers) 187 | - [Cloudflare](https://support.cloudflare.com/hc/en-us/articles/200168236-Configuring-Cloudflare-IP-Geolocation) 188 | 189 | ```ruby 190 | AuthTrail.geocode = false 191 | 192 | AuthTrail.transform_method = lambda do |data, request| 193 | data[:country] = request.headers[""] 194 | data[:region] = request.headers[""] 195 | data[:city] = request.headers[""] 196 | end 197 | ``` 198 | 199 | Check out [this example](https://github.com/ankane/authtrail/issues/40) 200 | 201 | ## Data Retention 202 | 203 | Delete older data with: 204 | 205 | ```ruby 206 | LoginActivity.where("created_at < ?", 2.years.ago).in_batches.delete_all 207 | ``` 208 | 209 | Delete data for a specific user with: 210 | 211 | ```ruby 212 | LoginActivity.where(user_id: 1, user_type: "User").in_batches.delete_all 213 | ``` 214 | 215 | ## Other Notes 216 | 217 | We recommend using this in addition to Devise’s `Lockable` module and [Rack::Attack](https://github.com/kickstarter/rack-attack). 218 | 219 | Check out [Hardening Devise](https://ankane.org/hardening-devise) and [Secure Rails](https://github.com/ankane/secure_rails) for more best practices. 220 | 221 | ## History 222 | 223 | View the [changelog](https://github.com/ankane/authtrail/blob/master/CHANGELOG.md) 224 | 225 | ## Contributing 226 | 227 | Everyone is encouraged to help improve this project. Here are a few ways you can help: 228 | 229 | - [Report bugs](https://github.com/ankane/authtrail/issues) 230 | - Fix bugs and [submit pull requests](https://github.com/ankane/authtrail/pulls) 231 | - Write, clarify, or fix documentation 232 | - Suggest or add new features 233 | 234 | To get started with development and testing: 235 | 236 | ```sh 237 | git clone https://github.com/ankane/authtrail.git 238 | cd authtrail 239 | bundle install 240 | bundle exec rake test 241 | ``` 242 | --------------------------------------------------------------------------------