Skip to content

Commit f01bab5

Browse files
committed
Clear account tokens when an account change is made
Previously, the only account change that cleared all account tokens was closing the account. If you reset your password, only the reset password token was cleared, if you verified a login change, only the verify login change token was cleared, etc. This changes the default behavior to clear most account tokens when an account change is made. The following account changes trigger clearing of tokens: * change login * close account * reset password * unlock account * verify account The following account tokens are cleared upon such actions: * active sessions (other than logged in session) * email auth * jwt refresh (if not logged in) * lockout (updates token if it exists) * remember (creates and uses new remember token if logged in via remember token) * reset password * single session (if not logged in) * verify account * verify login change This provides more secure behavior in some cases. Let's say you notice something funny with your email. You request a password change. However, then you realize someone has access to your email, so you change your login. Previously, the password reset link for the account is still valid after the email change, so an attacker can still change the password on the account post login change. With this commit, once the login has been changed, the reset password token is no longer valid. Similarly, once the password has been reset, the verify login change token is no longer valid. While I think this makes for a more secure and more appropriate default, it's possible that this behavior is not desirable for all Rodauth installations. To allow for customization, a clear_tokens configuration method is available. This takes a block that is passed a symbol for the change being made. The user can override the behavior for specific symbols. They can also call super with the reason to get the default behavior of clearing all related tokens. This unfortunately requires a number of special cases so that it does not break expected behavior. For active_sessions and single_session, you don't want to clear a token used for the current session, because otherwise changing your login would result in a logout. For lockout, you cannot clear the token, because that would result in an account unlock, so instead the token is updated. Remember is a mix between active_sessions/single_session and lockout, where you want to clear the token if the session is not logged in via remember, if the session is logged in via remember, you want to update the session value, so that the current session stays remembered. For jwt_refresh, this does not clear refresh tokens if the session is logged in, as we don't know which refresh token is for the current session. Potentially that ability could be added in the future, but this issue is simple to address by using the active_sessions feature, so recommend that approach.
1 parent 0cee9cb commit f01bab5

23 files changed

+476
-20
lines changed

CHANGELOG

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
=== master
2+
3+
* Clear account tokens when an account change is made (jeremyevans)
4+
15
=== 2.40.0 (2025-08-22)
26

37
* Use HTTP header instead of meta tag for otp unlock not yet available page (jeremyevans)

doc/base.rdoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ cache_templates :: Whether to cache templates. True by default. It may be worth
3535
check_csrf? :: Whether Rodauth should use Roda's +check_csrf!+ method for checking CSRF tokens before dispatching to Rodauth routes, true by default.
3636
check_csrf_opts :: Options to pass to Roda's +check_csrf!+ if Rodauth calls it before dispatching.
3737
check_csrf_block :: Proc for block to pass to Roda's +check_csrf!+ if Rodauth calls it before dispatching.
38+
clear_tokens(reason) :: Called when there is an account change, clears tokens for the account. By examining the reason symbol you can get different behavior per action, but the default behavior is clear all tokens whenever there is an account. Clearing actions/reasons are +:reset_password+, +:verify_account+, +:change_login+, +:unlock_account+, and +close_account+. Tokens are cleared for the following features +reset_password+, +verify_account+, +verify_login_change+, +jwt_refresh+, +remember+, +email_auth+, +single_session+, and +active_sessions+.
3839
convert_token_id_to_integer? :: Whether token ids should be converted to a valid 64-bit integer value. If not set, defaults to true if +account_id_column+ uses an integer type, and false otherwise.
3940
default_field_attributes :: The default attributes to use for input field tags, if field_attributes returns nil for the field.
4041
default_redirect :: Where to redirect after most successful actions.

doc/jwt_refresh.rdoc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ If you change the +allow_refresh_with_expired_jwt_access_token?+ setting to +tru
2525
an expired but otherwise valid access token will be accepted, and Rodauth will check
2626
that the access token was issued in the same session as the refresh token.
2727

28+
When an account change is made made during a logged in session (such as a login change),
29+
refresh tokens are not automatically invalidated, as Rodauth does not know which refresh
30+
token is being used for the current session. It is recommended that you use the
31+
active_sessions feature if you would like an account change during a logged in session
32+
to invalidate refresh tokens. Technically, this invalides the account tokens for other
33+
sessions and not the refresh tokens, but you need a valid access token to use the
34+
refresh token, so it has the same effect.
35+
2836
This feature depends on the jwt feature.
2937

3038
== Auth Value Methods

lib/rodauth/features/active_sessions.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,11 @@ def update_session
125125
add_active_session
126126
end
127127

128+
def clear_tokens(reason)
129+
super
130+
remove_all_active_sessions_except_current
131+
end
132+
128133
private
129134

130135
def after_refresh_token

lib/rodauth/features/base.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ module Rodauth
9393
:autocomplete_for_field?,
9494
:check_csrf,
9595
:clear_session,
96+
:clear_tokens,
9697
:csrf_tag,
9798
:function_name,
9899
:hook_action,
@@ -330,6 +331,9 @@ def clear_session
330331
end
331332
end
332333

334+
def clear_tokens(reason)
335+
end
336+
333337
def login_required
334338
set_redirect_error_status(login_required_error_status)
335339
set_error_reason :login_required

lib/rodauth/features/change_login.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ def _update_login(login)
8686
if raised
8787
set_login_requirement_error_message(:already_an_account_with_this_login, already_an_account_with_this_login_message)
8888
end
89-
updated && !raised
89+
change_made = updated && !raised
90+
clear_tokens(:change_login) if change_made
91+
change_made
9092
end
9193
end
9294
end

lib/rodauth/features/close_account.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,12 @@ module Rodauth
4545
before_close_account
4646
close_account
4747
after_close_account
48+
clear_session
49+
clear_tokens(:close_account)
4850
if delete_account_on_close?
4951
delete_account
5052
end
5153
end
52-
clear_session
5354

5455
close_account_response
5556
end

lib/rodauth/features/email_auth.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,11 @@ def email_auth_email_recently_sent?
167167
(email_last_sent = get_email_auth_email_last_sent) && (Time.now - email_last_sent < email_auth_skip_resend_email_within)
168168
end
169169

170+
def clear_tokens(reason)
171+
super
172+
remove_email_auth_key
173+
end
174+
170175
private
171176

172177
def _multi_phase_login_forms
@@ -210,11 +215,6 @@ def after_login
210215
super
211216
end
212217

213-
def after_close_account
214-
remove_email_auth_key
215-
super if defined?(super)
216-
end
217-
218218
def generate_email_auth_key_value
219219
@email_auth_key_value = random_key
220220
end

lib/rodauth/features/jwt_refresh.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ def account_from_refresh_token(token)
8989
@account = _account_from_refresh_token(token)
9090
end
9191

92+
def clear_tokens(reason)
93+
super
94+
jwt_refresh_token_account_ds(account_id).delete unless logged_in?
95+
end
96+
9297
private
9398

9499
def rescue_jwt_payload(e)

lib/rodauth/features/lockout.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ module Rodauth
126126
transaction do
127127
before_unlock_account
128128
unlock_account
129+
clear_tokens(:unlock_account)
129130
after_unlock_account
130131
if unlock_account_autologin?
131132
autologin_session('unlock_account')
@@ -241,6 +242,11 @@ def unlock_account_email_recently_sent?
241242
(email_last_sent = get_unlock_account_email_last_sent) && (Time.now - email_last_sent < unlock_account_skip_resend_email_within)
242243
end
243244

245+
def clear_tokens(reason)
246+
super
247+
account_lockouts_ds.update(account_lockouts_key_column => generate_unlock_account_key)
248+
end
249+
244250
private
245251

246252
attr_reader :unlock_account_key_value

0 commit comments

Comments
 (0)