Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ gem 'net-http', '>= 0.4.0'
gem 'sprockets-rails', '>= 3.5.1'

gem 'connection_pool', '< 3'
gem 'notifications-ruby-client'

group :development, :test do
gem 'brakeman', require: false
Expand Down
7 changes: 7 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,10 @@ GEM
nokogiri (1.19.2)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.2-x86_64-linux-gnu)
racc (~> 1.4)
notifications-ruby-client (6.3.0)
jwt (>= 1.5, < 4)
oauth2 (2.0.18)
faraday (>= 0.17.3, < 4.0)
jwt (>= 1.0, < 4.0)
Expand Down Expand Up @@ -628,7 +632,9 @@ GEM
zeitwerk (2.7.5)

PLATFORMS
aarch64-linux-musl
ruby
x86_64-linux

DEPENDENCIES
aasm
Expand Down Expand Up @@ -666,6 +672,7 @@ DEPENDENCIES
mutex_m (~> 0.3.0)
net-http (>= 0.4.0)
nokogiri (>= 1.18.9)
notifications-ruby-client
omniauth-google-oauth2 (>= 1.2.2)
omniauth-rails_csrf_protection (~> 2.0, >= 2.0.0)
parslet
Expand Down
23 changes: 22 additions & 1 deletion app/controllers/admin/users_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
class Admin::UsersController < AdminController
before_action :find_user, only: %i[show edit update reactivate_user confirm_delete confirm_reactivate destroy]
before_action :find_user,
only: %i[show edit update reactivate_user confirm_delete confirm_reactivate destroy edit_email
update_email]

def index
@users = User.search(params[:search]).page(params[:page])
Expand Down Expand Up @@ -76,6 +78,25 @@ def update
redirect_to admin_user_path(@user)
end

def edit_email; end

def update_email
new_email = user_params[:email]
if new_email == @user.email
flash[:notice] = I18n.t('errors.messages.error_updating_same_user_email_in_auth0')
redirect_to admin_user_path(@user)
return
end

result = UpdateUserEmail.new(@user, new_email)
if result.call
flash[:notice] = I18n.t('errors.messages.success_updating_user_email_in_auth0')
elsif result.failure?
flash[:alert] = I18n.t('errors.messages.error_updating_user_email_in_auth0')
end
redirect_to admin_user_path(@user)
end

def confirm_delete; end

def confirm_reactivate; end
Expand Down
97 changes: 97 additions & 0 deletions app/controllers/v1/email_verifications_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
module V1
class EmailVerificationsController < ApiController
skip_before_action :reject_without_user!, only: [:verification]

def create
user = User.find_by(auth_id: current_auth_id)
new_email = params.dig('_jsonapi', 'new_email')

verification_request = verification_request(user, new_email)

if verification_request.save

SendEmailVerificationJob.perform_later(
new_email: new_email,
verification_url: verification_request.verification_url,
person_name: user.name
)

render jsonapi: verification_request, status: :ok, context: { request: request }
else
render jsonapi_errors: verification_request.errors, status: :unprocessable_entity
end
end

def verification
token = find_token_params || (return render_invalid_token_error)
email_change_request = EmailChangeRequest.find_by(token: token) || (return render_invalid_token_error)
update_user_email = update_email(email_change_request)
email_change_request.update(used_at: Time.current, active: false)

return render jsonapi: email_change_request.user, status: :ok if update_user_email&.call

error_body = verification_request&.errors ||
{ error: I18n.t('email_verifications.invalid_or_expired_token') }
render jsonapi_errors: error_body, status: :unprocessable_entity
end

def active
user = User.find_by(auth_id: current_auth_id)
unless user
render jsonapi_errors: { error: 'User not found' }, status: :not_found
return
end

verification = EmailChangeRequest.where(user: user, active: true).order(created_at: :desc).first

if verification
render jsonapi: verification, status: :ok, context: { request: request }
else
render jsonapi_errors: { error: 'No active email verification found' }, status: :not_found
end
end

def cancel_pending_email_change
user = User.find_by(auth_id: current_auth_id)
unless user
render jsonapi_errors: { error: 'User not found' }, status: :not_found
return
end

EmailChangeRequest.where(user: user, active: true).find_each do |record|
record.update(active: false)
end

head :no_content
end

private

def verification_request(user, new_email)
expires_at = 2.days.from_now

EmailChangeRequest.where(user: user, active: true).find_each do |record|
record.update(active: false)
end

EmailChangeRequest.new(
user: user,
new_email: new_email,
expires_at: expires_at
)
end

def update_email(email_change_request)
UpdateUserEmail.new(email_change_request.user, email_change_request.new_email)
end

def render_invalid_token_error
render jsonapi_errors: ApiMessage.new({ message: I18n.t('email_verifications.invalid_or_expired_token') }),
status: :unprocessable_entity
end

def find_token_params
params.dig('_jsonapi', 'token')
end
end
end
56 changes: 56 additions & 0 deletions app/controllers/v1/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,60 @@ def update_name
status: :unprocessable_entity
end
end

def update_email
user = User.find_by(auth_id: current_auth_id)
new_email = params.dig('_jsonapi', 'email')

if new_email.blank?
render jsonapi_errors: { email: ['is required'] }, status: :unprocessable_entity
return
end

verification_request = verification_request(user, new_email)

if verification_request.save
notify_result = SendEmailVerificationJob.perform_later(
new_email: new_email,
verification_url: verification_request.verification_url,
person_name: user.name
)

if notify_result == false
render jsonapi_errors: { notify: ['Failed to send email'] }, status: :unprocessable_entity
return
end

render jsonapi: verification_request, status: :ok, context: { request: request }
else
render jsonapi_errors: verification_request.errors.presence || { base: ['Failed to save verification request'] },
status: :unprocessable_entity
end
end

def verification_request(user, new_email)
token = SecureRandom.hex(24)
expires_at = 2.days.from_now

EmailChangeRequest.where(user: user, active: true).find_each do |record|
record.update(active: false)
end

EmailChangeRequest.new(
user: user,
new_email: new_email,
token: token,
expires_at: expires_at
)
end

def user_auth_logs
user = User.find_by!(auth_id: current_auth_id)
auth_logs = UserLogsInAuth0.new(user: user).call
objects = auth_logs.map do |log|
OpenStruct.new(log.merge('id' => SecureRandom.uuid))
end

render jsonapi: objects, class: { OpenStruct: SerializableUserAuthLog }, status: :ok
end
end
17 changes: 17 additions & 0 deletions app/jobs/send_confirm_email_verification_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class SendConfirmEmailVerificationJob < ApplicationJob
queue_as :default

TEMPLATE_ID = '59cda9d1-9a70-4196-8b50-fba71e410765'.freeze

def perform(new_email:, person_name: nil)
Notify.new.send_email(
template_id: TEMPLATE_ID,
email: new_email,
vars: {
login_url: ENV['FRONTEND_URL'],
email_address: new_email,
person_name: person_name
}
)
end
end
17 changes: 17 additions & 0 deletions app/jobs/send_email_verification_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class SendEmailVerificationJob < ApplicationJob
queue_as :default

TEMPLATE_ID = '26164cdb-915b-4e13-97e9-d4a42367f068'.freeze

def perform(new_email:, verification_url:, person_name: nil)
Notify.new.send_email(
template_id: TEMPLATE_ID,
email: new_email,
vars: {
verify_url: verification_url,
new_email: new_email,
person_name: person_name
}
)
end
end
26 changes: 26 additions & 0 deletions app/models/api_message.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
class ApiMessage
def initialize(attributes = {})
@attributes = attributes.transform_keys(&:to_sym)
@attributes[:id] ||= SecureRandom.uuid
end

def id
@attributes[:id]
end

def as_json(*_args)
@attributes
end

def method_missing(method, *args, &block)
if @attributes.key?(method)
@attributes[method]
else
super
end
end

def respond_to_missing?(method, include_private = false)
@attributes.key?(method) || super
end
end
39 changes: 39 additions & 0 deletions app/models/email_change_request.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
class EmailChangeRequest < ApplicationRecord
has_secure_token :token, length: 64
belongs_to :user

validates :new_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :expires_at, presence: true
validate :new_email_not_taken
validate :email_not_verified
validate :verifiable, on: :update

def self.verified_email(user, new_email)
where(user: user, new_email: new_email, active: false)
.where.not(used_at: nil)
.order(used_at: :desc)
.first
end

def email_not_verified
return if self.class.verified_email(user, new_email).blank?

errors.add(:new_email, I18n.t('email_verifications.already_verified'))
end

def verification_url
"#{ENV['FRONTEND_URL']}/email/verification/#{token}"
end

def verifiable
used_at.nil? && expires_at > Time.current
end

private

def new_email_not_taken
return unless User.exists?(email: new_email)

errors.add(:new_email, 'is already taken')
end
end
3 changes: 3 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
require './lib/auth0_api'

class User < ApplicationRecord
has_one :email_change_request, lambda {
where(active: true).order(created_at: :desc)
}, class_name: 'EmailChangeRequest', dependent: :destroy, inverse_of: :user
has_many :memberships, dependent: :destroy
has_many :suppliers, through: :memberships
has_many :submissions, through: :suppliers
Expand Down
9 changes: 9 additions & 0 deletions app/serializable/serializable_api_message.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class SerializableApiMessage < JSONAPI::Serializable::Resource
type 'users'

id { @object.id }

attribute :attributes do
@object.as_json.except(:id)
end
end
4 changes: 4 additions & 0 deletions app/serializable/serializable_email_change_request.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class SerializableEmailChangeRequest < JSONAPI::Serializable::Resource
type 'email_change_requests'
attributes :new_email, :expires_at, :token, :verification_url
end
13 changes: 13 additions & 0 deletions app/serializable/serializable_user_auth_log.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# app/serializable/serializable_user_auth_log.rb
class SerializableUserAuthLog < JSONAPI::Serializable::Resource
type 'user_auth_logs'

attributes :date, :type, :description, :connection, :connection_id, :client_id, :client_name,
:ip, :client_ip, :user_agent, :details, :hostname, :user_id, :user_name,
:auth0_client, :strategy, :strategy_type, :environment_name, :log_id, :tenant_name,
:_id, :isMobile, :location_info

attribute :event_schema do
@object['$event_schema']
end
end
18 changes: 18 additions & 0 deletions app/services/notify.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
require 'notifications/client'

class Notify
def initialize
@client = Notifications::Client.new(ENV['GOV_NOTIFY_API_KEY'])
end

def send_email(template_id:, email:, vars: {})
@client.send_email(
email_address: email,
template_id: template_id,
personalisation: vars
)
rescue Notifications::Client::RequestError => e
Rails.logger.error "GOV.UK Notify Error: #{e.message}"
false
end
end
Loading
Loading