diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 545d8cab..408dd5d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,9 @@ jobs: - name: devise config_path: "ci/configs/devise.yml" skips: --skip-javascript + - name: devise_mfa + config_path: "ci/configs/devise_mfa.yml" + skips: --skip-javascript - name: basic_with_skips config_path: "ci/configs/basic.yml" skips: --skip-spring --skip-javascript diff --git a/ackama_rails_template.config.yml b/ackama_rails_template.config.yml index d0ac3db9..1287be00 100644 --- a/ackama_rails_template.config.yml +++ b/ackama_rails_template.config.yml @@ -17,6 +17,7 @@ use_typescript: true # Use these flags to enable features in the rails app created by this template. apply_variant_react: false apply_variant_devise: false +apply_variant_devise_mfa: false apply_variant_sidekiq: false apply_variant_bootstrap: false diff --git a/ci/configs/all-typescript.yml b/ci/configs/all-typescript.yml index 798cd595..5a191ee1 100644 --- a/ci/configs/all-typescript.yml +++ b/ci/configs/all-typescript.yml @@ -6,6 +6,7 @@ use_typescript: true apply_variant_github_actions_ci: true apply_variant_react: true apply_variant_devise: true +apply_variant_devise_mfa: true apply_variant_sidekiq: true apply_variant_bootstrap: true apply_variant_deploy_with_capistrano: true diff --git a/ci/configs/all.yml b/ci/configs/all.yml index 54d07a23..8679fa7b 100644 --- a/ci/configs/all.yml +++ b/ci/configs/all.yml @@ -6,6 +6,7 @@ use_typescript: false apply_variant_github_actions_ci: true apply_variant_react: true apply_variant_devise: true +apply_variant_devise_mfa: true apply_variant_sidekiq: true apply_variant_bootstrap: true apply_variant_deploy_with_capistrano: true diff --git a/ci/configs/basic-typescript.yml b/ci/configs/basic-typescript.yml index 59308ade..c9ae5140 100644 --- a/ci/configs/basic-typescript.yml +++ b/ci/configs/basic-typescript.yml @@ -6,6 +6,7 @@ use_typescript: true apply_variant_github_actions_ci: false apply_variant_react: false apply_variant_devise: false +apply_variant_devise_mfa: true apply_variant_sidekiq: false apply_variant_bootstrap: false apply_variant_deploy_with_capistrano: false diff --git a/ci/configs/basic.yml b/ci/configs/basic.yml index 161725b5..c9a5f27f 100644 --- a/ci/configs/basic.yml +++ b/ci/configs/basic.yml @@ -6,6 +6,7 @@ use_typescript: false apply_variant_github_actions_ci: false apply_variant_react: false apply_variant_devise: false +apply_variant_devise_mfa: false apply_variant_sidekiq: false apply_variant_bootstrap: false apply_variant_deploy_with_capistrano: false diff --git a/ci/configs/bootstrap-typescript.yml b/ci/configs/bootstrap-typescript.yml index 5e524677..3085e730 100644 --- a/ci/configs/bootstrap-typescript.yml +++ b/ci/configs/bootstrap-typescript.yml @@ -6,6 +6,7 @@ use_typescript: true apply_variant_github_actions_ci: false apply_variant_react: false apply_variant_devise: false +apply_variant_devise_mfa: true apply_variant_sidekiq: false apply_variant_bootstrap: true apply_variant_deploy_with_capistrano: false diff --git a/ci/configs/bootstrap.yml b/ci/configs/bootstrap.yml index f3326bf2..40e70761 100644 --- a/ci/configs/bootstrap.yml +++ b/ci/configs/bootstrap.yml @@ -6,6 +6,7 @@ use_typescript: false apply_variant_github_actions_ci: false apply_variant_react: false apply_variant_devise: false +apply_variant_devise_mfa: true apply_variant_sidekiq: false apply_variant_bootstrap: true apply_variant_deploy_with_capistrano: false diff --git a/ci/configs/deploy_with_ackama_ec2_capistrano.yml b/ci/configs/deploy_with_ackama_ec2_capistrano.yml index a9fd37dc..62fbd3b3 100644 --- a/ci/configs/deploy_with_ackama_ec2_capistrano.yml +++ b/ci/configs/deploy_with_ackama_ec2_capistrano.yml @@ -5,6 +5,7 @@ git_repo_url: "" use_typescript: false apply_variant_react: false apply_variant_devise: false +apply_variant_devise_mfa: true apply_variant_github_actions_ci: false apply_variant_sidekiq: false apply_variant_typescript: false diff --git a/ci/configs/deploy_with_capistrano.yml b/ci/configs/deploy_with_capistrano.yml index efc7ac2c..d1d4137a 100644 --- a/ci/configs/deploy_with_capistrano.yml +++ b/ci/configs/deploy_with_capistrano.yml @@ -6,6 +6,7 @@ use_typescript: false apply_variant_github_actions_ci: false apply_variant_react: false apply_variant_devise: false +apply_variant_devise_mfa: true apply_variant_sidekiq: false apply_variant_bootstrap: false apply_variant_deploy_with_capistrano: true diff --git a/ci/configs/devise.yml b/ci/configs/devise.yml index 61e93148..c8343a08 100644 --- a/ci/configs/devise.yml +++ b/ci/configs/devise.yml @@ -6,6 +6,7 @@ apply_variant_github_actions_ci: false use_typescript: false apply_variant_react: false apply_variant_devise: true +apply_variant_devise_mfa: true apply_variant_sidekiq: false apply_variant_bootstrap: false apply_variant_deploy_with_capistrano: false diff --git a/ci/configs/devise_mfa.yml b/ci/configs/devise_mfa.yml new file mode 100644 index 00000000..c8343a08 --- /dev/null +++ b/ci/configs/devise_mfa.yml @@ -0,0 +1,13 @@ +--- +staging_hostname: "staging.example.com" +production_hostname: "www.example.com" +git_repo_url: "" +apply_variant_github_actions_ci: false +use_typescript: false +apply_variant_react: false +apply_variant_devise: true +apply_variant_devise_mfa: true +apply_variant_sidekiq: false +apply_variant_bootstrap: false +apply_variant_deploy_with_capistrano: false +apply_variant_deploy_with_ackama_ec2_capistrano: false diff --git a/ci/configs/github_actions.yml b/ci/configs/github_actions.yml index e5ed645b..5cfe0483 100644 --- a/ci/configs/github_actions.yml +++ b/ci/configs/github_actions.yml @@ -6,6 +6,7 @@ use_typescript: false apply_variant_github_actions_ci: true apply_variant_react: false apply_variant_devise: false +apply_variant_devise_mfa: true apply_variant_sidekiq: false apply_variant_bootstrap: false apply_variant_deploy_with_capistrano: false diff --git a/ci/configs/react-typescript.yml b/ci/configs/react-typescript.yml index bb0daa7b..80acc821 100644 --- a/ci/configs/react-typescript.yml +++ b/ci/configs/react-typescript.yml @@ -6,6 +6,7 @@ use_typescript: true apply_variant_github_actions_ci: false apply_variant_react: true apply_variant_devise: false +apply_variant_devise_mfa: true apply_variant_sidekiq: false apply_variant_bootstrap: false apply_variant_deploy_with_capistrano: false diff --git a/ci/configs/react.yml b/ci/configs/react.yml index 1dfd4f3b..1dbfac12 100644 --- a/ci/configs/react.yml +++ b/ci/configs/react.yml @@ -6,6 +6,7 @@ use_typescript: false apply_variant_github_actions_ci: false apply_variant_react: true apply_variant_devise: false +apply_variant_devise_mfa: false apply_variant_sidekiq: false apply_variant_bootstrap: false apply_variant_deploy_with_capistrano: false diff --git a/ci/configs/sidekiq.yml b/ci/configs/sidekiq.yml index 6522ac53..d43c69d5 100644 --- a/ci/configs/sidekiq.yml +++ b/ci/configs/sidekiq.yml @@ -6,6 +6,7 @@ use_typescript: false apply_variant_github_actions_ci: false apply_variant_react: false apply_variant_devise: false +apply_variant_devise_mfa: false apply_variant_sidekiq: true apply_variant_bootstrap: false apply_variant_deploy_with_capistrano: false diff --git a/template.rb b/template.rb index 2dae5df8..7efc711d 100644 --- a/template.rb +++ b/template.rb @@ -45,6 +45,10 @@ def apply_variant_devise? @yaml_config.fetch("apply_variant_devise") end + def apply_variant_devise_mfa? + @yaml_config.fetch("apply_variant_devise") + end + def apply_variant_sidekiq? @yaml_config.fetch("apply_variant_sidekiq") end @@ -180,7 +184,10 @@ def apply_template! # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Met # we deliberately place this after the initial git commit because it # contains a lot of changes and adds its own git commit - apply "variants/devise/template.rb" if TEMPLATE_CONFIG.apply_variant_devise? + if TEMPLATE_CONFIG.apply_variant_devise? + apply "variants/devise/template.rb" + apply "variants/devise-mfa/template.rb" if TEMPLATE_CONFIG.apply_variant_devise_mfa? + end # We apply code annotation **after** all the other variants which might # generate routes and models diff --git a/variants/devise-mfa/app/controllers/dashboards_controller.rb b/variants/devise-mfa/app/controllers/dashboards_controller.rb new file mode 100644 index 00000000..fc506074 --- /dev/null +++ b/variants/devise-mfa/app/controllers/dashboards_controller.rb @@ -0,0 +1,11 @@ +## +# This is an example controller which you should remove from your application. +# It demonstrates how to create a controller which requires users to be +# authenticated before running any of its actions. +# +class DashboardsController < ApplicationController + # Only authenticated users can see the dashboard + def show + authorize :dashboard + end +end diff --git a/variants/devise-mfa/app/controllers/users/devise_controller.rb b/variants/devise-mfa/app/controllers/users/devise_controller.rb new file mode 100644 index 00000000..0b481540 --- /dev/null +++ b/variants/devise-mfa/app/controllers/users/devise_controller.rb @@ -0,0 +1,14 @@ +module Users + class DeviseController < ApplicationController + # class DeviseController < Devise::DeviseController + # TODO: this doesn't really need to inherit from Application controller anymore + # Is it more or less surprising to + before_action :configure_permitted_parameters, if: :devise_controller? + + protected + + def configure_permitted_parameters + devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt]) + end + end +end diff --git a/variants/devise-mfa/app/controllers/users/multi_factor_authentications_controller.rb b/variants/devise-mfa/app/controllers/users/multi_factor_authentications_controller.rb new file mode 100644 index 00000000..b8f122fe --- /dev/null +++ b/variants/devise-mfa/app/controllers/users/multi_factor_authentications_controller.rb @@ -0,0 +1,86 @@ +## +# This controller allows users to manage their MFA credential(s). This +# controller is not involved in signing in or out. +# +# Conceptually each user has one MFA resource which they can +# manage because devise-two-factor only supports one mfa code per user +# +module Users + class MultiFactorAuthenticationsController < ApplicationController + before_action { authorize :mfa } + + # Add the environment name to the two-factor authentication string so that + # you can more easily tell the MFA codes for different environments + # apart in your MFA app. + # + # Keep this name short. Some TOTP management applications (e.g. Google + # Authenticator) do not let users edit this name and do not show many + # characters on screen. + # + ISSUER = if Rails.env.production? + I18n.t("application.name").freeze + else + "#{I18n.t("application.name")} #{Rails.env}".freeze + end + + # Allow the template to access issuer + helper_method :issuer + + ## + # #show is the entry point for a user managing their two-factor + # authentication. It displays a summary of the current state of their + # two-factor authentication setup and buttons/links to take actions on + # it. + # + def show + end + + ## + # #new starts the process of setting up two-factor authentication for + # the user + # + def new + @otp_secret = User.generate_otp_secret + end + + ## + # #create accepts the form submission from #new and checks that the TOTP + # code supplied by the user is valid. If the user gives us a valid TOTP code + # then we can we be confident they have correctly setup OTP so we can + # require it at next sign in. + def create + if current_user.validate_and_consume_otp!(otp_param, otp_secret: otp_secret_param) + current_user.update!(otp_secret: otp_secret_param, otp_required_for_login: true) + redirect_to users_multi_factor_authentication_path, notice: t(".success") + else + @otp_secret = otp_secret_param + flash.now[:alert] = t(".invalid_code") + render :new + end + end + + def create_backup_codes + @backup_codes = current_user.generate_otp_backup_codes! + current_user.save! + end + + def destroy_backup_codes + current_user.update!(otp_backup_codes: nil) + redirect_to users_multi_factor_authentication_path, notice: t(".success") + end + + private + + def otp_param + params.require(:otp_attempt).gsub(/\A[^\d+]\z/, "") + end + + def otp_secret_param + params.require(:otp_secret) + end + + def issuer + ISSUER + end + end +end diff --git a/variants/devise-mfa/app/controllers/users/sessions_controller.rb b/variants/devise-mfa/app/controllers/users/sessions_controller.rb new file mode 100644 index 00000000..185fe0ca --- /dev/null +++ b/variants/devise-mfa/app/controllers/users/sessions_controller.rb @@ -0,0 +1,139 @@ +module Users + ## + # This controller contains overrides the standard Devise SessionController to + # support multi-factor authentication. + # + # Each action has a comment describing it's function. Also see + # doc/multi_factor_authentication.md for a more verbose description of the + # sequence of events for MFA. + # + class SessionsController < Devise::SessionsController + RESOURCE_OTP_TOKEN_EXPIRY = 15.minutes # Must sign in within this time + RESOURCE_OTP_TOKEN_PURPOSE = "mfa_attempt".freeze # Used to scope generated signed IDs + + ## + # We do not want Warden to try and authenticate the user in middleware, + # since middleware runs before any controller actions, resulting in the user + # being signed in (AND stored, which we definitely do not want), + # before our customised create action is run. Inside our create action, we + # enable this setting again to allow Devise::Strategies::Authenticatable to + # run. + # + # NOTE: This should be fixed in devise 5.x + # https://github.com/heartcombo/devise/issues/5013 + # https://github.com/heartcombo/devise/pull/5032 + # https://github.com/heartcombo/devise/blob/6d32d2447cc0f3739d9732246b5a5bde98d9e032/lib/devise/strategies/authenticatable.rb#L103 + # + skip_before_action :allow_params_authentication!, only: :create + + ## + # We need to intercept the sign-in attempt if the user has OTP enabled, and + # present them with UI to enter their MFA code. To do this, we check if the + # user requires MFA to sign in. If it does not, we proceed as-is. If it + # does, we render a response to request their MFA code, which then submits + # to the #validate_otp action. + # + def create + # Once we are inside the controller action, we _do_ want to allow params + # authentication, otherwise Devise won't authenticate the user from params. + allow_params_authentication! + + # Tell Devise not to track the user after any call to set_user. We set + # this because we don't want to track a successful sign in until after MFA + # verification. + disable_user_tracking! + + # NOTE: store: false is IMPORTANT - we want to authenticate the user, but not + # actually log them in. + self.resource = warden.authenticate!(**auth_options, store: false) + + return after_successful_sign_in unless resource.otp_required_for_login? + + store_otp_identifier(resource_to_otp_identifier) + render :mfa_prompt, status: :forbidden + end + + ## Validates the OTP code before signing in the user. This requires a + # valid, non-expired signed ID representing the user to exist in the + # session. This is set by successfully authenticating to #create. We do this + # to avoid leaking the signed ID to the user. Even though it has a short + # expiry, it could be used in a social phishing attack, and without + # requiring it in the session, would allow for remote sign in without + # authenticating the user beforehand. + def validate_otp + self.resource = resource_from_otp_identifier(retrieve_otp_identifer) + return after_successful_sign_in if otp_validated + + flash[:alert] = t("devise.failure.invalid", authentication_keys: User.human_attribute_name(:email)) + redirect_to new_user_session_path + end + + ## + # We want to make sure that when a user logs out (i.e. destroys their + # session) then the session cookie they had cannot be used again. We + # achieve this by overriding the built-in devise implementation of + # `Devise::SessionsController#destroy` action to invalidate all existing + # user sessions and then call `super` to run the built-in devise + # implementation of the method. + # + # References + # * https://github.com/plataformatec/devise/issues/3031 + # * http://maverickblogging.com/logout-is-broken-by-default-ruby-on-rails-web-applications/ + # * https://makandracards.com/makandra/53562-devise-invalidating-all-sessions-for-a-user + # + def destroy + current_user.invalidate_all_sessions! + super + end + + private + + ## + # Validates that a given token is eiher: + # 1. A valid OTP code + # 2. A valid OTP backup code + # + def otp_validated + code = otp_params[:otp_attempt].presence + return false unless resource && code + + resource.validate_and_consume_otp!(code) || + (resource.invalidate_otp_backup_code!(code) && resource.save!) + end + + def after_successful_sign_in + set_flash_message!(:notice, :signed_in) + enable_user_tracking! + sign_in(resource, scope: resource_name, force: true) + respond_with resource, location: after_sign_in_path_for(resource) + end + + def resource_to_otp_identifier + resource.to_signed_global_id(expires_in: RESOURCE_OTP_TOKEN_EXPIRY, for: RESOURCE_OTP_TOKEN_PURPOSE) + end + + def resource_from_otp_identifier(otp_identifier) + GlobalID::Locator.locate_signed(otp_identifier, for: RESOURCE_OTP_TOKEN_PURPOSE, only: User) + end + + def store_otp_identifier(otp_identifier) + flash[:otp_identifier] = otp_identifier&.to_s + end + + def retrieve_otp_identifer + flash[:otp_identifier].presence + end + + def otp_params + params.require(resource_name).permit(:otp_attempt, :resource_identifier) + end + + def disable_user_tracking! + request.env["devise.skip_trackable"] = true + end + + def enable_user_tracking! + request.env["devise.skip_trackable"] = false + end + end +end diff --git a/variants/devise-mfa/app/frontend/images/mfa/apple-app-store-badge.svg b/variants/devise-mfa/app/frontend/images/mfa/apple-app-store-badge.svg new file mode 100644 index 00000000..471845ff --- /dev/null +++ b/variants/devise-mfa/app/frontend/images/mfa/apple-app-store-badge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/variants/devise-mfa/app/frontend/images/mfa/google-play-badge.svg b/variants/devise-mfa/app/frontend/images/mfa/google-play-badge.svg new file mode 100644 index 00000000..4a519515 --- /dev/null +++ b/variants/devise-mfa/app/frontend/images/mfa/google-play-badge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/variants/devise-mfa/app/policies/dashboard_policy.rb b/variants/devise-mfa/app/policies/dashboard_policy.rb new file mode 100644 index 00000000..48e443f3 --- /dev/null +++ b/variants/devise-mfa/app/policies/dashboard_policy.rb @@ -0,0 +1,5 @@ +class DashboardPolicy < ApplicationPolicy + def show? + true + end +end diff --git a/variants/devise-mfa/app/policies/mfa_policy.rb b/variants/devise-mfa/app/policies/mfa_policy.rb new file mode 100644 index 00000000..6a645b63 --- /dev/null +++ b/variants/devise-mfa/app/policies/mfa_policy.rb @@ -0,0 +1,13 @@ +class MfaPolicy < ApplicationPolicy + def show? + true + end + + def new? + true + end + + def create? + true + end +end diff --git a/variants/devise-mfa/app/views/application/_header.html.erb b/variants/devise-mfa/app/views/application/_header.html.erb new file mode 100644 index 00000000..9b9c009a --- /dev/null +++ b/variants/devise-mfa/app/views/application/_header.html.erb @@ -0,0 +1,21 @@ +<%# header and navigation menu %> + diff --git a/variants/devise-mfa/app/views/application/_mfa_help.html.erb b/variants/devise-mfa/app/views/application/_mfa_help.html.erb new file mode 100644 index 00000000..20c954ce --- /dev/null +++ b/variants/devise-mfa/app/views/application/_mfa_help.html.erb @@ -0,0 +1,20 @@ +
+

What is two-factor authentication?

+
+

+ Two-factor authentication (2FA) is a way of adding security to your + account, and preventing people from accessing your account - even if + they have access to your email and password. +

+

+ You'll need an app on your phone or a similar device that can generate + a 6-digit "One Time Password" for you each time you sign in. We also + recommend generating backup codes, which can each be used once to + access your account if you aren't able to use an authenticator app. +

+

+ If you do think someone has access to your email and password, please + contact us immediately. +

+
+
diff --git a/variants/devise-mfa/app/views/dashboards/show.html.erb b/variants/devise-mfa/app/views/dashboards/show.html.erb new file mode 100644 index 00000000..0693782f --- /dev/null +++ b/variants/devise-mfa/app/views/dashboards/show.html.erb @@ -0,0 +1,10 @@ +<% provide(:title, "Authenticated user dashboard") %> + +

Authenticated user dashboard

+ +

+This is the signed in user dashboard. +Only signed in users can see this. +You are signed in as <%= current_user.email %> +

+ diff --git a/variants/devise-mfa/app/views/users/multi_factor_authentications/create_backup_codes.html.erb b/variants/devise-mfa/app/views/users/multi_factor_authentications/create_backup_codes.html.erb new file mode 100644 index 00000000..05fba3dc --- /dev/null +++ b/variants/devise-mfa/app/views/users/multi_factor_authentications/create_backup_codes.html.erb @@ -0,0 +1,24 @@ +
+

Generate backup codes

+ +

+ If you lose access to your authentication app, you can use these codes instead + of the 6-digit One Time Passwords from your device. +

+ +

+ Each code can only be used once, and we recommend you keep them in a safe, + secure place. +

+ +
+

Your backup codes

+ + <%= content_tag(:pre, @backup_codes.join("\n"), class: "border rounded p-2 user-select-all text-black") %> +
+ + + <%= link_to users_multi_factor_authentication_path, class: "link-primary" do %> + Return to two-factor authentication setup + <% end %> +
diff --git a/variants/devise-mfa/app/views/users/multi_factor_authentications/new.html.erb b/variants/devise-mfa/app/views/users/multi_factor_authentications/new.html.erb new file mode 100644 index 00000000..4a6018a2 --- /dev/null +++ b/variants/devise-mfa/app/views/users/multi_factor_authentications/new.html.erb @@ -0,0 +1,84 @@ +
+ <% if current_user.otp_required_for_login? %> +

Manage two-factor authentication

+ +

Use this page if you need to link your user to a new device or + authentication app.

+ <% else %> +

Welcome to APP_NAME_HERE

+

+ As this is the first time you’re signing in, you’ll need to set up + two-factor authentication (2FA) before using APP_NAME_HERE. This helps keep data within this application secure. +

+ <% end %> + + <%= render "application/mfa_help" %> + +
+ +

Step 1

+ +

+ First, download a free app to generate One Time Passwords. + We recommend the Google Authenticator app for + <%= link_to "Apple iOS", "https://itunes.apple.com/au/app/google-authenticator/id388497605?mt=8", target: "_blank", class: "link-primary" %> + if you’re on an iPhone, or for + <%= link_to "Android", "https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en", target: "_blank", class: "link-primary" %> + if you have an Android phone. +

+ +
+ <%= link_to "https://itunes.apple.com/au/app/google-authenticator/id388497605?mt=8", class: "me-4" do %> + <%= image_pack_tag "static/images/mfa/apple-app-store-badge.svg", alt: "Download Google Authenticator on the Apple App Store", size: "180x60" %> + <% end %> + <%= link_to "https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en" do %> + <%= image_pack_tag "static/images/mfa/google-play-badge.svg", alt: "Download Google Authenticator at Google Play", size: "180x60" %> + <% end %> +
+ +
+ +

Step 2

+ +

+ Next, using this app, scan the below code. This will allow the app to generate + One Time Passwords, which you’ll need each time you sign in.

+ + <% + url = current_user.otp_provisioning_uri(current_user.email, issuer: issuer, otp_secret: @otp_secret) + qr = RQRCode::QRCode.new(url) + %> +
+ <%= raw qr.as_svg(module_size: 3, shape_rendering: 'crispEdges', level: :h) %> +
+ +

+ If you’re using an app that does not support scanning a QR code, or if your camera doesn’t work, you can use the following URL: +

+ +
<%= url %>
+ +
+ +

Step 3

+ +

+ Next enter the code the app shows on screen below. This will confirm that the + app has been set up correctly. +

+ + <%= form_tag(users_multi_factor_authentication_path, method: :post, class: "my-6") do %> + <%= hidden_field_tag :authenticity_token, form_authenticity_token %> + <%= hidden_field_tag :otp_secret, @otp_secret %> +
+ <%= text_field_tag :otp_attempt, nil, size: 10, required: true, maxlength: 6, placeholder: "123 456", class: "form-control w-auto" %> +
+
+ <%= button_tag "Confirm code", class: "btn btn-primary w-100", data: { turbo: false } %> +
+ <% end %> + + <%= link_to users_multi_factor_authentication_path, class: "link-primary" do %> + Cancel update + <% end %> +
diff --git a/variants/devise-mfa/app/views/users/multi_factor_authentications/show.html.erb b/variants/devise-mfa/app/views/users/multi_factor_authentications/show.html.erb new file mode 100644 index 00000000..71348b21 --- /dev/null +++ b/variants/devise-mfa/app/views/users/multi_factor_authentications/show.html.erb @@ -0,0 +1,68 @@ +

Manage my two-factor authentication

+ +
+
+
+ <% if current_user.otp_enabled_and_required? %> + ✅ + <% else %> + 🚫 + <% end %> +
+
+ Two-factor authentication is currently <%= current_user.otp_enabled_and_required? ? "enabled" : "disabled" %> for your user. +
+
+ +
+
+ <% if current_user.otp_backup_codes.present? %> + ✅ + <% else %> + 🚫 + <% end %> +
+
+ <% if current_user.otp_backup_codes.present? %> +

+ You currently have backup codes generated. +

+

+ If you lose access to your authentication app you will require these to + regain access to APP_NAME_HERE. Please keep them safe and + secure. +

+ <% else %> + You do not currently have backup codes generated. + <% end %> +
+
+ +
+ + <% if current_user.otp_enabled_and_required? %> +

Generate backup codes

+

+ If you lose access to your authentication app, you can use these codes instead of the 6-digit One Time Passwords from your device. +

+

+ Each code can only be used once, and we recommend you keep them in a safe, secure place. +

+ + <%= button_to t(".create_backup_codes"), backup_codes_users_multi_factor_authentication_path, class: "btn btn-primary px-6 mt-6", data: { turbo: false } %> + + <%= button_to backup_codes_users_multi_factor_authentication_path, method: :delete, class: "btn text-primary ps-0 d-flex mt-2 align-items-center" do %> + <%= t(".destroy_backup_codes") %> + <% end if current_user.otp_backup_codes.present? %> + +
+ <% end %> + + +

Manage two-factor authentication

+

+ If you need to set up a new device or authentication app, you can do that on this page. +

+ + <%= link_to t(".new_multi_factor_authentication"), new_users_multi_factor_authentication_path, class: "btn btn-outline-primary px-6" %> +
diff --git a/variants/devise-mfa/app/views/users/sessions/mfa_prompt.html.erb b/variants/devise-mfa/app/views/users/sessions/mfa_prompt.html.erb new file mode 100644 index 00000000..9c85d1f2 --- /dev/null +++ b/variants/devise-mfa/app/views/users/sessions/mfa_prompt.html.erb @@ -0,0 +1,16 @@ +

Log in

+ +<%= form_with(scope: resource_name, url: users_validate_otp_path) do |f| %> + Please enter your two-factor authentication (2FA) code to sign in. + +
+ <%= f.text_field :otp_attempt, autocomplete: "one-time-code", placeholder: "123 456", label: t("users.sessions.mfa_field_label") %> +
+ +
+ <%= f.submit "Submit", class: "btn btn-primary" %> +
+ + <%= render "application/mfa_help" %> +<% end %> + diff --git a/variants/devise-mfa/config/locales/mfa.en.yml b/variants/devise-mfa/config/locales/mfa.en.yml new file mode 100644 index 00000000..5f39cd37 --- /dev/null +++ b/variants/devise-mfa/config/locales/mfa.en.yml @@ -0,0 +1,16 @@ +en: + application: + multi_factor_authentication_required: | + You do not currently have two-factor authentication enabled. + You will need to set it up before you can use this application. + users: + sessions: + mfa_field_label: "Two-factor authentication code (or backup code)" + multi_factor_authentications: + show: + new_multi_factor_authentication: Manage two-factor authentication + create_backup_codes: Generate backup codes + destroy_backup_codes: Delete my current backup codes + destroy_backup_codes: + success: Backup codes successfully deleted + delete_backup_codes: Delete my current backup codes diff --git a/variants/devise-mfa/doc/multi_factor_authentication.md b/variants/devise-mfa/doc/multi_factor_authentication.md new file mode 100644 index 00000000..79215c47 --- /dev/null +++ b/variants/devise-mfa/doc/multi_factor_authentication.md @@ -0,0 +1,153 @@ +# Multi-factor authentication + +This application uses MFA to help to protect access to accounts. The only +currently supported MFA mechanism is time-based one-time-passwords (TOTP). + +Supported MFA functions are: + +* Sign in without MFA if it is not enabled +* Require MFA with sign in if it is enabled +* Configure a single device with a TOTP code to use for sign in. Multiple +* devices are not supported right not. +* Generate 5 backup codes that can be used instead of/in place of device-based TOTP. +* Reset backup codes to invalidate lost or consumed codes. + +Once enabled, MFA can be switched to another device, but cannot be disabled. + +## How sign-in MFA works + +The library we are using +[devise-two-factor-authentication](https://github.com/devise-two-factor/devise-two-factor) +provides a custom strategy called `:two_factor_authenticatable` that expects the +OTP code attempt to be provided at the same time as email and password. + +This works fine if MFA is **required** for **all** users. We can add MFA to more +applications if we default to MFA being an opt-in security upgrade for users so +opt-in MFA is our default configuration. + +The `otp_required_for_login` attribute on `User` (accessible through +`User#otp_required_for_login?`) decides whether a user **must** use MFA to sign +in. If the application needs to force some categories of users to use MFA (e.g. +admins) then this flag should be set on those users during sign-up and all +functionality allowing users to unset the flag should be removed from the app. + +To support our default "MFA as opt-in security upgrade" flow, we have split the +authentication into two steps: + +## Can I force some/all users to use MFA? + +Yes. There is a commented out code in [ApplicationController](../app/controllers/application_controller.rb) that demonstrates how to require MFA for some or all users. + +### Step 1: Authentication + +- The user is presented with a form allowing them to provide their email and + password (Devise's stock `accounts/sessions#new`). +- The user enters an email and password, which is validated by the standard + Devise `:database_authenticatable` strategy. This strategy also runs any + pre-screens on the user to check that they can sign in the first place (for + example, if they are locked, unconfirmed or inactive, they cannot sign in). +- If the user has provided an invalid email or password, Warden's failure mode + activates, redirecting back to the sign in page with a flash error message. +- If the user has provided a valid email and password, but their account does + not **require** MFA (`#User.otp_required_for_login?` returns false) then they + are signed in. Additional checks within the application forcibly redirect all + signed-in routes to the "enable MFA" screen if MFA is required +- If the user has provided a valid email and password, and have activated MFA, + `accounts/sessions#create` signs the account out (`warden.authenticate!` has + signed the account in), and generates a + [**signed** Global ID](https://github.com/rails/globalid) representing the + account, and stores it in the session. `accounts/sessions#create` then renders + the OTP validation form to being step 2. + +### Step 2: OTP validation + +- The OTP validation form requests their OTP code (labelled "Two-factor + authentication code") as a single text field. +- The user enters an OTP code, either from their device, or using one of their + backup codes, and submits the form. +- The signed Global ID of the account is retrieved from the session and resolved to the + account, so long as it has not been tampered with or expired. +- The resolved account validates and consumes the OTP code provided by the user + via form params. +- If the account was resolved, and the OTP code is reported to be valid, we + follow the same steps we follow when a user signs in without MFA - we sign + them in, set a flash message, and redirect to the stored after sign in path. +- If the account was not resolved, or if the OTP code was invalid, we react as + if the entire authentication attempt failed, redirecting back to the initial + sign in page with a flash error message. + +### Failure modes: + +- Invalid email (account not found) +- Locked account +- Unconfirmed account +- Inactive account +- Invalid OTP token +- Expired signed ID for account +- Tampered or reused signed ID for account + +In all these failure modes, the application returns the user to the sign in page +with a generic failure message (in paranoid mode). + +### Example flows + +#### Sign in a user who does not have MFA enabled + +This flow applies to users whose `otp_required_for_login` is `false`. This is the default devise sign-in flow. + +```mermaid +sequenceDiagram + autonumber + participant H as Human + participant B as Browser + participant SC as SessionsController + participant W as Warden + + H->>B: Clicks 'Sign in' or visits sign-in URL directly + B->>SC: GET /users/sign_in -> users/sessions#35;new + SC->>B: Renders users/sessions/new.html.erb + + H->>B: Provides email and password + B->>SC: POST /users/sign_in(email, password) -> users/sessions#35;create + SC->>+W: Attempt to authenticate user + W->>-SC: Returns authenticated user + SC->>W: Signs in the user + SC->>B: Redirects to after_sign_in_path_for and presents flash message +``` + +#### Sign in a user who has MFA enabled + +This flow applies to users whose `otp_required_for_login` is `true`. + +```mermaid +sequenceDiagram + autonumber + participant H as Human + participant B as Browser + participant SC as SessionsController + participant W as Warden + participant U as User Model + participant RS as Rails Session + + H->>B: Clicks 'Sign in' or visits sign-in URL directly + B->>SC: GET /users/sign_in -> users/sessions#35;new + SC->>B: Renders users/sessions/new.html.erb + + H->>B: Provides email and password + B->>SC: POST /users/sign_in(email, password) -> users/sessions#35;create + SC->>+W: Attempt to authenticate user + W->>-SC: Returns authenticated user + SC->>SC: User#otp_required_for_login? is true so sign out user + SC->>SC: Generates signed Global ID for user record + SC->>RS: Store signed Global ID as otp_identifier + SC->>B: Renders users/sessions/mfa_prompt.html.erb + + H->>B: Provides OTP code attempt + B->>SC: POST /users/validate_otp -> users/sessions#35;validate_otp + SC->>+RS: Requests otp_identifier (the signed Global ID) + RS->>-SC: Returns otp_identifier (the signed Global ID) + SC->>U: Locates user if signed Global ID is valid + SC->>U: Validates the provided OTP code attempt + SC->>W: Signs in the user + SC->>B: Redirects to after_sign_in_path_for and presents flash message +``` diff --git a/variants/devise-mfa/doc/multi_factor_authentication_sequence.png b/variants/devise-mfa/doc/multi_factor_authentication_sequence.png new file mode 100644 index 00000000..4d49043b Binary files /dev/null and b/variants/devise-mfa/doc/multi_factor_authentication_sequence.png differ diff --git a/variants/devise-mfa/template.rb b/variants/devise-mfa/template.rb new file mode 100644 index 00000000..a6f7ee05 --- /dev/null +++ b/variants/devise-mfa/template.rb @@ -0,0 +1,250 @@ +# Allow us to copy file with root at the directory this file is in +source_paths.unshift(File.dirname(__FILE__)) + +###################################### +# Gemfile +###################################### + +TERMINAL.puts_header "Adding devise-two-factor, rqrcode-rails3 to Gemfile" +run "bundle add devise-two-factor" +run "bundle add rqrcode-rails3" + +###################################### +# Migration +###################################### + +# We need devise-two-factor but we aren't using it in the standard way so we +# don't run it's generator. Instead we install it manually. +TERMINAL.puts_header "Adding devise-two-factor manually" + +raw_output = `bundle exec rails g migration AddOtpSecretsToUser --pretend` +migration_path = raw_output.lines.find { |line| line.match?(%r{create\s+db/migrate}) }.split.last +create_file(migration_path) do + <<~EO_RUBY + class AddOtpSecretsToUser < ActiveRecord::Migration[7.0] + def change + change_table :users, bulk: true do |t| + t.string :otp_secret + t.integer :consumed_timestep + t.boolean :otp_required_for_login, default: false, null: false + end + end + end + EO_RUBY +end + +run "bundle exec rails db:migrate" + +###################################### +# User model +###################################### + +TERMINAL.puts_header "Configuring user model" +insert_into_file("app/models/user.rb", after: /^class User.*?\n/) do + <<-EO_RUBY + # NOTE: devise-two-factor requests that database_authenticatable is replaced + # with two_factor_authenticatable. We do NOT do this, because we have a + # two-step authentication process. We use DatabaseAuthenticatable to validate + # the email and password, and then validate the OTP code or backup code in a + # second step. The same is true of two_factor_backupable. Including this + # strategy means that a failed auth attempt is flagged as a failed attempt, + # even when rendering the MFA validation page. We DO include the model + # methods, because we still use them + # + include Devise::Models::TwoFactorAuthenticatable + include Devise::Models::TwoFactorBackupable + + EO_RUBY +end + +insert_into_file("app/models/user.rb", before: /^end/) do + <<-EO_RUBY + + def enable_otp! + update!(otp_secret: User.generate_otp_secret) + end + + # this resets the secret but deliberately does not touch the + # `otp_required_for_login` flag + def reset_otp_secret! + update!(otp_secret: User.generate_otp_secret) + end + + def require_otp! + update!(otp_required_for_login: true) + end + + def otp_enabled_and_required? + otp_secret.present? && otp_required_for_login + end + + def disable_otp! + update!(otp_secret: nil, otp_required_for_login: false, otp_backup_codes: nil) + end + + def discard_otp_secret! + update!(otp_secret: nil) + end + EO_RUBY +end + +###################################### +# Controllers +###################################### + +TERMINAL.puts_header "Configuring MFA controllers" + +copy_file "variants/devise-mfa/app/controllers/users/devise_controller.rb", "app/controllers/users/devise_controller.rb" +copy_file "variants/devise-mfa/app/controllers/users/sessions_controller.rb", "app/controllers/users/sessions_controller.rb", force: true +copy_file "variants/devise-mfa/app/controllers/users/multi_factor_authentications_controller.rb", "app/controllers/users/multi_factor_authentications_controller.rb" +copy_file "variants/devise-mfa/app/controllers/dashboards_controller.rb", "app/controllers/dashboards_controller.rb" + +gsub_file("app/controllers/application_controller.rb", /^\s*private$/) do + <<-EO_RUBY + + before_action :authenticate_user!, unless: :skip_authentication_for_this_action? + before_action :redirect_to_mfa_setup_page, if: :current_user_must_setup_mfa_immediately? + + private + + def skip_authentication_for_this_action? + return true if devise_controller? # Devise controllers do not need authentication + # TODO: what about editing user registraiton? taht shouldn't be public? + return true if controller_name == "home" # Home controller is public + + false + end + + def current_user_must_setup_mfa_immediately? + # Anonymous users do not need MFA (must be first check becaus it implicitly + # checks whether `current_user` exists) + return false unless user_signed_in? + return false if current_user.otp_required_for_login? # User already has MFA enabled + return false if devise_controller? # Devise controllers do not need MFA TODO: what about user registration edits? + + # The MFA setup page itself does not require MFA to be enabled + return false if controller_name == "multi_factor_authentications" && %w[new show create].include?(action_name) + + true + end + + def redirect_to_mfa_setup_page + redirect_to new_users_multi_factor_authentication_path, alert: t("application.multi_factor_authentication_required") + end + + def after_sign_in_path_for(resource) + stored_location_for(resource) || dashboards_path + end + EO_RUBY +end + +###################################### +# Views +###################################### + +TERMINAL.puts_header "Copying views" +copy_file "variants/devise-mfa/app/views/users/sessions/mfa_prompt.html.erb", "app/views/users/sessions/mfa_prompt.html.erb" +copy_file "variants/devise-mfa/app/views/dashboards/show.html.erb", "app/views/dashboards/show.html.erb" +copy_file "variants/devise-mfa/app/views/application/_mfa_help.html.erb", "app/views/application/_mfa_help.html.erb" +copy_file "variants/devise-mfa/app/views/application/_header.html.erb", "app/views/application/_header.html.erb", force: true +directory "variants/devise-mfa/app/views/users/multi_factor_authentications", "app/views/users/multi_factor_authentications" + +insert_into_file("app/views/users/registrations/edit.html.erb", before: %r{^

Cancel my account

}) do + <<~EO_FIELD + +

Two-factor authentication

+

Manage the device and codes you'll use to sign in to this application.

+ <%= link_to "Manage my two-factor authentication", users_multi_factor_authentication_path %> + <%= render "application/mfa_help" %> + + EO_FIELD +end + +###################################### +# Config +###################################### + +TERMINAL.puts_header "Adding otp_attempt to filtered params in logs" +append_to_file("config/initializers/filter_parameter_logging.rb") do + <<~EO_CONTENT + Rails.application.config.filter_parameters += %i[otp_attempt] + EO_CONTENT +end + +TERMINAL.puts_header "Tweaking config/initializers/devise.rb" + +gsub_file "config/initializers/devise.rb", + " # config.sign_in_after_reset_password = true", + <<-EO_CONFIG + # + # This must be set to false when using devise-two-factor - see + # https://github.com/tinfoil/devise-two-factor#disabling-automatic-login-after-password-resets + config.sign_in_after_reset_password = false + EO_CONFIG + +gsub_file "config/initializers/devise.rb", + " # config.parent_controller = 'DeviseController'", + ' config.parent_controller = "Users::DeviseController"' + +###################################### +# Images +###################################### + +TERMINAL.puts_header "Copying MFA logos" +directory "variants/devise-mfa/app/frontend/images/mfa", "app/frontend/images/mfa" + +###################################### +# Routes +###################################### + +TERMINAL.puts_header "Setting up routes.rb" +insert_into_file("config/routes.rb", before: /^end/) do + <<-EO_ROUTES + + namespace :users do + devise_scope :user do + post :validate_otp, to: "sessions#validate_otp" + end + + resource :multi_factor_authentication, only: %i[new show create] do + post :backup_codes, action: :create_backup_codes + delete :backup_codes, action: :destroy_backup_codes + end + end + + resource :dashboards, only: [:show] + EO_ROUTES +end + +###################################### +# Pundit +###################################### + +copy_file "variants/devise-mfa/app/policies/dashboard_policy.rb", "app/policies/dashboard_policy.rb" + +###################################### +# Locales +###################################### + +TERMINAL.puts_header "Setting up locales" +copy_file "variants/devise-mfa/config/locales/mfa.en.yml", "config/locales/mfa.en.yml" + +###################################### +# Documentation +###################################### + +TERMINAL.puts_header "Copying MFA docs" +copy_file "variants/devise-mfa/doc/multi_factor_authentication_sequence.png", "doc/multi_factor_authentication_sequence.png" +copy_file "variants/devise-mfa/doc/multi_factor_authentication.md", "doc/multi_factor_authentication.md" +remove_file "doc/.keep" + +###################################### +# Clean up +###################################### + +TERMINAL.puts_header "Running rubocop to clean up generated files" +run "bundle exec rubocop -A" + +TERMINAL.puts_header "Commiting changes to git" +git add: "-A ." +git commit: "-n -m 'Install and configure devise with MFA enabled'"