Skip to content

Commit

Permalink
One login account recovery feature
Browse files Browse the repository at this point in the history
When going live with one login, a candidate can sign up with a different
email address, not the magic link email address.

For this, we need to allow our candidates to recover their 'old'
account. This commit adds this feature.

We show a banner which the candidate can dismiss or they can click to
recover their old account. They will be asked to input their old email
and a code will be sent to their email. The code is encrypted with
bcrypt.
  • Loading branch information
CatalinVoineag committed Jan 10, 2025
1 parent 2dfeb64 commit 8ce7429
Show file tree
Hide file tree
Showing 31 changed files with 785 additions and 3 deletions.
3 changes: 3 additions & 0 deletions .erb_lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
EnableDefaultLinters: true
exclude:
- '**/node_modules/**/*'
- 'app/views/candidate_interface/account_recovery/new.html.erb'
- 'app/views/candidate_interface/application_choices/index.html.erb'
- 'app/views/candidate_interface/shared/_details.html.erb'
linters:
Rubocop:
enabled: true
Expand Down
46 changes: 46 additions & 0 deletions app/controllers/candidate_interface/account_recovery_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
module CandidateInterface
class AccountRecoveryController < CandidateInterfaceController
before_action :check_if_user_recovered
before_action :check_if_user_has_account_recovery_request

def new
@account_recovery = CandidateInterface::AccountRecoveryForm.new(current_candidate:)
end

def create
@account_recovery = CandidateInterface::AccountRecoveryForm.new(
current_candidate:,
code: permitted_params[:code],
)

if @account_recovery.call
terminate_session
start_new_session_for(
candidate: @account_recovery.old_candidate,
id_token_hint: @account_recovery.id_token_hint,
)

flash[:success] = I18n.t('.authentication.successful_account_recovery_html')
redirect_to candidate_interface_interstitial_path
else
render :new
end
end

private

def permitted_params
strip_whitespace(
params.require(:candidate_interface_account_recovery_form).permit(:code),
)
end

def check_if_user_recovered
redirect_to candidate_interface_details_path if current_candidate.account_recovery_status_recovered?
end

def check_if_user_has_account_recovery_request
redirect_to candidate_interface_details_path if current_candidate.account_recovery_request.nil?
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
module CandidateInterface
class AccountRecoveryRequestsController < CandidateInterfaceController
before_action :check_if_user_recovered

def new
@account_recovery_request = CandidateInterface::AccountRecoveryRequestForm
.build_from_candidate(current_candidate)
end

def create
@account_recovery_request = CandidateInterface::AccountRecoveryRequestForm.new(
current_candidate:,
previous_account_email_address: permitted_params[:previous_account_email_address],
)

if @account_recovery_request.save_and_email_candidate
redirect_to candidate_interface_account_recovery_new_path
else
render :new
end
end

private

def permitted_params
strip_whitespace(
params.require(:candidate_interface_account_recovery_request_form).permit(
:previous_account_email_address,
),
)
end

def check_if_user_recovered
redirect_to candidate_interface_details_path if current_candidate.account_recovery_status_recovered?
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module CandidateInterface
class DismissAccountRecoveryController < CandidateInterfaceController
def create
current_candidate.account_recovery_status_dismissed!
redirect_to candidate_interface_details_path
end
end
end
1 change: 1 addition & 0 deletions app/controllers/concerns/authentication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def start_new_session_for(candidate:, id_token_hint: nil)

def terminate_session
Current.session&.destroy
Current.session = nil
cookies.delete(:session_id)
reset_session
end
Expand Down
54 changes: 54 additions & 0 deletions app/forms/candidate_interface/account_recovery_form.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
module CandidateInterface
class AccountRecoveryForm
include ActiveModel::Model

attr_accessor :code
attr_reader :valid_account_recovery_request, :current_candidate, :old_candidate, :id_token_hint

validates :code, presence: true
validates :code, numericality: { only_integer: true }, length: { is: 6 }

validate :account_recovery, unless: -> { valid_account_recovery_request && old_candidate }
validate :previous_account_has_no_one_login, if: -> { valid_account_recovery_request && old_candidate }

def initialize(current_candidate:, code: nil)
self.code = code
@current_candidate = current_candidate
@id_token_hint = current_candidate.sessions.last.id_token_hint
end

def call
valid_request_code = current_candidate.account_recovery_request.codes.not_expired.find do |requested_code|
requested_code.authenticate_code(code)
end

@valid_account_recovery_request = valid_request_code&.account_recovery_request
@old_candidate = Candidate.find_by(email_address: valid_account_recovery_request&.previous_account_email_address)

return false unless valid?

ActiveRecord::Base.transaction do
old_candidate.account_recovery_status_recovered!
current_candidate.one_login_auth.update!(candidate: old_candidate)
current_candidate.reload
current_candidate.destroy!
end
end

def requested_new_code?
current_candidate.account_recovery_request.codes.not_expired.count > 1
end

private

def account_recovery
errors.add(:code, :invalid)
end

def previous_account_has_no_one_login
if old_candidate.one_login_auth.present?
errors.add(:code, :one_login_already_present)
end
end
end
end
60 changes: 60 additions & 0 deletions app/forms/candidate_interface/account_recovery_request_form.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
module CandidateInterface
class AccountRecoveryRequestForm
include ActiveModel::Model

attr_accessor :previous_account_email_address
attr_reader :current_candidate, :previous_candidate

validates :previous_account_email_address, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validate :email_different_from_current_candidate, if: -> { previous_candidate.present? }

def initialize(current_candidate:, previous_account_email_address: nil)
self.previous_account_email_address = previous_account_email_address&.downcase&.strip
@current_candidate = current_candidate
end

def self.build_from_candidate(candidate)
new(
current_candidate: candidate,
previous_account_email_address: candidate.account_recovery_request&.previous_account_email_address,
)
end

def save_and_email_candidate
@previous_candidate = Candidate.find_by(email_address: previous_account_email_address)
return false unless valid?

ActiveRecord::Base.transaction do
account_recovery_request = find_or_create_account_recovery_request

account_recovery_request_code = account_recovery_request.codes.create(
code: AccountRecoveryRequestCode.generate_code,
)

if Candidate.find_by(email_address: previous_account_email_address).present?
AccountRecoveryMailer.send_code(
email: previous_account_email_address,
code: account_recovery_request_code.code,
).deliver_later
else
true # We still want the user to progress to the next page
end
end
end

private

def find_or_create_account_recovery_request
AccountRecoveryRequest.find_by(
candidate: current_candidate,
previous_account_email_address:,
) || current_candidate.create_account_recovery_request(previous_account_email_address:)
end

def email_different_from_current_candidate
if current_candidate.email_address == previous_account_email_address
errors.add(:previous_account_email_address, :email_same_as_current_candidate)
end
end
end
end
15 changes: 15 additions & 0 deletions app/frontend/styles/_button.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.govuk-link-button {
padding: 0;

font-size: inherit;
color: $govuk-link-colour;

cursor: pointer;

background: none;
border: none;
}

form:has(.govuk-link-button) {
display: contents;
}
1 change: 1 addition & 0 deletions app/frontend/styles/application.scss
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ $govuk-new-link-styles: true;
@import "autocomplete";
@import "banner";
@import "box";
@import "button";
@import "notification_banner";
@import "card";
@import "conditions-list";
Expand Down
14 changes: 14 additions & 0 deletions app/mailers/account_recovery_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class AccountRecoveryMailer < ApplicationMailer
helper UtmLinkHelper

def send_code(email:, code:)
@code = code
@account_recovery_url = candidate_interface_account_recovery_new_url

mailer_options = {
to: email,
subject: t('.subject'),
}
notify_email(mailer_options)
end
end
4 changes: 3 additions & 1 deletion app/models/account_recovery_request.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class AccountRecoveryRequest < ApplicationRecord
belongs_to :candidate
has_many :account_recovery_request_codes, dependent: :destroy
has_many :codes, class_name: 'AccountRecoveryRequestCode', dependent: :destroy

normalizes :previous_account_email_address, with: ->(email) { email.downcase.strip }
end
9 changes: 9 additions & 0 deletions app/models/account_recovery_request_code.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
class AccountRecoveryRequestCode < ApplicationRecord
belongs_to :account_recovery_request
has_secure_password :code, validations: false

validates :code, presence: true

scope :not_expired, -> { where('created_at >= ?', 1.hour.ago) }

def self.generate_code
Array.new(6) { rand(0..9) }.join
end
end
8 changes: 8 additions & 0 deletions app/models/candidate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ class Candidate < ApplicationRecord

PUBLISHED_FIELDS = %w[email_address].freeze

enum :account_recovery_status, {
not_started: 'not_started',
recovered: 'recovered',
dismissed: 'dismissed',
}, prefix: true

after_create do
update!(candidate_api_updated_at: Time.zone.now)
end
Expand All @@ -37,6 +43,8 @@ class Candidate < ApplicationRecord
end
end

delegate :previous_account_email_address, to: :account_recovery_request, allow_nil: true

def touch_application_choices_and_forms
return unless application_choices.any?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,12 @@ def can_submit_more_applications?
CycleTimetable.can_add_course_choice?(application_form) # The apply deadline for this form has not passed
end

def show_account_recovery_banner?
return false if OneLogin.bypass?

application_form.candidate.account_recovery_status_not_started? && !submitted_applications?
end

private

def show_review_volunteering?
Expand Down
9 changes: 9 additions & 0 deletions app/views/account_recovery_mailer/send_code.text.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Hello

You requested a code to get your details back in Apply for teacher training.

^<%= @code %>

Enter this code in [Apply for teacher training](<%= @account_recovery_url %>). It will expire in 1 hour.

If you did not request a code you can ignore this email.
40 changes: 40 additions & 0 deletions app/views/candidate_interface/account_recovery/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<% page_title = if @account_recovery.requested_new_code?
I18n.t('page_titles.account_recovery_resend_email', email: current_candidate.previous_account_email_address)
else
I18n.t('page_titles.account_recovery', email: current_candidate.previous_account_email_address)
end %>

<% content_for :title, page_title %>
<% content_for :before_content do %>
<%= govuk_back_link(
text: 'Back',
href: new_candidate_interface_account_recovery_request_path,
) %>
<% end %>

<h1 class="govuk-heading-l">
<%= page_title %>
</h1>

<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
<%= form_with model: @account_recovery, url: candidate_interface_account_recovery_create_path do |f| %>
<%= f.govuk_error_summary %>
<%= f.govuk_text_field :code, label: { text: t('.form.code.label'), size: 'm' }, width: 20 %>

<%= f.govuk_submit %>
<% end %>

<%= button_to(
t('.form.request_a_new_code'),
candidate_interface_account_recovery_requests_path(
params: {
candidate_interface_account_recovery_request_form: {
previous_account_email_address: current_candidate.previous_account_email_address,
},
},
),
class: 'govuk-link govuk-link-button',
) %>
</div>
</div>
Loading

0 comments on commit 8ce7429

Please sign in to comment.