Skip to content

Instantly share code, notes, and snippets.

@bjeanes
Last active November 1, 2023 12:52
Show Gist options
  • Save bjeanes/b50b13d2f2bcbbf73803af2df244c007 to your computer and use it in GitHub Desktop.
Save bjeanes/b50b13d2f2bcbbf73803af2df244c007 to your computer and use it in GitHub Desktop.
Rodauth feature to migrate from Devise, including maintaining all Devise columns (that I was using) in case a rollback was necessary (it wasn't)
# frozen_string_literal: true
require 'rodauth'
# In order for Rails to reload this constant in dev, we need `require_dependency` because Rodauth expects the features
# in a specific load path, but it defines a constant against Rails' expectations, which breaks reloading.
require_dependency 'rodauth/features/remote_ip'
module Rodauth
Feature.define(:migrate_from_devise, :MigrateFromDevise) do
depends :login, :remote_ip
auth_value_method :devise_group, 'user'
def devise_model
devise_group.to_s.capitalize.constantize
end
# Ensure a signup can't happen in Rodauth if there is an un-migrated Device account
def before_create_account
super if defined?(super) # ensure other plugins run
email_in_use = devise_model.exists?(['lower(email) = ?', param(login_param).downcase])
if email_in_use
throw_error_status(
invalid_field_error_status,
login_param,
already_an_account_with_this_login_message
)
end
end
# Ensure a Rodauth account can't change login to an email address used by an un-migrated Devise account
def before_change_login
super if defined?(super) # ensure other plugins run
email_in_use = devise_model.exists?([
'lower(email) = ? AND id != ?',
param(login_param).downcase,
account_id
])
if email_in_use
throw_error_status(
invalid_field_error_status,
login_param,
already_an_account_with_this_login_message
)
end
end
def login_session(auth_type)
super
devise_model.connection.exec_update(<<~SQL, 'Update user login metadata', [[nil, Time.now], [nil, remote_ip], [nil, account_id]])
UPDATE #{devise_model.table_name}
SET sign_in_count = coalesce(sign_in_count, 0) + 1
, last_sign_in_at = current_sign_in_at
, current_sign_in_at = $1
, last_sign_in_ip = current_sign_in_ip
, current_sign_in_ip = $2
WHERE id = $3;
SQL
end
def after_verify_account_email_resend
super if defined?(super) # ensure other plugins run
sent_at = verify_account_ds.get(verify_account_email_last_sent_column)
devise_model.update(account_id, confirmation_sent_at: sent_at)
end
# NOTE: this happens inside transaction
# https://github.com/jeremyevans/rodauth/blob/85ab7de/lib/rodauth/features/verify_account.rb#L141-L149
def after_verify_account
super if defined?(super) # ensure other plugins run
devise_model.update(account_id, confirmed_at: Time.now)
end
def after_reset_password_request
super if defined?(super) # ensure other plugins run
sent_at, token = password_reset_ds.get([reset_password_email_last_sent_column, reset_password_key_column])
# `token` won't be what Devise expects if we have to roll back, but I think setting it is better than not for
# understanding what happened if we do have to roll back.
devise_model.update(account_id, reset_password_token: token, reset_password_sent_at: sent_at)
end
def after_reset_password
super if defined?(super) # ensure other plugins run
devise_model.update(account_id, reset_password_token: nil, reset_password_sent_at: nil)
end
# NOTE: this happens inside transaction
# https://github.com/jeremyevans/rodauth/blob/e030516/lib/rodauth/features/change_password.rb#L52-L56
def set_password(password)
super(password).tap do |hash|
if @user&.new_record? # during sign up
@user.encrypted_password = hash
else
devise_model.update(account_id, encrypted_password: hash)
end
end
end
def after_verify_login_change
super if defined?(super) # ensure other plugins run
devise_model.update(account_id, {
email: @verify_login_change_new_login,
unconfirmed_email: nil,
confirmed_at: Time.now,
})
end
# If "verify login change" feature is enabled, then this will not have changed login but instead created an
# verify login change key.
#
# NOTE: This happens inside a transaction
# https://github.com/jeremyevans/rodauth/blob/85ab7dea/lib/rodauth/features/change_login.rb#L44-L53
def after_change_login
super if defined?(super) # ensure other plugins run
new_login = param(login_param)
user = devise_model.find(self.account_id)
if defined?(verify_login_change) # login change verification feature enabled
if user.respond_to?(:unconfirmed_email)
user.update(unconfirmed_email: new_login)
end
else
user.update(email: new_login)
end
end
def account_from_login(login)
super(login)
return @account if @account # already migrated
user = devise_model.find_by('lower(email) = ?', login.downcase)
return unless user
verified = !user.respond_to?(:confirmed_at) || user.confirmed_at.present?
db.transaction do
# Create account record
db[accounts_table].insert({
id: user.id,
email: user.email,
status: (verified ? 'verified' : 'unverified'),
})
# Carry over password hash
db[password_hash_table].insert({
id: user.id,
password_hash: user.encrypted_password,
})
# This should now return the row (that we just created) and will set the internal @account var
account = super(login) # sets @account
verify_account_email_resend unless verified
_reissue_login_change_verification(user)
_reissue_password_reset_request(user)
# Return account object
account
end.tap do
Stats.increment('logins.migrated')
end
end
private # additionally, methods below are prefixed with `_` to disambiguate from Rodauth hooks.
def _reissue_login_change_verification(user)
# Check `verify_login_change` feature enabled is enabled for current Rodauth config
return unless defined?(verify_login_change)
# Check this Devise resource has email change confirmation and whether it has a change in-flight
return unless user.respond_to?(:unconfirmed_email) && user.unconfirmed_email
# When `verify_login_change` feature is enabled, `update_login` creates the change request and emails
# a verification request.
#
# NOTE: This will straight up change the email if the feature is disabled, so the short-circuit check at
# beginning of this method is critical.
update_login(user.unconfirmed_email)
rescue Sequel::UniqueConstraintViolation => ex
# If they had an invalid inflight email change request, we'll eat the error so as not to tank a login request.
ErrorReporting.warn(ex)
end
def _reissue_password_reset_request(user)
return unless user.reset_password_token
# TODO(optional): Make this expiry intelligent not hard-coded
return if user.reset_password_sent_at < 24.hours.ago
# Generate and store the password reset token
generate_reset_password_key_value
create_reset_password_key
# Email the new admin a password reset link
send_reset_password_email
end
# TODO: handle migrating + logging in users from remember token
# TODO: handle migrating + logging in users with existing session (lookup devise session key?)
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment