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_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>" |
||||||
|
@ -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 |
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) |
||||||
|
|
||||||
|
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 |
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