diff --git a/.env.example b/.env.example index c86fc0949..2163f2802 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ DB_USERNAME=postgres-user DB_PASSWORD=postgres-password -GOVUK_NOTIFY_API_KEY= + +GOVUK_NOTIFY_API_KEY= +OTP_SECRET_ENCRYPTION_KEY="" diff --git a/Gemfile b/Gemfile index ffb456be4..b96ea93e1 100644 --- a/Gemfile +++ b/Gemfile @@ -37,6 +37,9 @@ gem "json-schema" # Authentication # Point at branch until devise is compatible with Turbo, see https://github.com/heartcombo/devise/pull/5340 gem "devise", github: "baarkerlounger/devise", branch: "dluhc-fixes" +# Two-factor Authentication for devise models. Pointing at fork until this is merged for Rails 6 compatibility +# https://github.com/Houdini/two_factor_authentication/pull/204 +gem "two_factor_authentication", github: "baarkerlounger/two_factor_authentication" # UK postcode parsing and validation gem "uk_postcode" # Get rich data from postcode lookups. Wraps postcodes.io diff --git a/Gemfile.lock b/Gemfile.lock index 87a9f4a7d..b48605158 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -18,6 +18,17 @@ GIT responders warden (~> 1.2.3) +GIT + remote: https://github.com/baarkerlounger/two_factor_authentication.git + revision: a7522becd7222f1aa4ddf73d7caf19f05bdb4dac + specs: + two_factor_authentication (2.2.0) + devise + encryptor + rails (>= 3.1.1) + randexp + rotp (>= 4.0.0) + GIT remote: https://github.com/tagliala/activeadmin.git revision: d1492c54e76871d95f3a7ff20e445b48f455d4cb @@ -157,6 +168,7 @@ GEM dotenv-rails (2.7.6) dotenv (= 2.7.6) railties (>= 3.2) + encryptor (3.0.0) erubi (1.10.0) excon (0.90.0) factory_bot (6.2.0) @@ -310,6 +322,7 @@ GEM zeitwerk (~> 2.5) rainbow (3.1.1) rake (13.0.6) + randexp (0.1.7) ransack (2.5.0) activerecord (>= 5.2.4) activesupport (>= 5.2.4) @@ -325,6 +338,7 @@ GEM roo (2.8.3) nokogiri (~> 1) rubyzip (>= 1.3.0, < 3.0.0) + rotp (6.2.0) rspec-core (3.10.2) rspec-support (~> 3.10.0) rspec-expectations (3.10.2) @@ -472,6 +486,7 @@ DEPENDENCIES scss_lint-govuk selenium-webdriver simplecov + two_factor_authentication! tzinfo-data uk_postcode view_component diff --git a/app/models/admin_user.rb b/app/models/admin_user.rb index 14ea71789..5c4f4b1c1 100644 --- a/app/models/admin_user.rb +++ b/app/models/admin_user.rb @@ -1,5 +1,16 @@ class AdminUser < ApplicationRecord # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable - devise :database_authenticatable, :recoverable, :rememberable, :validatable + devise :two_factor_authenticatable, :database_authenticatable, :recoverable, + :rememberable, :validatable + + has_one_time_password(encrypted: true) + + MFA_SMS_TEMPLATE_ID = "bf309d93-804e-4f95-b1f4-bd513c48ecb0".freeze + + def send_two_factor_authentication_code(code) + template_id = MFA_SMS_TEMPLATE_ID + personalisation = { otp: code } + Sms.send(phone, template_id, personalisation) + end end diff --git a/app/services/sms.rb b/app/services/sms.rb new file mode 100644 index 000000000..c5a4f8ffd --- /dev/null +++ b/app/services/sms.rb @@ -0,0 +1,15 @@ +require "notifications/client" + +class Sms + def self.notify_client + Notifications::Client.new(ENV["GOVUK_NOTIFY_API_KEY"]) + end + + def self.send(phone_number, template_id, args) + notify_client.send_sms( + phone_number: phone_number, + template_id: template_id, + personalisation: args, + ) + end +end diff --git a/app/views/devise/two_factor_authentication/show.html.erb b/app/views/devise/two_factor_authentication/show.html.erb new file mode 100644 index 000000000..9f9bc7ad1 --- /dev/null +++ b/app/views/devise/two_factor_authentication/show.html.erb @@ -0,0 +1,26 @@ +<% content_for :title, "Check your phone" %> + +<%= form_with(url: "/admin/two_factor_authentication", html: { method: :put }) do |f| %> +
+
+ +

+ <%= content_for(:title) %> +

+ +

We’ve sent you a text message with a security code.

+ + <%= f.govuk_number_field :code, + label: { text: "Security code" }, + width: 5, + autofocus: true + %> + + <%= f.govuk_submit "Submit" %> +
+
+<% end %> + +

+ <%= govuk_link_to "Not received a text message?", "#" %> +

diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index d5196b163..0d9dcbaf7 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -309,4 +309,15 @@ Devise.setup do |config| # When set to false, does not sign a user in automatically after their password is # changed. Defaults to true, so a user is signed in automatically after changing a password. # config.sign_in_after_change_password = true + + # 2FA + config.max_login_attempts = 3 # Maximum second factor attempts count. + config.allowed_otp_drift_seconds = 30 # Allowed TOTP time drift between client and server. + config.otp_length = 6 # TOTP code length + config.direct_otp_valid_for = 5.minutes # Time before direct OTP becomes invalid + config.direct_otp_length = 6 # Direct OTP code length + config.remember_otp_session_for_seconds = 1.day # Time before browser has to perform 2fA again. Default is 0. + config.otp_secret_encryption_key = ENV["OTP_SECRET_ENCRYPTION_KEY"] + config.second_factor_resource_id = "id" # Field or method name used to set value for 2fA remember cookie + config.delete_cookie_on_logout = false # Delete cookie when user signs out, to force 2fA again on login end diff --git a/db/migrate/20211203135623_two_factor_authentication_add_to_admin_users.rb b/db/migrate/20211203135623_two_factor_authentication_add_to_admin_users.rb new file mode 100644 index 000000000..556023f3c --- /dev/null +++ b/db/migrate/20211203135623_two_factor_authentication_add_to_admin_users.rb @@ -0,0 +1,16 @@ +class TwoFactorAuthenticationAddToAdminUsers < ActiveRecord::Migration[6.1] + def change + change_table :admin_users, bulk: true do |t| + t.column :second_factor_attempts_count, :integer, default: 0 + t.column :encrypted_otp_secret_key, :string + t.column :encrypted_otp_secret_key_iv, :string + t.column :encrypted_otp_secret_key_salt, :string + t.column :direct_otp, :string + t.column :direct_otp_sent_at, :datetime + t.column :totp_timestamp, :timestamp + t.column :phone, :string + + t.index :encrypted_otp_secret_key, unique: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index a56d45399..d93c8efed 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -23,6 +23,15 @@ ActiveRecord::Schema.define(version: 2022_01_31_123638) do t.datetime "remember_created_at" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false + t.integer "second_factor_attempts_count", default: 0 + t.string "encrypted_otp_secret_key" + t.string "encrypted_otp_secret_key_iv" + t.string "encrypted_otp_secret_key_salt" + t.string "direct_otp" + t.datetime "direct_otp_sent_at" + t.datetime "totp_timestamp" + t.string "phone" + t.index ["encrypted_otp_secret_key"], name: "index_admin_users_on_encrypted_otp_secret_key", unique: true end create_table "case_logs", force: :cascade do |t| diff --git a/spec/factories/admin_user.rb b/spec/factories/admin_user.rb index 29a5b079b..74f9a96b5 100644 --- a/spec/factories/admin_user.rb +++ b/spec/factories/admin_user.rb @@ -2,6 +2,7 @@ FactoryBot.define do factory :admin_user do sequence(:email) { |i| "admin#{i}@example.com" } password { "pAssword1" } + phone { "07563867654" } created_at { Time.zone.now } updated_at { Time.zone.now } end diff --git a/spec/features/admin_panel_spec.rb b/spec/features/admin_panel_spec.rb new file mode 100644 index 000000000..6625cdaa3 --- /dev/null +++ b/spec/features/admin_panel_spec.rb @@ -0,0 +1,45 @@ +require "rails_helper" + +RSpec.describe "Admin Panel" do + let!(:admin) { FactoryBot.create(:admin_user) } + let(:notify_client) { instance_double(Notifications::Client) } + let(:mfa_template_id) { AdminUser::MFA_SMS_TEMPLATE_ID } + let(:otp) { "999111" } + + before do + allow(Sms).to receive(:notify_client).and_return(notify_client) + allow(notify_client).to receive(:send_sms).and_return(true) + end + + context "with a valid 2FA code" do + before do + allow(SecureRandom).to receive(:random_number).and_return(otp) + end + + it "authenticates successfully" do + expect(notify_client).to receive(:send_sms).with( + hash_including(phone_number: admin.phone, template_id: mfa_template_id), + ) + visit("/admin") + fill_in("admin_user[email]", with: admin.email) + fill_in("admin_user[password]", with: admin.password) + click_button("Login") + fill_in("code", with: otp) + click_button("Submit") + expect(page).to have_content("Dashboard") + expect(page).to have_content("Two factor authentication successful.") + end + end + + context "with an invalid 2FA code" do + it "does not authenticate successfully" do + visit("/admin") + fill_in("admin_user[email]", with: admin.email) + fill_in("admin_user[password]", with: admin.password) + click_button("Login") + fill_in("code", with: otp) + click_button("Submit") + expect(page).to have_content("Check your phone") + end + end +end