File Coverage

blib/lib/Net/Duo.pm
Criterion Covered Total %
statement 100 115 86.9
branch 14 26 53.8
condition 7 14 50.0
subroutine 17 17 100.0
pod 3 3 100.0
total 141 175 80.5


line stmt bran cond sub pod time code
1             # Perl interface for the Duo multifactor authentication service.
2             #
3             # This Perl module collection provides a Perl interface to the Duo multifactor
4             # authentication service (https://www.duosecurity.com/). It differs from the
5             # Perl API sample code in that it abstracts some of the API details and throws
6             # rich exceptions rather than requiring the caller deal with JSON data
7             # structures directly.
8             #
9             # This module is intended primarily for use as a base class for more
10             # specialized Perl modules implementing the specific Duo APIs, but it can also
11             # be used directly to make generic API calls.
12              
13             package Net::Duo 1.01;
14              
15 10     10   3490 use 5.014;
  10         33  
16 10     10   50 use strict;
  10         18  
  10         227  
17 10     10   46 use warnings;
  10         24  
  10         306  
18              
19 10     10   46 use Carp qw(croak);
  10         17  
  10         523  
20 10     10   48 use Digest::SHA qw(hmac_sha1_hex);
  10         17  
  10         461  
21 10     10   55 use HTTP::Request;
  10         53  
  10         228  
22 10     10   67 use JSON ();
  10         20  
  10         302  
23 10     10   9881 use LWP::UserAgent 6.00;
  10         176965  
  10         345  
24 10     10   6908 use Net::Duo::Exception;
  10         34  
  10         382  
25 10     10   62 use Perl6::Slurp;
  10         21  
  10         90  
26 10     10   8909 use POSIX qw(strftime);
  10         73533  
  10         70  
27 10     10   14558 use URI::Escape qw(uri_escape_utf8);
  10         21  
  10         11130  
28              
29             # All dies are of constructed objects, which perlcritic misdiagnoses.
30             ## no critic (ErrorHandling::RequireCarping)
31              
32             ##############################################################################
33             # Constructor
34             ##############################################################################
35              
36             # Create a new Net::Duo object, which will be used for subsequent calls.
37             #
38             # $class - Class of object to create
39             # $args - Anonymous hash of arguments with the following keys:
40             # api_hostname - API hostname for the Duo API integration
41             # integration_key - Public key for the Duo API integration
42             # key_file - Path to file with integration information
43             # secret_key - Secret key for the Duo API integration
44             # user_agent - User agent object to use instead of LWP::UserAgent
45             #
46             # Returns: Newly-created object
47             # Throws: Net::Duo::Exception on any failure
48             sub new {
49 10     10 1 162 my ($class, $args_ref) = @_;
50 10         25 my $self = {};
51              
52             # Load integration information from key_file if set.
53 10         21 my $keys;
54 10 50       56 if ($args_ref->{key_file}) {
55 10         69 my $json = JSON->new->relaxed(1);
56 10         49 my $key_data = slurp($args_ref->{key_file});
57 10         1098 $keys = eval { $json->decode($key_data) };
  10         86  
58 10 50       64 if ($@) {
59 0         0 die Net::Duo::Exception->propagate($@);
60             }
61             }
62              
63             # Integration data from $args_ref overrides key_file data.
64 10         30 for my $key (qw(api_hostname integration_key secret_key)) {
65 30   33     139 $self->{$key} = $args_ref->{$key} // $keys->{$key};
66 30 50       96 if (!defined($self->{$key})) {
67 0         0 my $error = "missing parameter to Net::Duo::new: $key";
68 0         0 die Net::Duo::Exception->internal($error);
69             }
70             }
71              
72             # Create or set the user agent object.
73 10   33     91 $self->{agent} = $args_ref->{user_agent} // LWP::UserAgent->new;
74              
75             # Create the JSON decoder that we'll use for subsequent operations.
76 10         85 $self->{json} = JSON->new->utf8(1);
77              
78             # Bless and return the new object.
79 10         31 bless($self, $class);
80 10         42 return $self;
81             }
82              
83             ##############################################################################
84             # General methods
85             ##############################################################################
86              
87             # Internal method to canonicalize the arguments. The Duo API requires that
88             # all data be URL-encoded and then either used as GET arguments or sent in the
89             # POST body.
90             #
91             # $self - Net::Duo object
92             # $args_ref - Reference to hash of arguments (may be undef)
93             #
94             # Returns: URL-encoded string representing those arguments
95             # undef if there are no arguments
96             sub _canonicalize_args {
97 44     44   75 my ($self, $args_ref) = @_;
98              
99             # Return undef if there are no arguments.
100 44 100       142 return if !defined($args_ref);
101              
102             # Encode the arguments into a list of key and value pairs.
103 30         41 my @pairs;
104 30         47 while (my ($key, $value) = each %{$args_ref}) {
  99         357  
105 69         213 my $encoded_key = uri_escape_utf8($key);
106 69         1184 my $encoded_value = uri_escape_utf8($value);
107 69         1005 my $pair = $encoded_key . q{=} . $encoded_value;
108 69         196 push(@pairs, $pair);
109             }
110              
111             # Return the arguments joined with &.
112 30         153 return join(q{&}, sort(@pairs));
113             }
114              
115             # Internal method to sign a Duo API call and stores the appropriate
116             # Authorization header in the HTTP::Request. For the signature specification,
117             # see the Duo API documentation.
118             #
119             # $self - Net::Duo object
120             # $request - HTTP::Request object that will be used for the call
121             # $path - URI path for the REST endpoint
122             # $args - URI-encoded arguments to the call
123             #
124             # Returns: undef
125             sub _sign_call {
126 44     44   97 my ($self, $request, $path, $args) = @_;
127 44         134 my $date = $request->header('Date');
128 44         1663 my $method = uc($request->method);
129 44         440 my $host = $self->{api_hostname};
130              
131             # Generate the request information that should be signed.
132 44   100     241 my $data = join("\n", $date, $method, $host, $path, $args // q{});
133              
134             # Generate a SHA-1 HMAC as the signature.
135 44         630 my $signature = hmac_sha1_hex($data, $self->{secret_key});
136              
137             # The HTTP Basic Authentication username is the integration key.
138 44         97 my $username = $self->{integration_key};
139              
140             # Set the Authorization header.
141 44         194 $request->authorization_basic($username, $signature);
142 44         81667 return;
143             }
144              
145             # Make a generic Duo API call with no assumptions about its return data type.
146             # This returns the raw HTTP::Response object without any further processing.
147             # For most Duo APIs, use call_json instead, which assumes that the call
148             # returns JSON in a particular structure and checks the status of the HTTP
149             # response.
150             #
151             # $self - Net::Duo object
152             # $method - HTTP method (GET, PUT, POST, or DELETE)
153             # $path - URL path to the REST endpoint to call
154             # $args_ref - Reference to a hash of additional arguments
155             #
156             # Returns: The HTTP::Response object from the API call
157             # Throws: Net::Duo::Exception on any failure
158             sub call {
159 44     44 1 100 my ($self, $method, $path, $args_ref) = @_;
160 44         114 my $host = $self->{api_hostname};
161 44         198 my $args = $self->_canonicalize_args($args_ref);
162 44         125 $method = uc($method);
163              
164             # Verify that the URL path starts with a slash.
165 44 50       196 if ($path !~ m{ \A / }xms) {
166 0         0 my $error = "REST endpoint '$path' does not begin with /";
167 0         0 die Net::Duo::Exception->internal($error);
168             }
169              
170             # Set up the request.
171 44         238 my $request = HTTP::Request->new;
172 44         1890 $request->method($method);
173 44         526 $request->protocol('HTTP/1.1');
174 44         520 $request->date(time());
175 44         3870 $request->header(Host => $host);
176              
177             # Use an undocumented feature of LWP::Protocol::https to ensure that the
178             # certificate subject is from duosecurity.com. This header is not passed
179             # to the remote host, only used internally by LWP.
180 44         2016 my $subject_regex = qr{ CN=[^=]+ [.] duosecurity [.] com \z }xms;
181 44         201 $request->header('If-SSL-Cert-Subject' => $subject_regex);
182              
183             # Sign the request.
184 44         2759 $self->_sign_call($request, $path, $args);
185              
186             # For POST and PUT, send the arguments as form data. Otherwise, add them
187             # to the path as GET parameters.
188 44 100 66     274 if ($method eq 'POST' || $method eq 'PUT') {
    100          
189 22         164 $request->content_type('application/x-www-form-urlencoded');
190 22         587 $request->content($args);
191 22         511 $request->uri('https://' . $host . $path);
192             } elsif (!defined($args)) {
193 13         67 $request->uri('https://' . $host . $path);
194             } else {
195 9         45 $request->uri('https://' . $host . $path . q{?} . $args);
196             }
197              
198             # Make the request and return the response.
199 44         110829 return $self->{agent}->request($request);
200             }
201              
202             # Make a generic Duo API call that returns JSON and do the return status
203             # checking that's common to most of the Duo API calls. There are a few
204             # exceptions, like /logo, which do not return JSON and therefore cannot be
205             # called using this method).
206             #
207             # $self - Net::Duo object
208             # $method - HTTP method (GET, PUT, POST, or DELETE)
209             # $path - URL path to the REST endpoint to call
210             # $args_ref - Reference to a hash of additional arguments
211             #
212             # Returns: Reference to hash corresponding to the JSON result
213             # Throws: Net::Duo::Exception on any failure
214             sub call_json {
215 43     43 1 105 my ($self, $method, $path, $args_ref) = @_;
216              
217             # Use the simpler call() method to do most of the work. This returns the
218             # HTTP::Response object. Retrieve the content of the response as well.
219 43         165 my $response = $self->call($method, $path, $args_ref);
220 43         238 my $content = $response->decoded_content;
221              
222             # If the content was empty, we have a failure of some sort.
223 43 50       5552 if (!defined($content)) {
224 0 0       0 if ($response->is_success) {
225 0         0 die Net::Duo::Exception->protocol('empty response');
226             } else {
227 0         0 die Net::Duo::Exception->http($response);
228             }
229             }
230              
231             # Otherwise, try to decode the JSON. If we cannot, treat this as an
232             # HTTP failure if we didn't get success and a protocol failure
233             # otherwise.
234 43         68 my $data = eval { $self->{json}->decode($content) };
  43         538  
235 43 50       126 if ($@) {
236 0 0       0 if ($response->is_success) {
237 0         0 my $error = 'invalid JSON in reply';
238 0         0 die Net::Duo::Exception->protocol($error, $content);
239             } else {
240 0         0 die Net::Duo::Exception->http($response);
241             }
242             }
243              
244             # Check whether the API call succeeded. If not, throw an exception.
245 43 50 33     313 if (!defined($data->{stat}) || $data->{stat} ne 'OK') {
246 0         0 die Net::Duo::Exception->api($data, $content);
247             }
248              
249             # Return the response portion of the reply.
250 43 50       150 if (!defined($data->{response})) {
251 0         0 my $error = 'no response key in JSON reply';
252 0         0 die Net::Duo::Exception->protocol($error, $content);
253             }
254 43         300 return $data->{response};
255             }
256              
257             1;
258             __END__