Skip to content
Merged
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
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ PATH
ransack (>= 4.2, < 4.5)
redis (>= 4.1, < 6.0)
request_store (~> 1.7.0)
rotp (~> 6.3)
rqrcode (>= 2.2, < 3.3)
ruby-vips (~> 2.2)
rubyXL (~> 3.4)
Expand Down Expand Up @@ -747,6 +748,7 @@ GEM
actionpack (>= 7.0)
railties (>= 7.0)
rexml (3.4.4)
rotp (6.3.0)
rqrcode (3.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
Expand Down
1 change: 0 additions & 1 deletion decidim-admin/lib/decidim/admin/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ class Engine < ::Rails::Engine
Decidim.icons.register(name: "download-line", icon: "download-line", category: "system", description: "", engine: :admin)
Decidim.icons.register(name: "mail-open-line", icon: "mail-open-line", category: "system", description: "", engine: :admin)
Decidim.icons.register(name: "forbid-2-line", icon: "forbid-2-line", category: "system", description: "", engine: :admin)
Decidim.icons.register(name: "key-2-line", icon: "key-2-line", category: "system", description: "", engine: :admin)
Decidim.icons.register(name: "arrow-go-back-line", icon: "arrow-go-back-line", category: "system", description: "", engine: :admin)
Decidim.icons.register(name: "computer-line", icon: "computer-line", category: "system", description: "", engine: :admin)
Decidim.icons.register(name: "arrow-right-s-line", icon: "arrow-right-s-line", category: "system", description: "", engine: :admin)
Expand Down
6 changes: 6 additions & 0 deletions decidim-core/app/commands/decidim/destroy_account.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def call
destroy_user_access_tokens
destroy_user_reminders
destroy_user_notifications
destroy_user_two_factor_records
destroy_user_badges
destroy_user_likes
destroy_user_reports
Expand Down Expand Up @@ -96,6 +97,11 @@ def destroy_user_notifications
current_user.notifications.find_each(&:destroy)
end

def destroy_user_two_factor_records
current_user.two_factor_authenticators.find_each(&:destroy)
current_user.two_factor_recovery_codes.find_each(&:destroy)
end

def destroy_follows
Decidim::Follow.where(followable: current_user).find_each(&:destroy)
Decidim::Follow.where(user: current_user).find_each(&:destroy)
Expand Down
28 changes: 28 additions & 0 deletions decidim-core/app/commands/decidim/two_factor/confirm_totp.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

module Decidim
module TwoFactor
# Activates the authenticator app once the user proves it shows the right code.
class ConfirmTotp < EnrollAuthenticator
# Public: Initializes the command.
#
# authenticator - The pending authenticator being confirmed.
# form - A form object with the code typed by the user.
def initialize(authenticator, form)
super(authenticator&.user)
@form = form
@authenticator = authenticator
end

protected

def invalid? = authenticator.blank? || authenticator.confirmed? || form.invalid? || !authenticator.verify(form)

def enroll = authenticator.update!(confirmed_at: Time.current)

private

attr_reader :form, :authenticator
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

module Decidim
module TwoFactor
# Turns on the one-time code sent to the account email.
class EnableEmailAuthenticator < EnrollAuthenticator
protected

def enroll = EmailAuthenticator.create!(user:)
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# frozen_string_literal: true

module Decidim
module TwoFactor
# The base command of the enrollments: attaches the factor and issues the
# recovery codes the first time the user needs them.
class EnrollAuthenticator < Decidim::Command
# Public: Initializes the command.
#
# user - The user attaching the factor.
def initialize(user)
@user = user
end

# Executes the command. Broadcasts these events:
#
# - :ok with the fresh recovery codes, or nil when the user already holds unused ones.
# - :invalid if the factor cannot be attached.
#
# Returns nothing.
def call
return broadcast(:invalid) if invalid?

# The row lock serialises two enrollments, so the one-per-type validation holds.
codes = user.with_lock do
enroll
generate_recovery_codes
end

broadcast(:ok, codes)
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
broadcast(:invalid)
end

protected

attr_reader :user

# Checks run before attaching the factor.
def invalid? = false

# Attaches the factor, raising when it cannot be saved.
def enroll = raise(NotImplementedError)

private

def generate_recovery_codes
return if user.two_factor_recovery_codes.unused.exists?

RegenerateRecoveryCodes.call(user)[:ok]
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true

module Decidim
module TwoFactor
# Replaces the recovery codes of a user with a fresh set.
class RegenerateRecoveryCodes < Decidim::Command
# Public: Initializes the command.
#
# user - The user whose recovery codes are replaced.
def initialize(user)
@user = user
end

# Executes the command. Broadcasts these events:
#
# - :ok with the new codes in plain form.
#
# Returns nothing.
def call
# The row lock serialises two regenerations, so a single set stays valid.
codes = user.with_lock do
user.two_factor_recovery_codes.destroy_all
Array.new(Decidim.two_factor_recovery_codes_count) { create_code }
end

broadcast(:ok, codes)
end

private

attr_reader :user

def create_code
plain = SecureRandom.hex(16)
user.two_factor_recovery_codes.create!(code_digest: CodeDigest.create(plain))
plain
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# frozen_string_literal: true

module Decidim
module TwoFactor
# Detaches a second factor; the recovery codes go with the last one.
class RemoveAuthenticator < Decidim::Command
delegate :user, to: :authenticator

# Public: Initializes the command.
#
# authenticator - The authenticator to detach.
def initialize(authenticator)
@authenticator = authenticator
end

# Executes the command. Broadcasts these events:
#
# - :ok when the factor was removed.
# - :invalid if there is no such factor.
#
# Returns nothing.
def call
return broadcast(:invalid) if authenticator.blank?

transaction do
authenticator.destroy!
user.two_factor_recovery_codes.destroy_all unless user.two_factor_enabled?
end

broadcast(:ok)
end

private

attr_reader :authenticator
end
end
end
39 changes: 39 additions & 0 deletions decidim-core/app/commands/decidim/two_factor/reset_user.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

module Decidim
module TwoFactor
# Removes every second factor from an account so the owner signs in with
# the password alone again.
class ResetUser < Decidim::Command
# Public: Initializes the command.
#
# user - The user losing all the second factors.
def initialize(user)
@user = user
end

# Executes the command. Broadcasts these events:
#
# - :ok when every factor was removed.
# - :invalid if the account has no second factor to remove.
#
# Returns nothing.
def call
return broadcast(:invalid) unless user.two_factor_authenticators.exists?

transaction do
user.two_factor_authenticators.destroy_all
user.two_factor_recovery_codes.destroy_all
end

TwoFactorMailer.factors_reset(user).deliver_later

broadcast(:ok)
end

private

attr_reader :user
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# frozen_string_literal: true

module Decidim
module TwoFactor
# The base controller of the second factors attached to the user's account:
# the controller of every method inherits its checks.
class AuthenticatorsController < Decidim::ApplicationController
include Decidim::UserProfile

helper SetupHelper
helper_method :after_setup_path

before_action :ensure_two_factor_authentication_enabled
before_action :ensure_method_available

def show
redirect_to two_factor_authentication_path
end

def destroy
enforce_permission_to(:update, :user, current_user:)

RemoveAuthenticator.call(authenticator) do
on(:ok) { flash[:notice] = t(".success") }
on(:invalid) { flash[:alert] = t(".error") }
end

redirect_to two_factor_authentication_path
end

private

def ensure_two_factor_authentication_enabled
redirect_to account_path if !current_organization.two_factor_authentication_enabled? || current_user_impersonated?
end

def ensure_method_available
return if two_factor_method.blank?
return if Decidim::TwoFactor.available_methods(current_organization).map(&:name).include?(two_factor_method)

redirect_to two_factor_authentication_path
end

def two_factor_method = nil

def authenticator
@authenticator ||= current_user.two_factor_authenticators.find_by(id: params[:id])
end

def after_setup_path
@after_setup_path ||= session.delete("decidim_two_factor_return_to") || two_factor_authentication_path
end

def show_recovery_codes_or_redirect(recovery_codes, success_message)
@recovery_codes = recovery_codes

return render("decidim/two_factor/authenticators/recovery_codes") if @recovery_codes.present?

flash[:notice] = success_message
redirect_to two_factor_authentication_path
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

module Decidim
module TwoFactor
# Enables the one-time code sent to the account email.
class EmailAuthenticatorsController < AuthenticatorsController
def create
enforce_permission_to(:update, :user, current_user:)

EnableEmailAuthenticator.call(current_user) do
on(:ok) { |recovery_codes| show_recovery_codes_or_redirect(recovery_codes, t(".success")) }

on(:invalid) do
flash[:alert] = t(".error")
redirect_to two_factor_authentication_path
end
end
end

private

def two_factor_method = "email"
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

module Decidim
module TwoFactor
# Replaces the recovery codes of the user with a fresh set.
class RecoveryCodesController < AuthenticatorsController
def create
enforce_permission_to(:update, :user, current_user:)
return redirect_to(two_factor_authentication_path) unless current_user.two_factor_enabled?

RegenerateRecoveryCodes.call(current_user) do
on(:ok) { |recovery_codes| show_recovery_codes_or_redirect(recovery_codes, t(".success")) }
end
end
end
end
end
Loading
Loading