Skip to content

Instantly share code, notes, and snippets.

@josevalim
Last active December 18, 2023 10:57
Show Gist options
  • Save josevalim/fb706b1e933ef01e4fb6 to your computer and use it in GitHub Desktop.
Save josevalim/fb706b1e933ef01e4fb6 to your computer and use it in GitHub Desktop.
# This snippet shows how TokenAuthenticatable works in Devise today.
# In case you want to maintain backwards compatibility, you can ditch
# devise's token mechanism in favor of this hand-rolled one. If not,
# it is recommended to migrate to the mechanism defined in the following
# snippet (2_safe_token_authenticatable.rb).
#
# In both snippets, we are assuming the User is the Devise model.
class User < ActiveRecord::Base
# You likely have this before callback set up for the token.
before_save :ensure_authentication_token
def ensure_authentication_token
if authentication_token.blank?
self.authentication_token = generate_authentication_token
end
end
private
def generate_authentication_token
loop do
token = Devise.friendly_token
break token unless User.where(authentication_token: token).first
end
end
end
# With a token setup, all you need to do is override
# your application controller to also consider token
# lookups:
class ApplicationController < ActionController::Base
# This is our new function that comes before Devise's one
before_filter :authenticate_user_from_token!
# This is Devise's authentication
before_filter :authenticate_user!
private
# For this example, we are simply using token authentication
# via parameters. However, anyone could use Rails's token
# authentication features to get the token from a header.
def authenticate_user_from_token!
user_token = params[:user_token].presence
user = user_token && User.find_by_authentication_token(user_token.to_s)
if user
# Notice we are passing store false, so the user is not
# actually stored in the session and a token is needed
# for every request. If you want the token to work as a
# sign in token, you can simply remove store: false.
sign_in user, store: false
end
end
end
# We could make the authentication mechanism above a bit more safe
# by requiring a token **AND** an e-mail for token authentication.
# The code in the model looks the same, we just need to slightly
# change the controller:
class ApplicationController < ActionController::Base
# This is our new function that comes before Devise's one
before_filter :authenticate_user_from_token!
# This is Devise's authentication
before_filter :authenticate_user!
private
def authenticate_user_from_token!
user_email = params[:user_email].presence
user = user_email && User.find_by_email(user_email)
# Notice how we use Devise.secure_compare to compare the token
# in the database with the token given in the params, mitigating
# timing attacks.
if user && Devise.secure_compare(user.authentication_token, params[:user_token])
sign_in user, store: false
end
end
end
@aruprakshit
Copy link

I used this link also with the steps shown here.

@fastdivision
Copy link

@tadas-s Yes, if you're using the Trackable module you can skip it like so:

env['devise.skip_trackable'] = true
sign_in user, store: false

@melsatar
Copy link

melsatar commented Jun 28, 2017

Hi guys,

I added a token reset after successful sign in so, it will be always new token assigned after the login in which will make it more secure, what do you think?

	def authenticate_user_from_token!
		if !current_user.nil?
		
		else
			user_email = params[:user_email].presence
			user = user_email && User.find_by_email(user_email)
			if params[:user_token].present?
				if user && Devise.secure_compare(user.authentication_token, params[:user_token])
					sign_in :user, user
					current_user.update(:authentication_token => Devise.friendly_token)
				else
					respond_to do |format|
		    			format.html { redirect_to "/authentication_failure", :layout => false, status: 301 }
		    		end					
				end
			end
		end	
	end

@StMacLennan
Copy link

StMacLennan commented Jul 7, 2017

This is super cool thanks! I made a few changes, as I want tokens to be generated on user creation, and then refreshed or replaced (depending how old the token is) when I send a transactional email with a auth_tokened link. So I created a custom lib with the token generator, added a token lifetime check to see when it was activated and a custom error message and login redirect for when the user has the right token but it's old. I decided to do this database side, as if you include the validation date in the token itself, the user could effectively have 2 reminder emails with different tokens (one working, and one not). So when I send an email with the token, i'll refresh or replace it depending on it's age.

My tweaked code:

/lib/users/authentication_token.rb

class Users::AuthenticationToken
  attr_accessor :user

  def initialize(user)
    @user = user
  end

  def generate_authentication_token
    new_token(@user)
    activate_token(@user)
    @user.save
  end

  def reactivate_authentication_token
    activate_token(@user)
    @user.save
  end

  private
  def new_token(user)
    user.auth_token = Devise.friendly_token
  end

  def activate_token(user)
    user.auth_token_activated_at = DateTime.now
  end
end

controllers/application_controller

def authenticate_user_from_token!
  user_token = params[:user_token].presence
  user       = user_token && User.find_by_auth_token(user_token.to_s)

  if user
    activation_date = User.find_by_auth_token(user_token.to_s).auth_token_activated_at
    if (DateTime.now.to_date - activation_date.to_date).to_i <= 2
      sign_in user
    else
      flash[:warning] = "Please sign in to continue"
      redirect_to(request.referrer || new_user_session_path)
    end
  end
end

models/user

before_save :ensure_authentication_token

def ensure_authentication_token
    if auth_token.blank?
      tokenizer = Users::AuthenticationToken.new(self)
      tokenizer.generate_authentication_token
    end
end

Basically I can then include a custom method in my mailers controllers to check the age of the users token, and then replace or refresh it before sending an email.

I'll probably edit this authentication to include the email and encrypt/decrypt the whole token (as suggested above) when using it with users. But thought I'd share this in the mean time! Hope it helps someone!

@briankung
Copy link

With 2_safe_token_authentication.rb, is it less secure to just encode the user email / user token combo to make a mega token for the API client to use? Something like this:

# Before giving the "public" API token to the user:

email_and_token = "#{user.email}:#{user.internal_token}" #=> "me@example.com:abc123"

public_token = Base64.encode64(email_and_token)

# When receiving public token from parameters (I am also considering using ActiveSupport::MessageEncryptor instead of simply Base64 encoding):

decoded_token = Base64.decode64(params[:public_token]) #=> "me@example.com:abc123"

user_email, user_token = decoded_token.split(':') # Naive - this will introduce errors if, for example, there's no colon
user       = user_email && User.find_by_email(user_email)

if user && Devise.secure_compare(user.authentication_token, user_token)
  sign_in user, store: false
end

Actually, I this seems similar to what @ahacking was proposing? Speaking of which, according to this security stackexchange post, adding a random delay isn't an effective strategy for dealing with timing attacks: https://security.stackexchange.com/questions/96489/can-i-prevent-timing-attacks-with-random-delays

@remy727
Copy link

remy727 commented Jan 3, 2022

We can refactor this line:
break token unless User.where(authentication_token: token).first
to
break token unless User.exists?(authentication_token: token)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment