From a38aef8c7c836eaeb21646755c45da2580278cdf Mon Sep 17 00:00:00 2001 From: Rasmus Kjellberg <2277443+kjellberg@users.noreply.github.com> Date: Mon, 1 Apr 2024 15:12:42 +0200 Subject: [PATCH] feat: two factor authentication --- app/controllers/accounts_controller.rb | 2 +- app/controllers/application_controller.rb | 23 +++++----- .../users/onboarding_controller.rb | 2 +- app/controllers/users/sessions_controller.rb | 44 +++++++++++++++++++ app/models/user.rb | 14 +++--- app/views/users/sessions/new.html.erb | 1 + app/views/users/sessions/otp.html.erb | 18 ++++++++ config/credentials/test.key | 1 + config/credentials/test.yml.enc | 1 + .../initializers/filter_parameter_logging.rb | 2 +- config/locales/kiqr.en.yml | 9 ++++ .../rails/credentials/credentials.yml.tt | 13 ++++++ .../users/sessions_controller_test.rb | 17 +++++++ test/factories/user.rb | 5 +++ test/system/signin_test.rb | 14 ++++++ 15 files changed, 147 insertions(+), 19 deletions(-) create mode 100644 app/views/users/sessions/otp.html.erb create mode 100644 config/credentials/test.key create mode 100644 config/credentials/test.yml.enc create mode 100644 lib/templates/rails/credentials/credentials.yml.tt create mode 100644 test/controllers/users/sessions_controller_test.rb diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 3773aa6..eda9b7b 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -5,7 +5,7 @@ def edit end def update - if @account.update(account_params) + if @account.update(account_permitted_parameters) redirect_to edit_account_path, notice: I18n.t("accounts.update.success") else render :edit, status: :unprocessable_entity diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a5d5899..fdf79f7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -4,22 +4,15 @@ class ApplicationController < ActionController::Base before_action :authenticate_user! before_action :ensure_onboarded, unless: :devise_controller? - before_action :configure_permitted_parameters, if: :devise_controller? - - protected - - def configure_permitted_parameters - devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt]) - end - - private # Strong parameters for account. # Used for account creation and update. - def account_params + def account_permitted_parameters params.require(:account).permit(:name) end + private + # Automatically include account_id in all URL options if it is already present in the params. # This is used to ensure that all routes are scoped to the current team. Personal account # routes are not affected. @@ -32,8 +25,16 @@ def ensure_onboarded redirect_to onboarding_path if user_signed_in? && !current_user.onboarded? end - # Redirect to dashboard after sign in + # Override the method to change the sign-in redirect path def after_sign_in_path_for(resource) dashboard_path end + + # Override the method to change the sign-out redirect path + def after_sign_out_path_for(resource_or_scope) + # Generate the root path without default URL options + uri = URI.parse(root_url) + uri.query = nil # Remove any query parameters + uri.to_s + end end diff --git a/app/controllers/users/onboarding_controller.rb b/app/controllers/users/onboarding_controller.rb index b56aa11..5c2777b 100644 --- a/app/controllers/users/onboarding_controller.rb +++ b/app/controllers/users/onboarding_controller.rb @@ -21,7 +21,7 @@ def new end def create - @account = current_user.build_personal_account(account_params) + @account = current_user.build_personal_account(account_permitted_parameters) @account.personal = true if current_user.save diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index d2daa38..be0d75d 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -1,2 +1,46 @@ class Users::SessionsController < Devise::SessionsController + # prepend_before_action :configure_permitted_parameters, if: :devise_controller? + prepend_before_action :otp_authentication, only: :create + + def new + # New login attempts should reset the otp_user_id + session.delete(:otp_user_id) + super + end + + # Override the default Devise create method and check if the user has enabled 2FA + # If 2FA is enabled, render the OTP form page. Otherwise, proceed with the default login flow. + def otp_authentication + devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt]) + + if sign_in_params[:email] + show_otp_code_form + elsif session[:otp_user_id] + validate_otp_code + end + end + + def show_otp_code_form + # Reset the session if the user is trying to login again. + session.delete(:otp_user_id) + + self.resource = User.find_by(email: sign_in_params[:email]) + if resource.valid_password?(sign_in_params[:password]) && resource.otp_required_for_login? + session[:otp_user_id] = resource.id + render :otp, status: :unprocessable_entity + end + end + + def validate_otp_code + self.resource = User.find(session[:otp_user_id]) + if resource.validate_and_consume_otp!(sign_in_params[:otp_attempt]) + + set_flash_message!(:notice, :signed_in) + sign_in(resource_name, resource) + redirect_to after_sign_in_path_for(resource) + else + resource.errors.add(:otp_attempt, :invalid) + render :otp, status: :unprocessable_entity + end + end end diff --git a/app/models/user.rb b/app/models/user.rb index 69496f6..3ffc210 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,14 +1,18 @@ class User < ApplicationRecord - # Include default devise modules. - devise :registerable, :recoverable, :rememberable, :validatable, :confirmable, :lockable, :timeoutable, :trackable, :two_factor_authenticatable # , :omniauthable + # Default devise modules. + devise :registerable, :recoverable, :rememberable, :validatable, :confirmable, :lockable, :timeoutable, :trackable - # Access the users personal account. + # Enable two-factor authentication. + devise :two_factor_authenticatable + + # User belongs to a personal account. belongs_to :personal_account, class_name: "Account", optional: true, dependent: :destroy + validates_associated :personal_account + + # User can have many team accounts. has_many :account_users, dependent: :destroy has_many :accounts, through: :account_users - validates_associated :personal_account - def onboarded? personal_account.present? && personal_account.persisted? end diff --git a/app/views/users/sessions/new.html.erb b/app/views/users/sessions/new.html.erb index dcda14e..5b87404 100644 --- a/app/views/users/sessions/new.html.erb +++ b/app/views/users/sessions/new.html.erb @@ -9,6 +9,7 @@ <%= simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %> <%= f.input :email, placeholder: t(".form.email.placeholder"), required: true, autofocus: true, input_html: { autocomplete: "email" } %> <%= f.input :password, placeholder: t(".form.password.placeholder"), required: true, input_html: { autocomplete: "current-password" } %> + <%= f.input :remember_me, label: t(".form.remember_me.label"), as: :boolean, wrapper: :inline_checkbox if devise_mapping.rememberable? %>