Skip to content

Commit fb218ce

Browse files
feat: add two-factor method registration and base model module
Add Devise.register_two_factor_method API for extensions to register 2FA methods (analogous to config.omniauth). Register a single :two_factor_authenticatable module in modules.rb. Extend mapping strategies to include 2FA methods for Warden scope defaults. Provide TwoFactorAuthenticatable base model module with per-model two_factor_methods config, enabled_two_factors discovery, and automatic inclusion of extension model concerns.
1 parent 605de86 commit fb218ce

5 files changed

Lines changed: 128 additions & 2 deletions

File tree

lib/devise.rb

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ module Devise
1818
autoload :ParameterSanitizer, 'devise/parameter_sanitizer'
1919
autoload :TimeInflector, 'devise/time_inflector'
2020
autoload :TokenGenerator, 'devise/token_generator'
21+
autoload :TwoFactor, 'devise/two_factor'
2122

2223
module Controllers
2324
autoload :Helpers, 'devise/controllers/helpers'
@@ -40,6 +41,7 @@ module Mailers
4041
module Strategies
4142
autoload :Base, 'devise/strategies/base'
4243
autoload :Authenticatable, 'devise/strategies/authenticatable'
44+
autoload :TwoFactor, 'devise/strategies/two_factor'
4345
end
4446

4547
module Test
@@ -312,6 +314,14 @@ def self.mappings
312314
mattr_accessor :sign_in_after_change_password
313315
@@sign_in_after_change_password = true
314316

317+
# Global default for two_factor_methods per-model config.
318+
mattr_accessor :two_factor_methods
319+
@@two_factor_methods = []
320+
321+
# Registry of two-factor method configs set via register_two_factor_method.
322+
mattr_reader :two_factor_method_configs
323+
@@two_factor_method_configs = {}
324+
315325
# Default way to set up Devise. Run rails generate devise_install to create
316326
# a fresh initializer with all configuration values.
317327
def self.setup
@@ -439,6 +449,59 @@ def self.add_module(module_name, options = {})
439449
Devise::Mapping.add_module module_name
440450
end
441451

452+
453+
# Register available devise two factor methods.
454+
# Third-party modules that intend to add a 2FA method need to be added explicitly using this method.
455+
#
456+
# Note that adding a module using this method does not cause it to be used in the authentication
457+
# process. That requires the `:two_factor_authenticatable` module to be listed in the arguments passed
458+
# to the 'devise' method in the model class definition along with the two factor method name listed under
459+
# the `:two_factor_methods` argument passed to the 'devise' method.
460+
#
461+
# == Options:
462+
#
463+
# +name+ - String representing the name of the 2FA method. This will be used to identify it.
464+
# +model+ - String representing the load path to a custom *model* for this 2FA method (to autoload.)
465+
# +strategy+ - Symbol representing if this module got a custom *strategy*.
466+
# +route+ - Generates extension-specific routes and URL helpers (e.g., credential management
467+
# endpoints). This is separate from the core challenge/create routes that Devise
468+
# generates automatically from +two_factor_methods+. Accepts true (defaults route
469+
# name to the method name), a Symbol, or a Hash. Works the same as the +:route+
470+
# option in +add_module+.
471+
#
472+
# == Examples:
473+
#
474+
# Devise.register_two_factor_method(:my_two_factor_method)
475+
# Devise.register_two_factor_method(:my_two_factor_method, model: 'my_two_factor_method/model')
476+
# Devise.register_two_factor_method(:my_two_factor_method, model: 'my_two_factor_method/model', strategy: :my_two_factor_method, route: true)
477+
#
478+
def self.register_two_factor_method(name, options = {})
479+
options.assert_valid_keys(:model, :strategy, :route)
480+
481+
two_factor_method_configs[name.to_sym] = options
482+
483+
STRATEGIES[name.to_sym] = options[:strategy] if options[:strategy]
484+
485+
if route = options[:route]
486+
case route
487+
when TrueClass
488+
key, value = name, []
489+
when Symbol
490+
key, value = route, []
491+
when Hash
492+
key, value = route.keys.first, route.values.flatten
493+
else
494+
raise ArgumentError, ":route should be true, a Symbol or a Hash"
495+
end
496+
497+
URL_HELPERS[key] ||= []
498+
URL_HELPERS[key].concat(value)
499+
URL_HELPERS[key].uniq!
500+
501+
ROUTES[name.to_sym] = key
502+
end
503+
end
504+
442505
# Sets warden configuration using a block that will be invoked on warden
443506
# initialization.
444507
#

lib/devise/mapping.rb

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,27 @@ def to
8484
end
8585

8686
def strategies
87-
@strategies ||= STRATEGIES.values_at(*self.modules).compact.uniq.reverse
87+
@strategies ||= begin
88+
keys = self.modules
89+
if to.respond_to?(:two_factor_methods) && to.two_factor_methods
90+
keys = keys + Array(to.two_factor_methods)
91+
end
92+
STRATEGIES.values_at(*keys).compact.uniq.reverse
93+
end
8894
end
8995

9096
def no_input_strategies
9197
self.strategies & Devise::NO_INPUT
9298
end
9399

94100
def routes
95-
@routes ||= ROUTES.values_at(*self.modules).compact.uniq
101+
@routes ||= begin
102+
keys = self.modules
103+
if to.respond_to?(:two_factor_methods) && to.two_factor_methods
104+
keys = keys + Array(to.two_factor_methods)
105+
end
106+
ROUTES.values_at(*keys).compact.uniq
107+
end
96108
end
97109

98110
def authenticatable?

lib/devise/models.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,4 @@ def devise_modules_hook!
120120
end
121121

122122
require 'devise/models/authenticatable'
123+
require 'devise/models/two_factor_authenticatable'
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# frozen_string_literal: true
2+
3+
module Devise
4+
module Models
5+
module TwoFactorAuthenticatable
6+
extend ActiveSupport::Concern
7+
8+
def self.required_fields(klass)
9+
[]
10+
end
11+
12+
module ClassMethods
13+
Devise::Models.config(self, :two_factor_methods)
14+
15+
def two_factor_methods=(methods)
16+
@two_factor_methods = methods
17+
Array(methods).each do |method_name|
18+
config = Devise.two_factor_method_configs[method_name]
19+
raise "Unknown two-factor method: #{method_name}. " \
20+
"Did you call Devise.register_two_factor_method?" unless config
21+
begin
22+
require config[:model]
23+
rescue LoadError
24+
raise unless config[:model].camelize.safe_constantize
25+
end
26+
mod = config[:model].camelize.constantize
27+
include mod
28+
end
29+
end
30+
31+
def two_factor_modules
32+
Array(two_factor_methods)
33+
end
34+
end
35+
36+
def enabled_two_factors
37+
self.class.two_factor_modules.select do |method_name|
38+
send(:"#{method_name}_two_factor_enabled?")
39+
end
40+
end
41+
42+
def two_factor_enabled?
43+
enabled_two_factors.any?
44+
end
45+
end
46+
end
47+
end

lib/devise/modules.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
# Other authentications
1414
d.add_module :omniauthable, controller: :omniauth_callbacks, route: :omniauth_callback
1515

16+
# Two-factor authentication
17+
d.add_module :two_factor_authenticatable, controller: :two_factor, route: :two_factor
18+
1619
# Misc after
1720
routes = [nil, :new, :edit]
1821
d.add_module :recoverable, controller: :passwords, route: { password: routes }

0 commit comments

Comments
 (0)