Skip to content

Commit

Permalink
Merge pull request #2696 from internetee/verify-contacts
Browse files Browse the repository at this point in the history
Handling contact verifications
  • Loading branch information
vohmar authored Nov 19, 2024
2 parents 5b7f6ae + 6bd4c25 commit 5d454fe
Show file tree
Hide file tree
Showing 25 changed files with 898 additions and 7 deletions.
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

0 comments on commit 5d454fe

Please sign in to comment.