Skip to content

Commit 95b435a

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 5b008ed commit 95b435a

5 files changed

Lines changed: 104 additions & 2 deletions

File tree

lib/devise.rb

Lines changed: 39 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
@@ -58,6 +60,13 @@ module Test
5860
# Strategies that do not require user input.
5961
NO_INPUT = []
6062

63+
# Global default for two_factor_methods per-model config.
64+
mattr_accessor :two_factor_methods
65+
@@two_factor_methods = []
66+
67+
# Registry of two-factor method configs set via register_two_factor_method.
68+
mattr_reader :two_factor_method_configs, default: {}
69+
6170
# True values used to check params
6271
TRUE_VALUES = [true, 1, '1', 'on', 'ON', 't', 'T', 'true', 'TRUE']
6372

@@ -439,6 +448,36 @@ def self.add_module(module_name, options = {})
439448
Devise::Mapping.add_module module_name
440449
end
441450

451+
def self.register_two_factor_method(name, options = {})
452+
options.assert_valid_keys(:model, :strategy, :controller, :route)
453+
two_factor_method_configs[name.to_sym] = options
454+
STRATEGIES[name.to_sym] = options[:strategy] if options[:strategy]
455+
456+
if controller = options[:controller]
457+
controller = (controller == true ? name : controller)
458+
CONTROLLERS[name.to_sym] = controller
459+
end
460+
461+
if route = options[:route]
462+
case route
463+
when TrueClass
464+
key, value = name, []
465+
when Symbol
466+
key, value = route, []
467+
when Hash
468+
key, value = route.keys.first, route.values.flatten
469+
else
470+
raise ArgumentError, ":route should be true, a Symbol or a Hash"
471+
end
472+
473+
URL_HELPERS[key] ||= []
474+
URL_HELPERS[key].concat(value)
475+
URL_HELPERS[key].uniq!
476+
477+
ROUTES[name.to_sym] = key
478+
end
479+
end
480+
442481
# Sets warden configuration using a block that will be invoked on warden
443482
# initialization.
444483
#

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)