Verify expired link was valid

115 views Asked by At

I am building Rails API app, where I'm using devise-passwordless library.

If I send magic link to user and user waits too long to come to my app, how can I verify that the used token was valid and generate/send new one?

I'm looking for something like:

User.find_by(
    email: magic_link_request_params[:email], 
    old_token: magic_link_request_params[:old_token] )
.send_magic_link(true)

to be sure, that the user was already invited before by authorized admin/manager. If so, I want to be able to resend the magic link, otherwise I don't want to let the user in my app, so looking him up just by email is not enough.

  • Ruby 3.2.2
  • Rails 7
  • ActionController::API
  • Devise::Controllers::Rails7ApiMode
  • Devise
  • Devise-passwordless
2

There are 2 answers

0
Chiperific On

By default, Devise isn't saving the token anywhere.

It encodes and decodes the token without any reliance on a User record.

It also doesn't have a method to just generate the token without emailing it, so you're going to be modifying this gem quite a bit to get your behavior.

First, you'll need a migration to add fields to your User object. In my example, I'm using last_magic_link and last_magic_link_sent_at

If you feel comfortable writing your own modules and prepending them, the cleanest solution is to create an initializer that changes the send_magic_link to update your User record between when the token is generated and when the email is sent:

# /config/initializers/devise/passwordless/mailer_override.rb
# from: https://github.com/abevoelker/devise-passwordless/blob/master/lib/devise/passwordless/mailer.rb#L6

# we need `require "devise/passwordless"` and `require "devise/mailer"`
# I believe we get them all by requiring all of "devise"
require "devise"

# a custom module to override Devise::Passwordless::Mailer#magic_link
# NOTE: I pasted this untested and untried code from SO, I should check to make sure this works as intended
module MailerAndRecordUpdater
  def magic_link(record, token, remember_me, opts = {})
    @token = token
    @remember_me = remember_me
    devise_mail(record, :magic_link, opts)
    # record is a User record
    record.update(last_magic_link: @token, last_magic_link_sent_at: Time.now)
  end
end

[Devise::Passwordless::Mailer, Devise::Passwordless::Mailer.singleton_class].each do |mod|
  mod.prepend MailerAndRecordUpdater
end

And, the authors did you a favor by adding a method called after_magic_link_authentication that you can use to remove those record attributes from the User once they successfully sign in:

class User < ApplicationRecord
  def after_magic_link_authentication
    # intentionally leaving :last_magic_link_sent_at for history
    update(last_magic_link: nil)
  end
end

Now you are saving and removing tokens as they are generated and used.

But you've still got your primary use case: see if the token && email matches if it's expired.

Unfortunately, the gem raises a very broad error: InvalidOrExpiredTokenError and it doesn't tell you which is actually the case: invalid or expired.

You can write a similar override on Devise::Passwordless::LoginToken#decrypt and add a message to raise InvalidOrExpiredTokenError if the token is expired:

      created_at = ActiveSupport::TimeZone["UTC"].at(decrypted_data["created_at"])
      if as_of.to_f > (created_at + expire_duration).to_f
        raise InvalidOrExpiredTokenError, "expired" #<- custom message
      end

Then, in the Devise::Passwordless::MagicLinksController (that you'll have to ask Devise to generate) you can rescue from that error:

  def show
    begin
      self.resource = warden.authenticate!(auth_options)
    rescue InvalidOrExpiredTokenError -> error
      # check to see if the error's message is "expired"
      # check to see if the token matches the user record
      # then do what you want if both of the above are true
    end
    set_flash_message!(:notice, :signed_in)
    sign_in(resource_name, resource)
    yield resource if block_given?
    redirect_to after_sign_in_path_for(resource)
  end
2
Chiperific On

Uglier, slower approach than my previous answer, but much simpler:

  1. Generate sessions_controller and magic_links_controller (like this

  2. Create your migration to add last_magic_link and last_magic_link_sent_at to your User model

  3. Change the SessionsController#create code:

def create
    self.resource = resource_class.find_by(email: create_params[:email])
    if self.resource
      resource.send_magic_link(create_params[:remember_me])
      # assumes that the token generated here will match the token generated by #send_magic_link
      # this is a BIG ASSUMPTION since both use `Time.current` and can differ by milliseconds, could prevent this strategy from working
      token = Devise::Passwordless::LoginToken.encode(resource)
      resource.update(last_magic_link: token, last_magic_link_sent_at: Time.current)
      set_flash_message(:notice, :magic_link_sent, now: true)
    else
      set_flash_message(:alert, :not_found_in_database, now: true)
    end

    self.resource = resource_class.new(create_params)
    render :new
  end
  1. Change the MagicLinksController#show method:
  def show
    begin
      self.resource = warden.authenticate!(auth_options)
    rescue InvalidOrExpiredTokenError -> error
      # decode the token yourself (it's probably auth_options.token, dunno, you'll have to find it. It should be in params too)
      decoded = Devise::Passwordless::LoginToken.decode(token)
      # check to see if the token matches the user record
      # the decoded token has "key" and "email" resources
      email_from_token = decoded["data"]["resources"]["email"]
      user = User.find_by(email: email_from_token)
      user.last_magic_link == token
      # then do what you want, e.g. send another link
    end
    set_flash_message!(:notice, :signed_in)
    sign_in(resource_name, resource)
    yield resource if block_given?
    redirect_to after_sign_in_path_for(resource)
  end

Here's a big caveat:

The token has data["created_at"] saved as a float and it seems like that's part of the encryption used to encode the token.

So, the token you generate immediately after the resource.send_magic_link COULD differ from the token that the gem generates. I can't tell without testing if and exactly how to compare the token you've saved with the token you're receiving back from the email link.