Browse Source
* Add 2FA to admin panel sign in * Test OTP code expiry * Validate phone number presence * Resend view * Autocomplete 2fa code * Update resend textpull/271/head
baarkerlounger
3 years ago
committed by
GitHub
19 changed files with 300 additions and 6 deletions
@ -1,3 +1,5 @@
|
||||
DB_USERNAME=postgres-user |
||||
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>" |
||||
|
@ -0,0 +1,5 @@
|
||||
class Auth::TwoFactorAuthenticationController < Devise::TwoFactorAuthenticationController |
||||
def show_resend |
||||
render "devise/two_factor_authentication/resend" |
||||
end |
||||
end |
@ -1,5 +1,18 @@
|
||||
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) |
||||
|
||||
validates :phone, presence: true, numericality: 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 |
||||
|
@ -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 |
@ -0,0 +1,23 @@
|
||||
<% content_for :title, "Resend security code" %> |
||||
|
||||
<% content_for :before_content do %> |
||||
<%= govuk_back_link( |
||||
text: 'Back', |
||||
href: 'javascript:history.back()', |
||||
) %> |
||||
<% end %> |
||||
|
||||
<%= form_with(url: resend_code_admin_user_two_factor_authentication_path, html: { method: :get }) 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">Text messages sometimes take a few minutes to arrive. If you do not receive the text message, you can request a new one.</p> |
||||
|
||||
<%= f.govuk_submit "Resend security code" %> |
||||
</div> |
||||
</div> |
||||
<% end %> |
@ -0,0 +1,27 @@
|
||||
<% 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, |
||||
autocomplete: 'one-time-code', |
||||
autofocus: true |
||||
%> |
||||
|
||||
<%= f.govuk_submit "Submit" %> |
||||
</div> |
||||
</div> |
||||
<% end %> |
||||
|
||||
<p class="govuk-body"> |
||||
<%= govuk_link_to "Not received a text message?", admin_two_factor_authentication_resend_path %> |
||||
</p> |
@ -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 |
@ -0,0 +1,75 @@
|
||||
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) |
||||
visit("/admin") |
||||
fill_in("admin_user[email]", with: admin.email) |
||||
fill_in("admin_user[password]", with: admin.password) |
||||
end |
||||
|
||||
it "authenticates successfully" do |
||||
expect(notify_client).to receive(:send_sms).with( |
||||
hash_including(phone_number: admin.phone, template_id: mfa_template_id), |
||||
) |
||||
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 |
||||
|
||||
context "but it is more than 5 minutes old" do |
||||
it "does not authenticate successfully" do |
||||
click_button("Login") |
||||
admin.update!(direct_otp_sent_at: 10.minutes.ago) |
||||
fill_in("code", with: otp) |
||||
click_button("Submit") |
||||
expect(page).to have_content("Check your phone") |
||||
end |
||||
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 |
||||
|
||||
context "when the 2FA code needs to be resent" do |
||||
before do |
||||
visit("/admin") |
||||
fill_in("admin_user[email]", with: admin.email) |
||||
fill_in("admin_user[password]", with: admin.password) |
||||
click_button("Login") |
||||
end |
||||
|
||||
it "displays the resend view" do |
||||
click_link("Not received a text message?") |
||||
expect(page).to have_button("Resend security code") |
||||
end |
||||
|
||||
it "send a new OTP code and redirects back to the 2FA view" do |
||||
click_link("Not received a text message?") |
||||
expect { click_button("Resend security code") }.to(change { admin.reload.direct_otp }) |
||||
expect(page).to have_current_path("/admin/two-factor-authentication") |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,52 @@
|
||||
require "rails_helper" |
||||
|
||||
RSpec.describe AdminUser, type: :model do |
||||
describe "#new" do |
||||
it "requires a phone number" do |
||||
expect { |
||||
described_class.create!( |
||||
email: "admin_test@example.com", |
||||
password: "password123", |
||||
) |
||||
}.to raise_error(ActiveRecord::RecordInvalid) |
||||
end |
||||
|
||||
it "requires a numerical phone number" do |
||||
expect { |
||||
described_class.create!( |
||||
email: "admin_test@example.com", |
||||
password: "password123", |
||||
phone: "string", |
||||
) |
||||
}.to raise_error(ActiveRecord::RecordInvalid) |
||||
end |
||||
end |
||||
|
||||
it "requires an email" do |
||||
expect { |
||||
described_class.create!( |
||||
password: "password123", |
||||
phone: "075752137", |
||||
) |
||||
}.to raise_error(ActiveRecord::RecordInvalid) |
||||
end |
||||
|
||||
it "requires a password" do |
||||
expect { |
||||
described_class.create!( |
||||
email: "admin_test@example.com", |
||||
phone: "075752137", |
||||
) |
||||
}.to raise_error(ActiveRecord::RecordInvalid) |
||||
end |
||||
|
||||
it "can be created" do |
||||
expect { |
||||
described_class.create!( |
||||
email: "admin_test@example.com", |
||||
password: "password123", |
||||
phone: "075752137", |
||||
) |
||||
}.to change(described_class, :count).by(1) |
||||
end |
||||
end |
Loading…
Reference in new issue