jeremyevans / rodauth

Ruby's Most Advanced Authentication Framework
http://rodauth.jeremyevans.net
MIT License
1.67k stars 95 forks source link

Allow passing token keys to `*_email_link` methods #239

Closed janko closed 2 years ago

janko commented 2 years ago

This makes it easier to call the *_email_link methods outside of a request, which is fairly common because emails are typically delivered in a background job.

Background

In rodauth-rails, the generated Rodauth configuration currently overrides the email methods to call Action Mailer, which also support delaying the email delivery into a background job. Originally, it was passing the email links directly to mailer arguments (which would end up as background job arguments), but that had issues in API + SPA scenario where the SPA lives on a different subdomain, and I also realized it's probably not secure to include tokens in background job arguments.

class RodauthMain < Rodauth::Rails::Auth
  configure do
    create_verify_account_email do
      RodauthMailer.verify_account(email_to, verify_account_email_link)
    end
    # ...
  end
end
class RodauthMailer < ApplicationMailer
  def verify_account(recipient, email_link)
    @email_link = email_link

    mail to: recipient
  end
  # ...
end

Then I switched to sending only account ID and raw keys, and generated the email link manually:

class RodauthMain < Rodauth::Rails::Auth
  configure do
    create_verify_account_email do
      RodauthMailer.verify_account(account_id, verify_account_key_value)
    end
    # ...
  end
end
class RodauthMailer < ApplicationMailer
  def verify_account(account_id, key)
    @email_link = rodauth.verify_account_url(key: email_token(account_id, key)) # generate email link
    @account = Account.find(account_id)

    mail to: @account.email, recipient: rodauth.verify_account_email_subject
  end
  # ...
  private

  def email_token(account_id, key)
    "#{account_id}_#{rodauth.compute_hmac(key)}"
  end

  def rodauth
    RodauthApp.rodauth.allocate
  end
end

Now I would like to update the generated configuration to automatically work for any configuration, because I expect that authentication emails might largely be shared between different configurations, so it could make sense to use the same mailer. This complicates the logic, so I was exploring whether I could simplify email link generation by reusing Rodauth's methods. If these changes are merged, it would allow me to do the following:

class RodauthMain < Rodauth::Rails::Auth
  configure do
    create_verify_account_email do
      RodauthMailer.verify_account(self.class.configuration_name, account_id, verify_account_key_value)
    end
    # ...
  end
end
class RodauthMailer < ApplicationMailer
  def verify_account(name, account_id, key)
    @email_link = rodauth(name, account_id).verify_account_email_link(key) # reuse email link
    @account = Account.find(account_id)

    mail to: @account.email, recipient: rodauth(name).verify_account_email_subject
  end
  # ...
  private

  def rodauth(name, account_id = nil)
    instance = RodauthApp.rodauth(name).allocate
    instance.instance_variable_set(:@account, { id: account_id }) if account_id
    instance
  end
end
jeremyevans commented 2 years ago

This breaks backwards compatibility, because these methods are exposed as auth methods and are overridable in the Rodauth configuration. There would definitely be arity breakage for users passing lambdas to the related configuration methods. I doubt there is much of that. However, there is also semantic breakage. If users are using the configuration methods, and not expecting the key to be passed in, they could generate their own key different from the passed in key.

I realize this could make things easier for rodauth-rails, but I don't think it's worth breaking compatibility for that.

janko commented 2 years ago

OK, that makes sense, I didn't realize it would break backwards compatibility. Thanks to Rodauth's consistent naming convention, below is what I ended up with, but I'd welcome any suggestions if someone sees area for improvement 🙂

class RodauthMain < Rodauth::Rails::Auth
  configure do
    create_verify_account_email do
      RodauthMailer.verify_account(self.class.configuration_name, account_id, verify_account_key_value)
    end
    create_reset_password_email do
      RodauthMailer.reset_password(self.class.configuration_name, account_id, reset_password_key_value)
    end
    # ...
  end
end
class RodauthMailer < ApplicationMailer
  def verify_account(name, account_id, key)
    @email_link = email_link(name, :verify_account, account_id, key)
    @account = Account.find(account_id)

    mail to: @account.email, subject: rodauth(name).verify_account_email_subject
  end

  def reset_password(name, account_id, key)
    @email_link = email_link(name, :reset_password, account_id, key)
    @account = Account.find(account_id)

    mail to: @account.email, subject: rodauth(name).reset_password_email_subject
  end

  # ...

  private

  def email_link(name, action, account_id, key)
    instance = rodauth(name)
    instance.instance_variable_set(:@account, { id: account_id })
    instance.instance_variable_set(:"@#{action}_key_value", key)
    instance.public_send(:"#{action}_email_link")
  end

  def rodauth(name)
    RodauthApp.rodauth(name).allocate
  end
end