Device Detection with Devise

A friendly guide to implementing device detection with Devise

Device Detection with Devise

As Bento continues to grow, so does my paranoia about security.

I recently decided to add a layer of protection by implementing basic device detection. The idea is simple: if a user logs in from a new device, we send them an email to confirm it's really them.

The main security risk I'm trying to mitigate is the scenario where a customer adds a freelancer to their account, the freelancer (think of a virtual assistant) uses a weak password they or their boss created, and then their account gets compromised along with all their data. Not fun!

Whilst we have 2FA/OTP and we check all passwords against the haveibeenpwned database, I still very much wanted to add this extra layer of security.

1

Let's start by tracking login activity

First off, we need to keep track of login activity. For this, we'll use a gem called authtrail, created by the awesome Rails developer Andrew Kane.

gem "authtrail"
rails generate authtrail:install 
rails db:migrate

The above adds a new LoginActivity model to our database, which will keep track of all login activity. We can then add an association to our User model.

class User < ApplicationRecord
  has_many :login_activities, as: :user # use :user no matter what your model name
end

And that's it, you're now tracking login activity!

2

Time to add an index to your DB or push to Elastic

Since a LoginActivity record will be created every time a user signs in (including API authentications), this table can get pretty big. To avoid slow filtering of potentially millions of records, we need to add an index to our database or push the data to a search engine like Elastic.

We'll go with Elastic, as we already have it running in production, and it's a lot easier for us to push data there.

The following code will push all LoginActivity records to Elastic asynchronously in a background job.

class LoginActivity < ApplicationRecord
  ...
  searchkick callbacks: :async

end
3

Checking login activity on sign in

Now that we have all the logging in place, we can start checking the login activity on sign in. We'll do this in the SessionsController, which is where Devise handles all the authentication.

During sign-in, Bento checks if a user requires 2FA/OTP. If they do, it redirects them to a page to enter their code. We don't want to check their login activity if they have a 2FA/OTP code, as it's assumed they physically have their device on them. If they do not have 2FA/OTP enabled, we'll check their login activity.

Think of this approach as a soft 2FA for everyone else.

class Users::SessionsController < Devise::SessionsController
  ...
  def two_factor_check
	...
	# Check if two factor authentication is not enabled and user exists
	if !@two_factor_enabled && user
        
		# Fetch the last 10 successful login activities for the user, excluding API logins
		ips = LoginActivity.search(where: { user_id: user.id, _not: { context: { like: '%api%' } }, success: true }, limit: 10, order: { created_at: :desc }, select: [:ip], load: false).results
		
		# If you're just using your database, you can use this instead (make sure you have an appropriate index in place if you have a lot of records)
		# ips = LoginActivity.where(user_id: user.id).where("context NOT LIKE ?", '%api%').where(success: true).order(created_at: :desc).limit(10).pluck(:ip)

		# If there are any login activities
		if ips.any?
			# Get the IP address of the current request
			ip_to_check = request.remote_ip

			# Check if the IP address is IPv6
			is_ipv6 = IPAddr.new(ip_to_check).ipv6?

			# If the IP address of the current request is not in the list of the last 10 IP addresses used for successful logins
			#############################################
			# IMPORTANT: comparing IPv4 is fairly straight forward, but IPv6 is a bit more complex. We found that users rotate through a lot of IPv6 addresses, so we mask the IP address to just the first bit (network prefix)
			#############################################

			unless ips.pluck(:ip).any? { |ip| is_ipv6 ? IpAnonymizer.mask_ip(ip) == IpAnonymizer.mask_ip(ip_to_check) : ip == ip_to_check }
				# Redirect the user to a specific page with an alert message
				redirect_to magic_link_path, alert: 'We do not recognise this account.'
				return
			end
		end
		...
	end
	...
  end
end
4

Let's send an email to the user

In the above snippet, I have a placeholder for magic_link_path. There are plenty of Devise magic link gems out there, so in this snippet, I won't go into detail about how to implement that. Just pick any and link the user there.

5

Don't forget to test, test, test

Now that we have the basic functionality in place, it's time to test it.

Try logging in a few times on the same device, and then try logging in on a different device. You should be redirected to the magic link page each time. After you click the link, a new LoginActivity will be created with that IP and you won't be asked again.

And you're done! Rad huh?

I hope this guide helps you add an extra layer of security to your app. At the time of implementing I didn't find any guides on how to do this, so I hope this helps someone out there!

Subscribe to my personal updates

Get emails from me about building software, marketing, and things I've learned building products on the web. Occasionally, a quiet announcement or two.

Email marketing powered by Bento