File Coverage

lib/Experian/IDAuth.pm
Criterion Covered Total %
statement 16 18 88.8
branch n/a
condition n/a
subroutine 6 6 100.0
pod n/a
total 22 24 91.6


line stmt bran cond sub pod time code
1             package Experian::IDAuth;
2 1     1   29413 use strict;
  1         3  
  1         32  
3 1     1   7 use warnings;
  1         2  
  1         51  
4              
5             our $VERSION = '1.5';
6              
7 1     1   877 use Locale::Country;
  1         63883  
  1         82  
8 1     1   7033 use Path::Tiny;
  1         20357  
  1         79  
9 1     1   1203 use WWW::Mechanize;
  1         214035  
  1         44  
10 1     1   470 use XML::Simple;
  0            
  0            
11             use XML::Twig;
12             use SOAP::Lite;
13             use IO::Socket::SSL 'SSL_VERIFY_NONE';
14              
15             sub new {
16             my ( $class, %args ) = @_;
17             my $obj = bless {}, $class;
18             $obj->set( $obj->defaults, %args );
19             return $obj;
20             }
21              
22             sub defaults {
23             my $self = shift;
24             return (
25             username => 'experian_user',
26             password => '?',
27             members_url => 'https://proveid.experian.com',
28             api_uri => 'http://corpwsdl.oneninetwo',
29             api_proxy => 'https://xml.proveid.experian.com/IDSearch.cfc',
30             folder => '/tmp/proveid',
31              
32             # if you're using a logger,
33             #logger => Log::Log4per::get_logger,
34             );
35             }
36              
37             sub set {
38             my ( $self, %args ) = @_;
39             $self->{$_} = $args{$_} for keys %args;
40             return $self;
41             }
42              
43             sub logi {
44             my ( $self, $msg ) = @_;
45              
46             if ( $self->{logger} ) {
47             return $self->{logger}->info($msg);
48             }
49              
50             return;
51             }
52              
53             sub get_result {
54             my $self = shift;
55             $self->_do_192_authentication || return;
56             for ( $self->{search_option} ) {
57             /ProveID_KYC/ && return $self->_get_result_proveid;
58             /CheckID/ && return $self->_get_result_checkid;
59             die "invalid search_option $_";
60             }
61             return;
62             }
63              
64             sub save_pdf_result {
65              
66             my $self = shift;
67              
68             # Parse and convert the result to hash
69             my $result = $self->_xml_as_hash || die 'no xml result in place';
70              
71             # 192 reference which we should pass to 192 to download the result
72             my $our_ref = $result->{OurReference} || do {
73             $self->logi("No 'OurReference'; invalid save-pdf request");
74             return;
75             };
76              
77             my $url = $self->{members_url};
78             my $mech = WWW::Mechanize->new();
79             $mech->ssl_opts(
80             verify_hostname => 0,
81             SSL_verify_mode => SSL_VERIFY_NONE
82             );
83              
84             eval {
85              
86             # Get the login page
87             $mech->get("$url/signin/");
88              
89             # Login to the members environments
90             $mech->submit_form(
91             with_fields => {
92             login => $self->{username},
93             password => $self->{password},
94             }
95             );
96              
97             # Download pdf result on given reference number
98             $mech->get("$url/archive/index.cfm?event=archive.pdf&id=$our_ref");
99             1;
100             } || do {
101             my $err = $@;
102             $self->logi("errors downloading pdf: $err");
103             return;
104             };
105              
106             # Save the result to our pdf path
107             my $folder_pdf = "$self->{folder}/pdf";
108              
109             # make directory if necessary
110             if ( not -d $folder_pdf ) {
111             Path::Tiny::path($folder_pdf)->mkpath;
112             }
113             my $file_pdf = $self->_pdf_report_filename;
114             $mech->save_content($file_pdf);
115              
116             # Check if the downloaded file is a pdf.
117             my $file_type = qx(file $file_pdf);
118             if ( $file_type !~ /PDF/ ) {
119             $self->logi("discarding downloaded file $file_pdf, not a pdf!");
120             unlink $file_pdf;
121             return;
122             }
123              
124             return 1;
125             }
126              
127             sub has_downloaded_pdf {
128             my $self = shift;
129             my $file_pdf = $self->_pdf_report_filename;
130             -e ($file_pdf) || return;
131             my $file_type = qx(file $file_pdf);
132             return $file_type =~ /PDF/;
133             }
134              
135             sub has_done_request {
136             my $self = shift;
137             return -f $self->_xml_report_filename;
138             }
139              
140             sub get_192_xml_report {
141             my $self = shift;
142             return Path::Tiny::path( $self->_xml_report_filename )->slurp;
143             }
144              
145             sub valid_country {
146             my $self = shift;
147             my $country = shift;
148             for (
149              
150             # To make CheckID work well for non-UK countries we need to pass
151             # in drivers license, Passport MRZ, national ID number
152             #qw( ad at au be ca ch cz dk es fi fr gb gg hu ie im it je lu nl no pt se sk us )
153             qw ( gb )
154             )
155             {
156             return 1 if $country eq $_;
157             }
158             return;
159             }
160              
161             sub _build_request {
162             my $self = shift;
163              
164             $self->{request_as_xml} =
165             ''
166             . ( $self->_build_authentication_tag )
167             . ( $self->_build_country_code_tag || return )
168             . ( $self->_build_person_tag || return )
169             . ( $self->_build_addresses_tag )
170             . ( $self->_build_telephones_tag )
171             . ( $self->_build_search_reference_tag || return )
172             . ( $self->_build_search_option_tag )
173             . '';
174              
175             return 1;
176             }
177              
178             # Send the given SOAP request to 192.com
179             sub _send_request {
180             my $self = shift;
181              
182             my $request = $self->{request_as_xml} || die 'needs request';
183              
184             # Hide password
185             ( my $request1 = $request ) =~
186             s/\.+\<\/Password\>/\XXXXXXX<\/Password\>/;
187              
188             # Log request
189             $self->logi( "REQUEST: " . $self->{client_id} . " : $request1" );
190              
191             # Create soap object
192             my $soap =
193             SOAP::Lite->readable(1)->uri( $self->{api_uri} )
194             ->proxy( $self->{api_proxy} );
195              
196             $soap->transport->ssl_opts(
197             verify_hostname => 0,
198             SSL_verify_mode => SSL_VERIFY_NONE
199             );
200             $soap->transport->timeout(60);
201              
202             # Do it
203             my $som = $soap->search($request);
204             if ( $som->fault ) {
205             $self->logi( "ERRTEXT: " . $som->fault->faultstring );
206             return;
207             }
208              
209             my $result = $som->result;
210             $self->logi("RESULTS: $result");
211             $self->{result_as_xml} = $result;
212              
213             return 1;
214             }
215              
216             sub _build_authentication_tag {
217             my $self = shift;
218             return
219             "$self->{username}$self->{password}";
220             }
221              
222             sub _build_country_code_tag {
223             my $self = shift;
224             my $two_digit_country = $self->{residence};
225             my $three_digit_country =
226             uc Locale::Country::country_code2code( $two_digit_country,
227             LOCALE_CODE_ALPHA_2, LOCALE_CODE_ALPHA_3 );
228              
229             if ( not $three_digit_country ) {
230             $self->logi( "Client "
231             . $self->{client_id}
232             . " could not get country from residence [$two_digit_country]" );
233             return;
234             }
235              
236             return "$three_digit_country";
237             }
238              
239             sub _build_person_tag {
240             my $self = shift;
241              
242             my $dob = $self->{date_of_birth} || do {
243             $self->logi( "No date of birth for " . $self->{client_id} );
244             return;
245             };
246              
247             if ( $dob =~ /^(\d\d\d\d)/ ) {
248             my $birth_year = $1;
249              
250             # Check client not older than 100 or less than 18 years old
251             my ( undef, undef, undef, undef, undef, $curyear ) = gmtime(time);
252             $curyear += 1900;
253             my $maxyear = $curyear - 17;
254             my $minyear = $curyear - 100;
255              
256             if ( $birth_year > $maxyear or $birth_year < $minyear ) {
257             return;
258             }
259             }
260             else {
261             $self->logi( "Invalid date of birth [$dob] for " . $self->{client_id} );
262             return;
263             }
264              
265             return
266             ''
267             . ''
268             . $self->{first_name}
269             . ''
270             . ''
271             . $self->{last_name}
272             . ''
273             . "$dob"
274             . '';
275              
276             }
277              
278             sub _build_addresses_tag {
279             my $self = shift;
280              
281             my $postcode = $self->{postcode};
282             my $premise = $self->{premise} || die 'needs premise';
283             my $country_code = $self->_build_country_code_tag;
284              
285             return qq(
$premise)
286             . qq($postcode$country_code);
287             }
288              
289             sub _build_telephones_tag {
290             my $self = shift;
291              
292             my $telephone_type = 'U';
293             my $number;
294             if ( $self->{phone} =~ /^([\+\d\s]+)/ ) {
295             $number = $1;
296             }
297              
298             return
299             ''
300             . qq()
301             . "$number"
302             . ""
303             . '';
304             }
305              
306             sub _build_search_reference_tag {
307             my $self = shift;
308             my $shortopt = ( $self->{search_option} eq 'ProveID_KYC' ) ? 'PK' : 'C';
309             my $time = time();
310             return
311             "${shortopt}_"
312             . $self->{client_id}
313             . "_$time";
314             }
315              
316             sub _build_search_option_tag {
317             my $self = shift;
318             return
319             "$self->{search_option}";
320             }
321              
322             sub _xml_as_hash {
323             my $self = shift;
324             my $xml = $self->{result_as_xml} || return;
325             return XML::Simple::XMLin(
326             $xml,
327             KeyAttr => { DocumentID => 'type' },
328             ForceArray => ['DocumentID'],
329             ContentKey => '-content',
330             );
331             }
332              
333             sub _get_result_proveid {
334             my $self = shift;
335              
336             my $report = $self->{result_as_xml} || die 'needs xml report';
337              
338             my $twig = eval { XML::Twig->parse($report) } || do {
339             my $err = $@;
340             $self->logi("could not parse xml report: $err");
341             return;
342             };
343              
344             my ($report_summary_twig) = $twig->get_xpath(
345             '/Search/Result/Summary/ReportSummary/DatablocksSummary');
346              
347             return unless $report_summary_twig;
348              
349             my %report_summary;
350             for my $dblock ( $report_summary_twig->get_xpath('DatablockSummary') ) {
351             my $name = $dblock->findvalue('Name');
352             my $value = $dblock->findvalue('Decision');
353             $report_summary{$name} = $value;
354             }
355             my ($kyc_summary) = $twig->get_xpath('/Search/Result/Summary/KYCSummary');
356             my ($credit_reference) =
357             $twig->get_xpath('/Search/Result/CreditReference/CreditReferenceSummary');
358              
359             return unless $credit_reference and $kyc_summary;
360              
361             my $decision = {};
362              
363             # check if client has died or fraud
364             my $cr_deceased = $credit_reference->findvalue('DeceasedMatch') || 0;
365             $report_summary{Deceased} ||= 0;
366             my $confidence_level = 0;
367             if ( $report_summary{Deceased} ) {
368              
369             # We only taking Deceased flag in ReportSummary into account
370             # if ConfidenceLevel 7 or above
371             my ($deceased_record) =
372             $twig->get_xpath('/Search/Result/Deceased/DeceasedRecord');
373             $confidence_level = $deceased_record->findvalue('ConfidenceLevel') || 0;
374             }
375             if ( ( $report_summary{Deceased} == 1 and $confidence_level > 6 )
376             or $cr_deceased == 1 )
377             {
378             $decision->{deceased} = 1;
379             return $decision;
380             }
381              
382             $report_summary{Fraud} ||= 0;
383             if ( $report_summary{Fraud} == 1 ) {
384             $decision->{fraud} = 1;
385             return $decision;
386             }
387              
388             # check if client is age verified
389             my $kyc_dob = $kyc_summary->findvalue('DateOfBirth/Count') || 0;
390             my $cr_total = $credit_reference->findvalue('TotalNumberOfVerifications')
391             || 0;
392             if ( $kyc_dob or $cr_total ) {
393             $decision->{age_verified} = 1;
394             }
395             else {
396             return $decision;
397             }
398              
399             # check if client is in any suspicious list
400             # we don't care about: NoOfCCJ, COAMatch
401             my @matches =
402             map { $_->[0] }
403             grep { $_->[1] > 0 }
404             map { [ $_, $credit_reference->findvalue($_) || 0 ] }
405             qw(BOEMatch PEPMatch OFACMatch CIFASMatch);
406              
407             if (@matches) {
408             if ( grep { /^(OFACMatch|CIFASMatch)$/ } @matches ) {
409              
410             # OFAC and CIFAS are hard failures and need manual verification
411             delete $decision->{age_verified};
412             $decision->{deny} = 1;
413             }
414             $decision->{matches} = \@matches;
415             return $decision;
416             }
417              
418             # if client is in Directors list, we should not fully authenticate him
419             if ( $report_summary{Directors} ) {
420             $decision->{matches} = ['Directors'];
421             return $decision;
422             }
423              
424             # check if client can be fully authenticated
425             my @kyc_two =
426             grep { $_ >= 2 }
427             map { $kyc_summary->findvalue("$_/Count") || 0 }
428             qw(FullNameAndAddress SurnameAndAddress Address DateOfBirth);
429             if ( @kyc_two or $cr_total >= 2 ) {
430             $decision->{fully_authenticated} = 1;
431             }
432              
433             return $decision;
434             }
435              
436             sub _get_result_checkid {
437              
438             my $self = shift;
439             my $passed = 0;
440              
441             # Convert xml to hashref
442             my $result = $self->_xml_as_hash || do {
443             $self->logi('no xml result');
444             return;
445             };
446              
447             if (
448             (
449             (
450             $result->{'Result'}->{'ElectoralRoll'}->{'Type'} eq 'Result'
451             and $result->{'Result'}->{'ElectoralRoll'}->{'Summary'}
452             ->{'Decision'} == 1
453             )
454             or ( $result->{'Result'}->{'Directors'}->{'Type'} eq 'Result'
455             and
456             $result->{'Result'}->{'Directors'}->{'Summary'}->{'Decision'} ==
457             1 )
458             or ( $result->{'Result'}->{'Telephony'}->{'Type'} eq 'Result'
459             and
460             $result->{'Result'}->{'Telephony'}->{'Summary'}->{'Decision'} ==
461             1 )
462             )
463             )
464             {
465              
466             # Check Directors DecisionReasons
467             if ( $result->{'Result'}->{'Directors'}->{'Type'} eq 'Result' ) {
468             DIRECTORS_DECISION_REASONS:
469             foreach my $decision_reason (
470             @{
471             $result->{'Result'}->{'Directors'}->{'Summary'}
472             ->{'DecisionReasons'}->{'DecisionReason'}
473             }
474             )
475             {
476             if ( $decision_reason->{'Element'} eq
477             'Director/Person/DateOfBirth'
478             and $decision_reason->{'Decision'} == 1 )
479             {
480             $passed = 1;
481             last DIRECTORS_DECISION_REASONS;
482             }
483             }
484              
485             if ( not $passed ) {
486             ELECTORALROLL_DECISION_REASONS:
487             foreach my $decision_reason (
488             @{
489             $result->{'Result'}->{'ElectoralRoll'}->{'Summary'}
490             ->{'DecisionReasons'}->{'DecisionReason'}
491             }
492             )
493             {
494             if ( $decision_reason->{'Element'} eq
495             'ElectoralRollRecord/Person/DateOfBirth'
496             and $decision_reason->{'Decision'} == 1 )
497             {
498             $passed = 1;
499             last ELECTORALROLL_DECISION_REASONS;
500             }
501             }
502             }
503             }
504             }
505              
506             return $passed;
507             }
508              
509             sub _do_192_authentication {
510             my $self = shift;
511              
512             my $search_option = $self->{search_option};
513              
514             my $force_recheck = $self->{force_recheck} || 0;
515              
516             $self->logi( "Attempt 192 authentication for "
517             . $self->{client_id}
518             . " via $search_option" );
519              
520             my $residence = $self->{residence};
521              
522             # check for 192 supported countries
523             unless ( $self->valid_country( $self->{residence} ) ) {
524             $self->logi( "Invalid residence: "
525             . $self->{client_id}
526             . ", Residence $residence" );
527             return;
528             }
529              
530             if ( !$force_recheck && $self->has_done_request ) {
531             $self->{result_as_xml} = $self->get_192_xml_report;
532             return 1;
533             }
534              
535             # No previous result so prepare a request
536             $self->_build_request
537             || die( "Cannot build xml_request for ["
538             . $self->{client_id}
539             . "/$search_option]" );
540              
541             # Remove old pdf in case this client has done the 192 pdf request before
542             my $file_pdf = $self->_pdf_report_filename;
543             unlink $file_pdf if -e $file_pdf;
544              
545             eval { $self->_send_request } || do {
546             my $err = $@ || '?';
547             $self->logi("could not send pdf request: $err");
548             return;
549             };
550              
551             # Save xml result
552             my $folder_xml = "$self->{folder}/xml";
553             if ( not -d $folder_xml ) {
554             Path::Tiny::path($folder_xml)->mkpath;
555             }
556             my $file_xml = $self->_xml_report_filename;
557             Path::Tiny::path($file_xml)->spew( $self->{result_as_xml} );
558              
559             if ( not -e $file_xml ) {
560             $self->logi(
561             "Couldn't save 192.com xml result for " . $self->{client_id} );
562             return;
563             }
564              
565             $self->save_pdf_result;
566              
567             return 1;
568             }
569              
570             sub _xml_report_filename {
571             my $self = shift;
572             my $search_option = $self->{search_option};
573             return "$self->{folder}/xml/" . $self->{client_id} . ".$search_option";
574             }
575              
576             sub _pdf_report_filename {
577             my $self = shift;
578             my $search_option = $self->{search_option};
579             return "$self->{folder}/pdf/" . $self->{client_id} . ".$search_option.pdf";
580             }
581              
582             1;
583              
584             =head1 NAME
585              
586             Experian::IDAuth - Experian's ID Authenticate service
587              
588             =head1 VERSION
589              
590             Version 1.5
591              
592             =head1 DESCRIPTION
593              
594             This module provides an interface to Experian's Identity Authenticate service.
595             http://www.experian.co.uk/identity-and-fraud/products/authenticate.html
596              
597             First create a subclass of this module to override the defaults method
598             with your own data.
599              
600             package My::Experian;
601             use strict;
602             use warnings;
603             use base 'Experian::IDAuth';
604              
605             # if you're using a logger
606             use Log::Log4perl;
607              
608             sub defaults {
609             my $self = shift;
610              
611             return (
612             $self->SUPER::defaults,
613             logger => Log::Log4perl::get_logger,
614             username => 'my_user',
615             password => 'my_pass',
616             residence => $residence,
617             postcode => $postcode || '',
618             date_of_birth => $date_of_birth || '',
619             first_name => $first_name || '',
620             last_name => $last_name || '',
621             phone => $phone || '',
622             email => $email || '',
623             );
624             }
625              
626             1;
627              
628             Then use this module.
629              
630             use My::Experian;
631              
632             # search_option can either be ProveID_KYC or CheckID
633             my $prove_id = My::Experian->new(
634             search_option => 'ProveID_KYC',
635             );
636              
637             my $prove_id_result = $prove_id->get_result();
638              
639             if (!$prove_id->has_done_request) {
640             # connection problems
641             die;
642             }
643              
644             if ($prove_id_result->{fully_authenticated}) {
645             # client successfully authenticated
646             }
647             if ($prove_id_result->{age_verified}) {
648             # client's age is verified
649             }
650             if ($prove_id_result->{deceased} || $prove_id_result->{fraud}) {
651             # client flagged as deceased or fraud
652             }
653              
654             # CheckID is a more simpler version and can be used if ProveID_KYC fails
655             my $check_id = My::Experian->new(
656             search_option => 'CheckID',
657             );
658              
659             if (!$check_id->has_done_request) {
660             # connection problems
661             die;
662             }
663              
664             if ($check_id->get_result()) {
665             # client successfully authenticated
666             }
667              
668             =head1 AUTHOR
669              
670             binary.com, C
671              
672             =head1 BUGS
673              
674             Please report any bugs or feature requests to C,
675             or through the web interface at
676             L.
677             We will be notified, and then you'll automatically be notified of progress
678             on your bug as we make changes.
679              
680             =head1 SUPPORT
681              
682             You can find documentation for this module with the perldoc command.
683              
684             perldoc Experian::IDAuth
685              
686              
687             You can also look for information at:
688              
689             =over 4
690              
691             =item * RT: CPAN's request tracker (report bugs here)
692              
693             L
694              
695             =item * AnnoCPAN: Annotated CPAN documentation
696              
697             L
698              
699             =item * CPAN Ratings
700              
701             L
702              
703             =item * Search CPAN
704              
705             L
706              
707             =back
708              
709              
710             =head1 DEPENDENCIES
711              
712             Locale::Country
713             Path::Tiny
714             WWW::Mechanize
715             XML::Simple
716             XML::Twig
717             SOAP::Lite
718             IO::Socket
719              
720             =head1 LICENSE AND COPYRIGHT
721              
722             Copyright 2014 binary.com.
723              
724             This program is free software; you can redistribute it and/or modify it
725             under the terms of the the Artistic License (2.0). You may obtain a
726             copy of the full license at:
727              
728             L
729              
730             Any use, modification, and distribution of the Standard or Modified
731             Versions is governed by this Artistic License. By using, modifying or
732             distributing the Package, you accept this license. Do not use, modify,
733             or distribute the Package, if you do not accept this license.
734              
735             If your Modified Version has been derived from a Modified Version made
736             by someone other than you, you are nevertheless required to ensure that
737             your Modified Version complies with the requirements of this license.
738              
739             This license does not grant you the right to use any trademark, service
740             mark, tradename, or logo of the Copyright Holder.
741              
742             This license includes the non-exclusive, worldwide, free-of-charge
743             patent license to make, have made, use, offer to sell, sell, import and
744             otherwise transfer the Package with respect to any patent claims
745             licensable by the Copyright Holder that are necessarily infringed by the
746             Package. If you institute patent litigation (including a cross-claim or
747             counterclaim) against any party alleging that the Package constitutes
748             direct or contributory patent infringement, then this Artistic License
749             to you shall terminate on the date that such litigation is filed.
750              
751             Disclaimer of Warranty: THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER
752             AND CONTRIBUTORS "AS IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES.
753             THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
754             PURPOSE, OR NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY
755             YOUR LOCAL LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR
756             CONTRIBUTOR WILL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR
757             CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE,
758             EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
759              
760              
761             =cut
762              
763             1; # End of Experian::IDAuth
764