Skip to content

Commit

Permalink
[CAPT-1781] Revisit OTP (#3333)
Browse files Browse the repository at this point in the history
* move one time password lib => models

* otp issuer now keyword argument

* email verification now uses non shared secret

* mobile verification now uses non shared secret

* remove OTP shared SECRET

---------

Co-authored-by: Steven Lorek <[email protected]>
  • Loading branch information
asmega and slorek authored Oct 23, 2024
1 parent 7eb9c54 commit cd54613
Show file tree
Hide file tree
Showing 20 changed files with 62 additions and 40 deletions.
2 changes: 1 addition & 1 deletion app/controllers/claims_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ def correct_journey_for_claim_in_progress?
def handle_magic_link
return unless params[:code] && params[:email]

otp = OneTimePassword::Validator.new(params[:code], secret: ENV.fetch("EY_MAGIC_LINK_SECRET") + params[:email])
otp = OneTimePassword::Validator.new(params[:code], secret: ROTP::Base32.encode(ENV.fetch("EY_MAGIC_LINK_SECRET") + params[:email]))
if otp.valid?
journey_session.answers.assign_attributes(email_address: params[:email])
journey_session.answers.assign_attributes(email_verified: true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def email_verification_before_update

def personal_details_after_form_save_success
answers.reminder_id = current_reminder.to_param
answers.email_verification_secret = ROTP::Base32.random
journey_session.save!
try_mailer { send_verification_email } || return
redirect_to_next_slug
Expand Down Expand Up @@ -42,7 +43,7 @@ def inject_sent_one_time_password_at_into_the_form
end

def send_verification_email
otp = OneTimePassword::Generator.new
otp = OneTimePassword::Generator.new(secret: answers.email_verification_secret)
ReminderMailer.email_verification(current_reminder, otp.code).deliver_now
session[:sent_one_time_password_at] = Time.now
end
Expand Down
7 changes: 6 additions & 1 deletion app/forms/email_address_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def save
journey_session.answers.assign_attributes(
email_address: email_address,
email_verified: email_verified,
email_verification_secret: otp_secret,
sent_one_time_password_at: Time.now
)

Expand All @@ -43,7 +44,11 @@ def email_verified
answers.email_verified
end

def otp_secret
@otp_secret ||= ROTP::Base32.random
end

def otp_code
@otp_code ||= OneTimePassword::Generator.new.code
@otp_code ||= OneTimePassword::Generator.new(secret: otp_secret).code
end
end
3 changes: 2 additions & 1 deletion app/forms/email_verification_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ def sent_one_time_password_must_be_valid
def otp_must_be_valid
otp = OneTimePassword::Validator.new(
one_time_password,
sent_one_time_password_at
sent_one_time_password_at,
secret: journey_session.answers.email_verification_secret
)

errors.add(:one_time_password, otp.warning) unless otp.valid?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ def sent_one_time_password_must_be_valid
def otp_must_be_valid
otp = OneTimePassword::Validator.new(
one_time_password,
sent_one_time_password_at
sent_one_time_password_at,
secret: journey_session.answers.email_verification_secret
)

errors.add(:one_time_password, otp.warning) unless otp.valid?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def email_verified
end

def otp_code(email_address)
@otp_code ||= OneTimePassword::Generator.new(secret: ENV.fetch("EY_MAGIC_LINK_SECRET") + email_address).code
@otp_code ||= OneTimePassword::Generator.new(secret: ROTP::Base32.encode(ENV.fetch("EY_MAGIC_LINK_SECRET") + email_address)).code
end
end
end
Expand Down
9 changes: 7 additions & 2 deletions app/forms/mobile_number_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def save
journey_session.answers.assign_attributes(
mobile_number: mobile_number,
mobile_verified: nil,
mobile_verification_secret: otp_secret,
sent_one_time_password_at: sent_one_time_password_at
)

Expand All @@ -38,17 +39,21 @@ def save

def send_sms_message
if Rails.env.development?
Rails.logger.info("\n\nSMS CODE: #{OneTimePassword::Generator.new.code}\n")
Rails.logger.info("\n\nSMS CODE: #{OneTimePassword::Generator.new(secret: otp_secret).code}\n")
return true
end

NotifySmsMessage.new(
phone_number: mobile_number,
template_id: NotifySmsMessage::OTP_PROMPT_TEMPLATE_ID,
personalisation: {otp: OneTimePassword::Generator.new.code}
personalisation: {otp: OneTimePassword::Generator.new(secret: otp_secret).code}
).deliver!
end

def otp_secret
@otp_secret ||= ROTP::Base32.random
end

def mobile_number_changed?
mobile_number != answers.mobile_number
end
Expand Down
3 changes: 2 additions & 1 deletion app/forms/mobile_verification_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ def save
def otp_validate
otp = OneTimePassword::Validator.new(
one_time_password,
answers.sent_one_time_password_at
answers.sent_one_time_password_at,
secret: journey_session.answers.mobile_verification_secret
)

errors.add(:one_time_password, otp.warning) unless otp.valid?
Expand Down
4 changes: 4 additions & 0 deletions app/mailers/claim_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ def email_verification(claim, one_time_password)
validity_duration: one_time_password_validity_duration
}

if Rails.env.development?
Rails.logger.info("\n\nEmail verification code: #{@one_time_password}\n")
end

send_mail(OTP_EMAIL_NOTIFY_TEMPLATE_ID, personalisation)
end

Expand Down
2 changes: 2 additions & 0 deletions app/models/journeys/session_answers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ class SessionAnswers
attribute :provide_mobile_number, :boolean
attribute :mobile_number, :string
attribute :email_verified, :boolean
attribute :email_verification_secret, :string
attribute :mobile_verified, :boolean
attribute :mobile_verification_secret, :string
attribute :hmrc_bank_validation_succeeded, :boolean
attribute :hmrc_bank_validation_responses, default: []
attribute :logged_in_with_tid, :boolean
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
require "rotp"

module OneTimePassword
class Base
DRIFT = 900
LENGTH = 6
ISSUER = "Claim Additional Payments for Teaching"
SECRET = ROTP::Base32.random.freeze

def rotp
@rotp ||= ROTP::TOTP
Expand All @@ -13,9 +13,5 @@ def rotp
private

attr_reader :issuer, :secret

def encode_secret(secret)
ROTP::Base32.encode(secret) if secret
end
end
end
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
module OneTimePassword
class Generator < Base
def initialize(issuer = nil, secret: nil)
def initialize(secret:, issuer: nil)
@issuer = issuer || ISSUER
@secret = encode_secret(secret) || SECRET
@secret = secret
end

def code
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
module OneTimePassword
class Validator < Base
def initialize(code, generated_at = nil, secret: nil)
def initialize(code, generated_at = nil, secret:)
@code = code
@generated_at = generated_at
@secret = encode_secret(secret) || SECRET
@secret = secret
end

def valid?
Expand Down
1 change: 0 additions & 1 deletion config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
require_relative "../lib/dfe_sign_in"
require_relative "../lib/hmrc"
require_relative "../lib/ordnance_survey"
require_relative "../lib/one_time_password"
require_relative "../lib/dqt"
require_relative "../lib/notify_sms_message"

Expand Down
3 changes: 0 additions & 3 deletions lib/one_time_password.rb

This file was deleted.

11 changes: 7 additions & 4 deletions spec/forms/email_verification_form_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

RSpec.describe EmailVerificationForm do
shared_examples "email_verification" do |journey|
let(:secret) { ROTP::Base32.random }

let(:params) do
ActionController::Parameters.new(
claim: {
Expand All @@ -14,6 +16,7 @@
create(
:"#{journey::I18N_NAMESPACE}_session",
answers: {
email_verification_secret: secret,
sent_one_time_password_at: sent_one_time_password_at
}
)
Expand Down Expand Up @@ -61,27 +64,27 @@
end

context "when the code has expired" do
let(:one_time_password) { OneTimePassword::Generator.new.code }
let(:one_time_password) { OneTimePassword::Generator.new(secret:).code }
let(:sent_one_time_password_at) { 30.minutes.ago }
it { is_expected.not_to be_valid }
end

context "when the code generation timestamp is missing" do
let(:one_time_password) { OneTimePassword::Generator.new.code }
let(:one_time_password) { OneTimePassword::Generator.new(secret:).code }
let(:sent_one_time_password_at) { nil }
it { is_expected.not_to be_valid }
end

context "when correct code" do
let(:one_time_password) { OneTimePassword::Generator.new.code }
let(:one_time_password) { OneTimePassword::Generator.new(secret:).code }
let(:sent_one_time_password_at) { Time.now }
it { is_expected.to be_valid }
end
end
end

describe "#save" do
let(:one_time_password) { OneTimePassword::Generator.new.code }
let(:one_time_password) { OneTimePassword::Generator.new(secret:).code }
let(:sent_one_time_password_at) { Time.now }

before { form.save }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
described_class.new(reminder: reminder, journey:, journey_session:, params:)
end

let(:secret) { ROTP::Base32.random }
let(:journey) { Journeys::AdditionalPaymentsForTeaching }
let(:journey_session) { build(:additional_payments_session) }
let(:answers) do
{email_verification_secret: secret}
end
let(:journey_session) { build(:additional_payments_session, answers:) }
let(:reminder) { Reminder.create! }
let(:slug) { "email-verification" }
let(:params) { ActionController::Parameters.new({slug:, form: form_params}) }
Expand All @@ -22,7 +26,7 @@
context "valid params" do
let(:form_params) do
{
"one_time_password" => OneTimePassword::Generator.new.code,
"one_time_password" => OneTimePassword::Generator.new(secret:).code,
"sent_one_time_password_at" => Time.now
}
end
Expand All @@ -36,7 +40,7 @@
context "invalid params" do
let(:form_params) do
{
"one_time_password" => OneTimePassword::Generator.new.code,
"one_time_password" => OneTimePassword::Generator.new(secret:).code,
"sent_one_time_password_at" => ""
}
end
Expand Down
9 changes: 6 additions & 3 deletions spec/forms/mobile_verification_form_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

RSpec.describe MobileVerificationForm do
shared_examples "mobile_verification" do |journey|
let(:secret) { ROTP::Base32.random }

let(:journey_session) do
create(
:"#{journey::I18N_NAMESPACE}_session",
answers: {
mobile_verification_secret: secret,
sent_one_time_password_at: sent_one_time_password_at
}
)
Expand Down Expand Up @@ -61,21 +64,21 @@
end

context "when the code has expired" do
let(:one_time_password) { OneTimePassword::Generator.new.code }
let(:one_time_password) { OneTimePassword::Generator.new(secret:).code }
let(:sent_one_time_password_at) { 30.minutes.ago }
it { is_expected.not_to be_valid }
end

context "when correct code" do
let(:one_time_password) { OneTimePassword::Generator.new.code }
let(:one_time_password) { OneTimePassword::Generator.new(secret:).code }
let(:sent_one_time_password_at) { Time.now }
it { is_expected.to be_valid }
end
end
end

describe "#save" do
let(:one_time_password) { OneTimePassword::Generator.new.code }
let(:one_time_password) { OneTimePassword::Generator.new(secret:).code }
let(:sent_one_time_password_at) { Time.now }

before { form.save }
Expand Down
7 changes: 3 additions & 4 deletions spec/lib/one_time_password/generator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
RSpec.describe OneTimePassword::Generator do
let(:totp) { instance_double ROTP::TOTP, now: one_time_passcode }
let(:one_time_passcode) { 123456 }
let(:secret) { ROTP::Base32.random }

before do
allow(ROTP::TOTP).to receive(:new).and_return(totp)
end

describe "#code" do
subject { described_class.new.code }
subject { described_class.new(secret:).code }

it "generates a new code" do
expect(subject).to eq one_time_passcode
Expand All @@ -19,10 +20,8 @@
context "specifying a secret" do
subject { described_class.new(secret: secret).code }

let(:secret) { "somesecret" }

it "uses the secret" do
expect(ROTP::TOTP).to receive(:new).with(ROTP::Base32.encode(secret), anything).and_return(totp)
expect(ROTP::TOTP).to receive(:new).with(secret, anything).and_return(totp)
subject
end

Expand Down
9 changes: 5 additions & 4 deletions spec/lib/one_time_password/validator_spec.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
require "rails_helper"

RSpec.describe OneTimePassword::Validator do
subject { described_class.new(one_time_passcode, generated_at) }
subject { described_class.new(one_time_passcode, generated_at, secret:) }

let!(:one_time_passcode) { OneTimePassword::Generator.new.code }
let(:secret) { ROTP::Base32.random }
let!(:one_time_passcode) { OneTimePassword::Generator.new(secret:).code }
let!(:generated_at) { Time.now }

context "with a valid code" do
Expand Down Expand Up @@ -78,7 +79,7 @@
end

context "when generated_at is not specified" do
subject { described_class.new(one_time_passcode) }
subject { described_class.new(one_time_passcode, secret:) }

it { is_expected.to_not be_valid }

Expand All @@ -89,7 +90,7 @@
end

context "not specifying generated_at" do
subject { described_class.new(one_time_passcode) }
subject { described_class.new(one_time_passcode, secret:) }

context "with a valid code" do
it { is_expected.to be_valid }
Expand Down

0 comments on commit cd54613

Please sign in to comment.