Add 2FA interface for extension gems to hook into#5843
Open
santiagorodriguez96 wants to merge 9 commits intoheartcombo:mainfrom
Open
Add 2FA interface for extension gems to hook into#5843santiagorodriguez96 wants to merge 9 commits intoheartcombo:mainfrom
santiagorodriguez96 wants to merge 9 commits intoheartcombo:mainfrom
Conversation
This file contains hidden or 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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Closes #5842.
How it works
The application opts in by adding
:two_factor_authenticatableto the model and listing the methods it wants to enable:Extension gems register themselves via a single API:
register_two_factor_methodadds the strategy to Devise'sSTRATEGIESmap and stores the model concern path in a registry. When the user model is loaded,two_factor_methods=looks each method up in that registry and includes the corresponding model concern – so each extension brings its own model module without needing to be a full Devise module itself.Sign in flow
DatabaseAuthenticatablevalidates it as before.remember_mein the session and redirects to the default method's challenge action – instead of callingsuccess!.Devise::TwoFactorController. Devise auto-generates anew_<method>action per registered method, which renders the method's challenge view.createaction, and Warden picks the right strategy via each strategy'svalid?. The baseDevise::Strategies::TwoFactorthen restoresremember_me, cleans up the session, and signs the user in.fail!and Warden recalls back to the challenge action.PasswordsController#updateis also intercepted: when a user with 2FA enabled completes a password reset, they're redirected to the 2FA challenge instead of being signed in directly.Generic URL helpers (
new_two_factor_challenge_path(scope, method),two_factor_path(scope)) and atwo_factor_method_linksview helper round out the public surface so extensions don't need to share a layout or hardcode route names – they just render their own challenge view and call the helper to render switch links to the user's other enabled methods.Conventions
For the pieces above to wire up automatically, extensions follow a small set of naming conventions keyed off the short name passed to
register_two_factor_method(e.g.:webauthn):<method>_two_factor_enabled?– instance method on the model concern. Returnstruewhen this user has the method configured (e.g.webauthn_two_factor_enabled?returns true when the user has at least one registered credential). Devise uses this to decide whether a user has 2FA on at all, and which methods are currently available for a given user.new_<method>– controller action. Auto-generated by Devise as an empty action that rendersnew_<method>.html.erb. Extensions can override it (see "Customizing the challenge action" below). Aset_authenticating_resourcebefore_actionexposes the resource being challenged as@authenticating_resourcefor use in challenge views and custom actions.app/views/devise/two_factor/new_<method>.html.erb– challenge view, shipped by the extension gem and resolved through Rails engine view lookup (apps can override by placing a file at the same path).app/views/devise/two_factor/_<method>_link.html.erb– switch-link partial, rendered by thetwo_factor_method_linkshelper when a user has multiple methods enabled so they can jump to an alternate factor from any challenge page.Advanced: customizing the challenge action
Simple methods (e.g. OTP code entry) work out of the box with the auto-generated
new_<method>action – no extra wiring needed. Methods that require setup logic on every render (e.g. WebAuthn generating a fresh challenge nonce and stashing it in the session) can inject a custom action via anActiveSupport.on_loadhook:The auto-generated action is only defined when the extension hasn't already provided one, so the injected method takes precedence.
Validating the interface
In order to test this, I adapted two real 2FA gems on top of this PR —
devise-webauthnanddevise-otp— and wired them up together in our demo app fordevise-webauthn, so that a single application offers users a choice between WebAuthn and TOTP.Both gems plug into the new interface via
Devise.register_two_factor_methodwithout needing to touchDevise's password authentication logic, and the demo app shows them coexisting – the user can pick either factor at the challenge step using the links generated by thetwo_factor_method_linkshelper.What's intentionally out of scope
after_two_factor_verification)Lockableis untouched, and failed 2FA attempts don't incrementfailed_attempts. Rate limiting for 2FA is better handled at the application or middleware level.I hope this lands as a solid foundation that we can build these features on top of.
A note on the scope of this PR
I'm aware this PR is on the ambitious side for a single change but fell into the "why does every 2FA gem reinvent this?" rabbit hole while building
devise-webauthn, and had a lot of fun working through what a shared interface might look like – so here we are. Happy to split into smaller PRs if reviewers would prefer that!A note on AI tooling
Claude Code was used to generate parts of the implementation. I was deeply involved in the design throughout – every architectural decision is something I worked through and chose deliberately. I've also reviewed and edited the generated code before submitting this PR.