Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/controllers/devise/passwords_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ def update
if resource.errors.empty?
resource.unlock_access! if unlockable?(resource)
if sign_in_after_reset_password?
if resource.respond_to?(:two_factor_enabled?) && resource.two_factor_enabled?
session[:devise_two_factor_resource_id] = resource.id
default_method = resource.enabled_two_factors.first
set_flash_message!(:notice, :updated_two_factor_required)
respond_with resource, location: new_two_factor_challenge_path(resource_name, default_method)
return
end

flash_message = resource.active_for_authentication? ? :updated : :updated_not_active
set_flash_message!(:notice, flash_message)
resource.after_database_authentication
Expand Down
54 changes: 54 additions & 0 deletions app/controllers/devise/two_factor_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# frozen_string_literal: true

class Devise::TwoFactorController < DeviseController
prepend_before_action :require_no_authentication
prepend_before_action :ensure_sign_in_initiated

# Extensions can inject custom actions or override defaults via on_load
ActiveSupport.run_load_hooks(:devise_two_factor_controller, self)

# Auto-generate default new_<module> actions for each registered 2FA module.
# Extensions that injected a custom action via on_load won't be overwritten.
Devise.two_factor_method_configs.each_key do |mod|
unless method_defined?(:"new_#{mod}")
define_method(:"new_#{mod}") do
@resource = find_pending_resource
end
end
end

# POST /users/two_factor
# All methods POST here. Warden picks the right strategy via valid?.
def create
self.resource = warden.authenticate!(auth_options)
set_flash_message!(:notice, :signed_in, scope: :"devise.sessions")
sign_in(resource_name, resource)
yield resource if block_given?
respond_with resource, location: after_sign_in_path_for(resource)
end

protected

def auth_options
resource = find_pending_resource
default_method = resource.enabled_two_factors.first
{ scope: resource_name, recall: "#{controller_path}#new_#{default_method}" }
end

def translation_scope
'devise.two_factor'
end

def find_pending_resource
return unless session[:devise_two_factor_resource_id]
resource_class.where(id: session[:devise_two_factor_resource_id]).first
end

private

def ensure_sign_in_initiated
return if session[:devise_two_factor_resource_id].present?
set_flash_message!(:alert, :sign_in_not_initiated, scope: :"devise.failure")
redirect_to new_session_path(resource_name)
end
end
4 changes: 4 additions & 0 deletions app/helpers/devise_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,8 @@

# Keeping the helper around for backward compatibility.
module DeviseHelper
def two_factor_method_links(resource, current_method)
methods = resource.enabled_two_factors - [current_method]
safe_join(methods.map { |method| render "devise/two_factor/#{method}_link" })
end
end
4 changes: 4 additions & 0 deletions app/views/devise/two_factor/_test_otp.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<%= form_tag(two_factor_path(resource_name), method: :post) do %>
<%= text_field_tag :otp_attempt %>
<%= submit_tag "Verify" %>
<% end %>
1 change: 1 addition & 0 deletions app/views/devise/two_factor/_test_otp_link.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<%= link_to "Use OTP instead", new_two_factor_challenge_path(resource_name, :test_otp) %>
3 changes: 3 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ en:
timeout: "Your session expired. Please sign in again to continue."
unauthenticated: "You need to sign in or sign up before continuing."
unconfirmed: "You have to confirm your email address before continuing."
two_factor_session_expired: "Your two-factor authentication session has expired. Please sign in again."
sign_in_not_initiated: "Please sign in first."
mailer:
confirmation_instructions:
subject: "Confirmation instructions"
Expand All @@ -36,6 +38,7 @@ en:
send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes."
updated: "Your password has been changed successfully. You are now signed in."
updated_not_active: "Your password has been changed successfully."
updated_two_factor_required: "Your password has been changed successfully. Please complete two-factor authentication."
registrations:
destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon."
signed_up: "Welcome! You have signed up successfully."
Expand Down
39 changes: 39 additions & 0 deletions lib/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ module Devise
autoload :ParameterSanitizer, 'devise/parameter_sanitizer'
autoload :TimeInflector, 'devise/time_inflector'
autoload :TokenGenerator, 'devise/token_generator'
autoload :TwoFactor, 'devise/two_factor'

module Controllers
autoload :Helpers, 'devise/controllers/helpers'
Expand All @@ -40,6 +41,7 @@ module Mailers
module Strategies
autoload :Base, 'devise/strategies/base'
autoload :Authenticatable, 'devise/strategies/authenticatable'
autoload :TwoFactor, 'devise/strategies/two_factor'
end

module Test
Expand All @@ -58,6 +60,13 @@ module Test
# Strategies that do not require user input.
NO_INPUT = []

# Global default for two_factor_methods per-model config.
mattr_accessor :two_factor_methods
@@two_factor_methods = []

# Registry of two-factor method configs set via register_two_factor_method.
mattr_reader :two_factor_method_configs, default: {}

# True values used to check params
TRUE_VALUES = [true, 1, '1', 'on', 'ON', 't', 'T', 'true', 'TRUE']

Expand Down Expand Up @@ -439,6 +448,36 @@ def self.add_module(module_name, options = {})
Devise::Mapping.add_module module_name
end

def self.register_two_factor_method(name, options = {})
options.assert_valid_keys(:model, :strategy, :controller, :route)
two_factor_method_configs[name.to_sym] = options
STRATEGIES[name.to_sym] = options[:strategy] if options[:strategy]

if controller = options[:controller]
controller = (controller == true ? name : controller)
CONTROLLERS[name.to_sym] = controller
end

if route = options[:route]
case route
when TrueClass
key, value = name, []
when Symbol
key, value = route, []
when Hash
key, value = route.keys.first, route.values.flatten
else
raise ArgumentError, ":route should be true, a Symbol or a Hash"
end

URL_HELPERS[key] ||= []
URL_HELPERS[key].concat(value)
URL_HELPERS[key].uniq!

ROUTES[name.to_sym] = key
end
end

# Sets warden configuration using a block that will be invoked on warden
# initialization.
#
Expand Down
16 changes: 14 additions & 2 deletions lib/devise/mapping.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,27 @@ def to
end

def strategies
@strategies ||= STRATEGIES.values_at(*self.modules).compact.uniq.reverse
@strategies ||= begin
keys = self.modules
if to.respond_to?(:two_factor_methods) && to.two_factor_methods
keys = keys + Array(to.two_factor_methods)
end
STRATEGIES.values_at(*keys).compact.uniq.reverse
end
end

def no_input_strategies
self.strategies & Devise::NO_INPUT
end

def routes
@routes ||= ROUTES.values_at(*self.modules).compact.uniq
@routes ||= begin
keys = self.modules
if to.respond_to?(:two_factor_methods) && to.two_factor_methods
keys = keys + Array(to.two_factor_methods)
end
ROUTES.values_at(*keys).compact.uniq
end
end

def authenticatable?
Expand Down
1 change: 1 addition & 0 deletions lib/devise/models.rb
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,4 @@ def devise_modules_hook!
end

require 'devise/models/authenticatable'
require 'devise/models/two_factor_authenticatable'
47 changes: 47 additions & 0 deletions lib/devise/models/two_factor_authenticatable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# frozen_string_literal: true

module Devise
module Models
module TwoFactorAuthenticatable
extend ActiveSupport::Concern

def self.required_fields(klass)
[]
end

module ClassMethods
Devise::Models.config(self, :two_factor_methods)

def two_factor_methods=(methods)
@two_factor_methods = methods
Array(methods).each do |method_name|
config = Devise.two_factor_method_configs[method_name]
raise "Unknown two-factor method: #{method_name}. " \
"Did you call Devise.register_two_factor_method?" unless config
begin
require config[:model]
rescue LoadError
raise unless config[:model].camelize.safe_constantize
end
mod = config[:model].camelize.constantize
include mod
end
end

def two_factor_modules
Array(two_factor_methods)
end
end

def enabled_two_factors
self.class.two_factor_modules.select do |method_name|
send(:"#{method_name}_two_factor_enabled?")
end
end

def two_factor_enabled?
enabled_two_factors.any?
end
end
end
end
3 changes: 3 additions & 0 deletions lib/devise/modules.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
# Other authentications
d.add_module :omniauthable, controller: :omniauth_callbacks, route: :omniauth_callback

# Two-factor authentication
d.add_module :two_factor_authenticatable, controller: :two_factor, route: :two_factor

# Misc after
routes = [nil, :new, :edit]
d.add_module :recoverable, controller: :passwords, route: { password: routes }
Expand Down
8 changes: 8 additions & 0 deletions lib/devise/rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ class Engine < ::Rails::Engine
end
end

initializer "devise.two_factor" do
config.after_initialize do
if Devise.two_factor_method_configs.any?
Devise.include_helpers(Devise::TwoFactor)
end
end
end

initializer "devise.secret_key" do |app|
Devise.secret_key ||= app.secret_key_base

Expand Down
19 changes: 19 additions & 0 deletions lib/devise/rails/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,25 @@ def devise_unlock(mapping, controllers) #:nodoc:
end
end

def devise_two_factor(mapping, controllers) #:nodoc:
return unless mapping.to.respond_to?(:two_factor_methods) && mapping.to.two_factor_methods.present?

controller = controllers[:two_factor] || "devise/two_factor"
two_factor_path = mapping.path_names[:two_factor] || "two_factor"

# Central POST endpoint — all methods submit here
post two_factor_path,
to: "#{controller}#create",
as: "two_factor"

# Per-method challenge routes
Array(mapping.to.two_factor_methods).each do |method_name|
get "#{two_factor_path}/#{method_name}/new",
to: "#{controller}#new_#{method_name}",
as: "new_two_factor_#{method_name}"
end
end

def devise_registration(mapping, controllers) #:nodoc:
path_names = {
new: mapping.path_names[:sign_up],
Expand Down
24 changes: 21 additions & 3 deletions lib/devise/strategies/database_authenticatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ def authenticate!
hashed = false

if validate(resource){ hashed = true; resource.valid_password?(password) }
remember_me(resource)
resource.after_database_authentication
success!(resource)
if resource.respond_to?(:two_factor_enabled?) && resource.two_factor_enabled?
initiate_two_factor_authentication!(resource)
else
remember_me(resource)
resource.after_database_authentication
success!(resource)
end
end

# In paranoid mode, hash the password even when a resource doesn't exist for the given authentication key.
Expand All @@ -24,6 +28,20 @@ def authenticate!
Devise.paranoid ? fail(:invalid) : fail(:not_found_in_database)
end
end

private

def initiate_two_factor_authentication!(resource)
session[:devise_two_factor_resource_id] = resource.id
session[:devise_two_factor_remember_me] = remember_me?
default_method = resource.enabled_two_factors.first
redirect!(new_two_factor_challenge_path(scope, default_method))
end

def new_two_factor_challenge_path(scope, method)
Rails.application.routes.url_helpers
.send(:"#{scope}_new_two_factor_#{method}_path")
end
end
end
end
Expand Down
50 changes: 50 additions & 0 deletions lib/devise/strategies/two_factor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# frozen_string_literal: true

require 'devise/strategies/base'

module Devise
module Strategies
class TwoFactor < Base
# The base strategy is never used directly — extensions subclass it.
def valid?
false
end

def authenticate!
resource = find_pending_resource
return fail!(:two_factor_session_expired) unless resource

verify_two_factor!(resource)

unless halted?
restore_remember_me(resource)
resource.after_database_authentication
cleanup_two_factor_session!
success!(resource)
end
end

# Extensions must override. Should call fail! with a specific
# message on failure — this halts execution and triggers recall.
def verify_two_factor!(resource)
raise NotImplementedError
end

private

def find_pending_resource
return unless session[:devise_two_factor_resource_id]
mapping.to.where(id: session[:devise_two_factor_resource_id]).first
end

def restore_remember_me(resource)
resource.remember_me = session[:devise_two_factor_remember_me] if resource.respond_to?(:remember_me=)
end

def cleanup_two_factor_session!
session.delete(:devise_two_factor_resource_id)
session.delete(:devise_two_factor_remember_me)
end
end
end
end
Loading