Browse Source

Add 2FA to admin panel sign in

pull/146/head
baarkerlounger 4 years ago
parent
commit
ce9e09741b
  1. 4
      .env.example
  2. 3
      Gemfile
  3. 15
      Gemfile.lock
  4. 13
      app/models/admin_user.rb
  5. 15
      app/services/sms.rb
  6. 26
      app/views/devise/two_factor_authentication/show.html.erb
  7. 11
      config/initializers/devise.rb
  8. 16
      db/migrate/20211203135623_two_factor_authentication_add_to_admin_users.rb
  9. 9
      db/schema.rb
  10. 1
      spec/factories/admin_user.rb
  11. 45
      spec/features/admin_panel_spec.rb

4
.env.example

@ -1,3 +1,5 @@
DB_USERNAME=postgres-user DB_USERNAME=postgres-user
DB_PASSWORD=postgres-password DB_PASSWORD=postgres-password
GOVUK_NOTIFY_API_KEY=<notify-key-here-if-testing-emails>
GOVUK_NOTIFY_API_KEY=<notify-key-here-if-testing-emails-or-admin-users>
OTP_SECRET_ENCRYPTION_KEY="<Generate this using bundle exec rake secret>"

3
Gemfile

@ -37,6 +37,9 @@ gem "json-schema"
# Authentication # Authentication
# Point at branch until devise is compatible with Turbo, see https://github.com/heartcombo/devise/pull/5340 # 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" 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 # UK postcode parsing and validation
gem "uk_postcode" gem "uk_postcode"
# Get rich data from postcode lookups. Wraps postcodes.io # Get rich data from postcode lookups. Wraps postcodes.io

15
Gemfile.lock

@ -18,6 +18,17 @@ GIT
responders responders
warden (~> 1.2.3) 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 GIT
remote: https://github.com/tagliala/activeadmin.git remote: https://github.com/tagliala/activeadmin.git
revision: d1492c54e76871d95f3a7ff20e445b48f455d4cb revision: d1492c54e76871d95f3a7ff20e445b48f455d4cb
@ -157,6 +168,7 @@ GEM
dotenv-rails (2.7.6) dotenv-rails (2.7.6)
dotenv (= 2.7.6) dotenv (= 2.7.6)
railties (>= 3.2) railties (>= 3.2)
encryptor (3.0.0)
erubi (1.10.0) erubi (1.10.0)
excon (0.90.0) excon (0.90.0)
factory_bot (6.2.0) factory_bot (6.2.0)
@ -310,6 +322,7 @@ GEM
zeitwerk (~> 2.5) zeitwerk (~> 2.5)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.0.6) rake (13.0.6)
randexp (0.1.7)
ransack (2.5.0) ransack (2.5.0)
activerecord (>= 5.2.4) activerecord (>= 5.2.4)
activesupport (>= 5.2.4) activesupport (>= 5.2.4)
@ -325,6 +338,7 @@ GEM
roo (2.8.3) roo (2.8.3)
nokogiri (~> 1) nokogiri (~> 1)
rubyzip (>= 1.3.0, < 3.0.0) rubyzip (>= 1.3.0, < 3.0.0)
rotp (6.2.0)
rspec-core (3.10.2) rspec-core (3.10.2)
rspec-support (~> 3.10.0) rspec-support (~> 3.10.0)
rspec-expectations (3.10.2) rspec-expectations (3.10.2)
@ -472,6 +486,7 @@ DEPENDENCIES
scss_lint-govuk scss_lint-govuk
selenium-webdriver selenium-webdriver
simplecov simplecov
two_factor_authentication!
tzinfo-data tzinfo-data
uk_postcode uk_postcode
view_component view_component

13
app/models/admin_user.rb

@ -1,5 +1,16 @@
class AdminUser < ApplicationRecord class AdminUser < ApplicationRecord
# Include default devise modules. Others available are: # Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable # :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 end

15
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

26
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| %>
<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
<h1 class="govuk-heading-l">
<%= content_for(:title) %>
</h1>
<p class="govuk-body">We’ve sent you a text message with a security code.</p>
<%= f.govuk_number_field :code,
label: { text: "Security code" },
width: 5,
autofocus: true
%>
<%= f.govuk_submit "Submit" %>
</div>
</div>
<% end %>
<p class="govuk-body">
<%= govuk_link_to "Not received a text message?", "#" %>
</p>

11
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 # 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. # changed. Defaults to true, so a user is signed in automatically after changing a password.
# config.sign_in_after_change_password = true # 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 end

16
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

9
db/schema.rb

@ -23,6 +23,15 @@ ActiveRecord::Schema.define(version: 2022_01_31_123638) do
t.datetime "remember_created_at" t.datetime "remember_created_at"
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_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 end
create_table "case_logs", force: :cascade do |t| create_table "case_logs", force: :cascade do |t|

1
spec/factories/admin_user.rb

@ -2,6 +2,7 @@ FactoryBot.define do
factory :admin_user do factory :admin_user do
sequence(:email) { |i| "admin#{i}@example.com" } sequence(:email) { |i| "admin#{i}@example.com" }
password { "pAssword1" } password { "pAssword1" }
phone { "07563867654" }
created_at { Time.zone.now } created_at { Time.zone.now }
updated_at { Time.zone.now } updated_at { Time.zone.now }
end end

45
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
Loading…
Cancel
Save