From e4048965e8d17a85610b7d6aba932c175e448f07 Mon Sep 17 00:00:00 2001 From: Jeremy Evans Date: Fri, 29 Sep 2023 15:05:03 -0700 Subject: [PATCH] Support hmac_secret rotation in email_base feature This supports the previous secret when validing email tokens. In terms of rotation time, that depends on the longest validity time you want to support for any feature using the email_base feature (email_auth, lockout, reset_password, verify_account, verify_login_change). The default validity for each of those features except verify_account is 1 day. verify_account has no deadline. However, if the verify account link has expired, the user can request that the email be resent, and the resent email will have the link with the new hmac. --- CHANGELOG | 2 ++ lib/rodauth/features/email_base.rb | 10 ++++------ spec/verify_account_spec.rb | 19 ++++++++++++++++++- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index a9ee01e9..04896126 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ === master +* Support hmac_secret rotation in email_base feature (jeremyevans) (#365) + * Support hmac_secret rotation in webauthn feature (jeremyevans) (#365) * Support hmac_secret rotation in jwt_refresh feature (jeremyevans) (#365) diff --git a/lib/rodauth/features/email_base.rb b/lib/rodauth/features/email_base.rb index 0133a781..24a6538e 100644 --- a/lib/rodauth/features/email_base.rb +++ b/lib/rodauth/features/email_base.rb @@ -69,12 +69,10 @@ def account_from_key(token, status_id=nil) return unless actual = yield(id) - unless timing_safe_eql?(key, convert_email_token_key(actual)) - if hmac_secret && allow_raw_email_token? - return unless timing_safe_eql?(key, actual) - else - return - end + unless (hmac_secret && timing_safe_eql?(key, convert_email_token_key(actual))) || + (hmac_secret_rotation? && timing_safe_eql?(key, compute_old_hmac(actual))) || + ((!hmac_secret || allow_raw_email_token?) && timing_safe_eql?(key, actual)) + return end ds = account_ds(id) ds = ds.where(account_status_column=>status_id) if status_id && !skip_status_checks? diff --git a/spec/verify_account_spec.rb b/spec/verify_account_spec.rb index 0f110325..b7f86c87 100644 --- a/spec/verify_account_spec.rb +++ b/spec/verify_account_spec.rb @@ -3,13 +3,14 @@ describe 'Rodauth verify_account feature' do it "should support verifying accounts" do last_sent_column = nil - secret = nil + secret = old_secret = nil allow_raw_token = false rodauth do enable :login, :create_account, :verify_account verify_account_autologin? false verify_account_email_last_sent_column{last_sent_column} hmac_secret{secret} + hmac_old_secret{old_secret} allow_raw_email_token?{allow_raw_token} verify_account_set_password? false require_login_confirmation? true @@ -89,6 +90,11 @@ visit link page.find('#error_flash').text.must_equal "There was an error verifying your account: invalid verify account key" + secret = SecureRandom.random_bytes(32) + old_secret = SecureRandom.random_bytes(32) + visit link + page.find('#error_flash').text.must_equal "There was an error verifying your account: invalid verify account key" + allow_raw_token = true visit link click_button 'Verify Account' @@ -103,11 +109,13 @@ [false, true].each do |ph| it "should support setting passwords when verifying accounts #{'with account_password_hash_column' if ph}" do initial_secret = secret = SecureRandom.random_bytes(32) + old_secret = nil rodauth do enable :login, :create_account, :verify_account account_password_hash_column :ph if ph verify_account_autologin? false hmac_secret{secret} + hmac_old_secret{old_secret} end roda do |r| r.rodauth @@ -125,6 +133,15 @@ visit link page.find('#error_flash').text.must_equal "There was an error verifying your account: invalid verify account key" + secret = SecureRandom.random_bytes(32) + old_secret = SecureRandom.random_bytes(32) + visit link + page.find('#error_flash').text.must_equal "There was an error verifying your account: invalid verify account key" + + old_secret = initial_secret + visit link + page.find_by_id('password')[:autocomplete].must_equal 'new-password' + secret = initial_secret visit link page.find_by_id('password')[:autocomplete].must_equal 'new-password'