Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handling contact verifications #2696

Merged
merged 8 commits into from
Nov 19, 2024
Merged
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@
/node_modules
/import
ettevotja_rekvisiidid__lihtandmed.csv.zip
Dockerfile.dev
1 change: 1 addition & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def info_for_paper_trail
def comma_support_for(parent_key, key)
return if params[parent_key].blank?
return if params[parent_key][key].blank?

params[parent_key][key].sub!(/,/, '.')
end

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# frozen_string_literal: true

module Eeid
module Webhooks
# Controller for handling eeID identification requests webhook
class IdentificationRequestsController < ActionController::Base
skip_before_action :verify_authenticity_token

THROTTLED_ACTIONS = %i[create].freeze
include Shunter::Integration::Throttle

rescue_from Shunter::ThrottleError, with: :handle_throttle_error

# POST /eeid/webhooks/identification_requests
def create
return render_unauthorized unless ip_whitelisted?
return render_invalid_signature unless valid_hmac_signature?(request.headers['X-HMAC-Signature'])

contact = Contact.find_by_code(permitted_params[:reference])
poi = catch_poi
verify_contact(contact)
inform_registrar(contact, poi)
render json: { status: 'success' }, status: :ok
rescue StandardError => e
handle_error(e)
end

private

def permitted_params
params.permit(:identification_request_id, :reference, :client_id)
end

def render_unauthorized
render json: { error: "IPAddress #{request.remote_ip} not authorized" }, status: :unauthorized
end

def render_invalid_signature
render json: { error: 'Invalid HMAC signature' }, status: :unauthorized
end

def valid_hmac_signature?(hmac_signature)
secret = ENV['ident_service_client_secret']
computed_signature = OpenSSL::HMAC.hexdigest('SHA256', secret, request.raw_post)
ActiveSupport::SecurityUtils.secure_compare(computed_signature, hmac_signature)
end

def verify_contact(contact)
ref = permitted_params[:reference]
if contact&.ident_request_sent_at.present?
contact.update(verified_at: Time.zone.now, verification_id: permitted_params[:identification_request_id])
Rails.logger.info("Contact verified: #{ref}")
else
Rails.logger.error("Valid contact not found for reference: #{ref}")
end
end

def catch_poi
ident_service = Eeid::IdentificationService.new
response = ident_service.get_proof_of_identity(permitted_params[:identification_request_id])
raise StandardError, response[:error] if response[:error].present?

response[:data]
end

def inform_registrar(contact, poi)
email = contact&.registrar&.email
return unless email

RegistrarMailer.contact_verified(email: email, contact: contact, poi: poi)
.deliver_now
end

def ip_whitelisted?
allowed_ips = ENV['webhook_allowed_ips'].to_s.split(',').map(&:strip)

allowed_ips.include?(request.remote_ip) || Rails.env.development?
end

# Mock throttled_user using request IP
def throttled_user
# Create a mock user-like object with the request IP
OpenStruct.new(id: request.remote_ip, class: 'WebhookRequest')
end

def handle_error(error)
Rails.logger.error("Error handling webhook: #{error.message}")
render json: { error: error.message }, status: :internal_server_error
end

def handle_throttle_error
render json: { error: Shunter.default_error_message }, status: :bad_request
end
end
end
end
38 changes: 34 additions & 4 deletions app/controllers/repp/v1/contacts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
module Repp
module V1
class ContactsController < BaseController # rubocop:disable Metrics/ClassLength
before_action :find_contact, only: %i[show update destroy]
skip_around_action :log_request, only: :search
before_action :find_contact, only: %i[show update destroy verify download_poi]
skip_around_action :log_request, only: %i[search]

THROTTLED_ACTIONS = %i[index check search create show update destroy].freeze
THROTTLED_ACTIONS = %i[index check search create show update destroy verify download_poi].freeze
include Shunter::Integration::Throttle

api :get, '/repp/v1/contacts'
Expand Down Expand Up @@ -116,6 +116,35 @@ def destroy
render_success
end

api :POST, '/repp/v1/contacts/verify/:contact_code'
desc 'Generate and send identification request to a contact'
def verify
authorize! :verify, Epp::Contact
action = Actions::ContactVerify.new(@contact)

unless action.call
handle_non_epp_errors(@contact)
return
end

data = { contact: { code: params[:id] } }

render_success(data: data)
end

api :get, '/repp/v1/contacts/download_poi/:contact_code'
desc 'Get proof of identity pdf file for a contact'
def download_poi
authorize! :verify, Epp::Contact
ident_service = Eeid::IdentificationService.new
response = ident_service.get_proof_of_identity(@contact.verification_id)

send_data response[:data], filename: "proof_of_identity_#{@contact.verification_id}.pdf",
type: 'application/pdf', disposition: 'inline'
rescue Eeid::IdentError => e
handle_non_epp_errors(@contact, e.message)
end

private

def index_params
Expand Down Expand Up @@ -217,7 +246,8 @@ def contact_addr_params
end

def contact_params
params.require(:contact).permit(:id, :name, :email, :phone, :legal_document,
params.require(:contact).permit(:id, :name, :email, :phone, :legal_document, :verified,
:verification_link,
legal_document: %i[body type],
ident: [%i[ident ident_type ident_country_code]],
addr: [%i[country_code city street zip state]])
Expand Down
47 changes: 47 additions & 0 deletions app/interactions/actions/contact_verify.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
module Actions
class ContactVerify
attr_reader :contact

def initialize(contact)
@contact = contact
end

def call
if contact.verified_at.present?
contact.errors.add(:base, :verification_exists)
return
end

create_identification_request

return false if contact.errors.any?

commit
end

private

def create_identification_request
ident_service = Eeid::IdentificationService.new
response = ident_service.create_identification_request(request_payload)
ContactMailer.identification_requested(contact: contact, link: response['link']).deliver_now
rescue Eeid::IdentError => e
Rails.logger.error e.message
contact.errors.add(:base, :verification_error)
end

def request_payload
{
claims_required: [{
type: 'sub',
value: "#{contact.ident_country_code}#{contact.ident}"
}],
reference: contact.code
}
end

def commit
@contact.update(ident_request_sent_at: Time.zone.now)
end
end
end
8 changes: 8 additions & 0 deletions app/mailers/contact_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ def email_changed(contact:, old_email:)
mail(to: contact.email, bcc: old_email, subject: subject)
end

def identification_requested(contact:, link:)
@contact = contact
@verification_link = link

subject = default_i18n_subject(contact_code: contact.code)
mail(to: contact.email, subject: subject)
end

private

def address_processing
Expand Down
10 changes: 10 additions & 0 deletions app/mailers/registrar_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class RegistrarMailer < ApplicationMailer
helper ApplicationHelper

def contact_verified(email:, contact:, poi:)
@contact = contact
subject = 'Successful Contact Verification'
attachments['proof_of_identity.pdf'] = poi
mail(to: email, subject: subject)
end
end
1 change: 1 addition & 0 deletions app/models/ability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def epp # Registrar/api_user dynamic role
can(:delete, Epp::Contact) { |c, pw| c.registrar_id == @user.registrar_id || c.auth_info == pw }
can(:renew, Epp::Contact)
can(:transfer, Epp::Contact)
can(:verify, Epp::Contact)
can(:view_password, Epp::Contact) { |c, pw| c.registrar_id == @user.registrar_id || c.auth_info == pw }
end

Expand Down
110 changes: 110 additions & 0 deletions app/services/eeid/base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# frozen_string_literal: true

module Eeid
class IdentError < StandardError; end

# Base class for handling EEID identification requests.
class Base
BASE_URL = ENV['eeid_base_url']
TOKEN_ENDPOINT = '/api/auth/v1/token'

def initialize(client_id, client_secret)
@client_id = client_id
@client_secret = client_secret
@token = nil
end

def request_endpoint(endpoint, method: :get, body: nil)
Rails.logger.debug("Requesting endpoint: #{endpoint} with method: #{method}")
authenticate unless @token
request = build_request(endpoint, method, body)
response = send_request(request)
handle_response(response)
rescue StandardError => e
handle_error(e)
end

def authenticate
Rails.logger.debug("Authenticating with client_id: #{@client_id}")
uri = URI.parse("#{BASE_URL}#{TOKEN_ENDPOINT}")
request = build_auth_request(uri)

response = send_auth_request(uri, request)
handle_auth_response(response)
end

private

def build_auth_request(uri)
request = Net::HTTP::Post.new(uri)
request['Authorization'] = "Basic #{Base64.strict_encode64("#{@client_id}:#{@client_secret}")}"
request
end

def send_auth_request(uri, request)
Net::HTTP.start(uri.hostname, uri.port, use_ssl: ssl_enabled?) do |http|
Rails.logger.debug("Sending authentication request to #{uri}")
http.request(request)
end
end

def handle_auth_response(response)
raise IdentError, "Authentication failed: #{response.body}" unless response.is_a?(Net::HTTPSuccess)

@token = JSON.parse(response.body)['access_token']
Rails.logger.debug('Authentication successful, token received')
end

def build_request(endpoint, method, body)
uri = URI.parse("#{BASE_URL}#{endpoint}")
request = create_request(uri, method)
request['Authorization'] = "Bearer #{@token}"
request.body = body.to_json if body
request.content_type = 'application/json'

request
end

def create_request(uri, method)
case method.to_sym
when :get
Net::HTTP::Get.new(uri)
when :post
Net::HTTP::Post.new(uri)
else
raise IdentError, "Unsupported HTTP method: #{method}"
end
end

def send_request(request)
uri = URI.parse(request.uri.to_s)
Net::HTTP.start(uri.hostname, uri.port, use_ssl: ssl_enabled?) do |http|
Rails.logger.debug("Sending #{request.method} request to #{uri} with body: #{request.body}")
http.request(request)
end
end

def handle_response(response)
case response['content-type']
when 'application/pdf', 'application/octet-stream'
parsed_response = { data: response.body, message: response['content-disposition'] }
when %r{application/json}
parsed_response = JSON.parse(response.body).with_indifferent_access
else
raise IdentError, 'Unsupported content type'
end

raise IdentError, parsed_response['error'] unless response.is_a?(Net::HTTPSuccess)

parsed_response
end

def handle_error(exception)
raise IdentError, exception.message
end

def ssl_enabled?
!%w[test].include?(Rails.env)
end
end
end
25 changes: 25 additions & 0 deletions app/services/eeid/identification_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

module Eeid
# This class handles identification services.
class IdentificationService < Base
CLIENT_ID = ENV['ident_service_client_id']
CLIENT_SECRET = ENV['ident_service_client_secret']

def initialize
super(CLIENT_ID, CLIENT_SECRET)
end

def create_identification_request(request_params)
request_endpoint('/api/ident/v1/identification_requests', method: :post, body: request_params)
end

def get_identification_request(id)
request_endpoint("/api/ident/v1/identification_requests/#{id}")
end

def get_proof_of_identity(id)
request_endpoint("/api/ident/v1/identification_requests/#{id}/proof_of_identity")
end
end
end
Loading
Loading