Last active
November 1, 2023 12:52
-
-
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)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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