File Coverage

blib/lib/Net/Duo.pm
Criterion Covered Total %
statement 119 137 86.8
branch 18 32 56.2
condition 9 17 52.9
subroutine 19 19 100.0
pod 4 4 100.0
total 169 209 80.8


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.duo.com/). It differs from the Perl API
5             # sample code in that it abstracts some of the API details and throws rich
6             # exceptions rather than requiring the caller deal with JSON data structures
7             # 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             # SPDX-License-Identifier: MIT
14              
15             package Net::Duo 1.02;
16              
17 10     10   2862 use 5.014;
  10         33  
18 10     10   48 use strict;
  10         20  
  10         181  
19 10     10   53 use warnings;
  10         23  
  10         359  
20              
21 10     10   68 use Carp qw(croak);
  10         17  
  10         596  
22 10     10   81 use Digest::SHA qw(hmac_sha1_hex);
  10         31  
  10         498  
23 10     10   59 use HTTP::Request;
  10         116  
  10         248  
24 10     10   52 use JSON ();
  10         19  
  10         296  
25 10     10   6417 use LWP::UserAgent 6.00;
  10         171950  
  10         410  
26 10     10   4661 use Net::Duo::Exception;
  10         30  
  10         287  
27 10     10   66 use Perl6::Slurp;
  10         20  
  10         92  
28 10     10   5163 use POSIX qw(strftime);
  10         63935  
  10         58  
29 10     10   14309 use URI::Escape qw(uri_escape_utf8);
  10         22  
  10         13355  
30              
31             # All dies are of constructed objects, which perlcritic misdiagnoses.
32             ## no critic (ErrorHandling::RequireCarping)
33              
34             ##############################################################################
35             # Constructor
36             ##############################################################################
37              
38             # Create a new Net::Duo object, which will be used for subsequent calls.
39             #
40             # $class - Class of object to create
41             # $args - Anonymous hash of arguments with the following keys:
42             # api_hostname - API hostname for the Duo API integration
43             # integration_key - Public key for the Duo API integration
44             # key_file - Path to file with integration information
45             # secret_key - Secret key for the Duo API integration
46             # user_agent - User agent object to use instead of LWP::UserAgent
47             #
48             # Returns: Newly-created object
49             # Throws: Net::Duo::Exception on any failure
50             sub new {
51 10     10 1 181 my ($class, $args_ref) = @_;
52 10         27 my $self = {};
53              
54             # Load integration information from key_file if set.
55 10         20 my $keys;
56 10 50       41 if ($args_ref->{key_file}) {
57 10         64 my $json = JSON->new->relaxed(1);
58 10         46 my $key_data = slurp($args_ref->{key_file});
59 10         1389 $keys = eval { $json->decode($key_data) };
  10         94  
60 10 50       66 if ($@) {
61 0         0 die Net::Duo::Exception->propagate($@);
62             }
63             }
64              
65             # Integration data from $args_ref overrides key_file data.
66 10         39 for my $key (qw(api_hostname integration_key secret_key)) {
67 30   33     142 $self->{$key} = $args_ref->{$key} // $keys->{$key};
68 30 50       96 if (!defined($self->{$key})) {
69 0         0 my $error = "missing parameter to Net::Duo::new: $key";
70 0         0 die Net::Duo::Exception->internal($error);
71             }
72             }
73              
74             # Create or set the user agent object.
75 10   33     146 $self->{agent} = $args_ref->{user_agent} // LWP::UserAgent->new;
76              
77             # Create the JSON decoder that we'll use for subsequent operations.
78 10         86 $self->{json} = JSON->new->utf8(1);
79              
80             # Bless and return the new object.
81 10         40 bless($self, $class);
82 10         43 return $self;
83             }
84              
85             ##############################################################################
86             # General methods
87             ##############################################################################
88              
89             # Internal method to canonicalize the arguments. The Duo API requires that
90             # all data be URL-encoded and then either used as GET arguments or sent in the
91             # POST body.
92             #
93             # $self - Net::Duo object
94             # $args_ref - Reference to hash of arguments (may be undef)
95             #
96             # Returns: URL-encoded string representing those arguments
97             # undef if there are no arguments
98             sub _canonicalize_args {
99 46     46   103 my ($self, $args_ref) = @_;
100              
101             # Return undef if there are no arguments.
102 46 100       148 return if !defined($args_ref);
103              
104             # Encode the arguments into a list of key and value pairs.
105 34         63 my @pairs;
106 34         56 while (my ($key, $value) = each %{$args_ref}) {
  111         380  
107 77         261 my $encoded_key = uri_escape_utf8($key);
108 77         1651 my $encoded_value = uri_escape_utf8($value);
109 77         1463 my $pair = $encoded_key . q{=} . $encoded_value;
110 77         182 push(@pairs, $pair);
111             }
112              
113             # Return the arguments joined with &.
114 34         183 return join(q{&}, sort(@pairs));
115             }
116              
117             # Internal method to sign a Duo API call and stores the appropriate
118             # Authorization header in the HTTP::Request. For the signature specification,
119             # see the Duo API documentation.
120             #
121             # $self - Net::Duo object
122             # $request - HTTP::Request object that will be used for the call
123             # $path - URI path for the REST endpoint
124             # $args - URI-encoded arguments to the call
125             #
126             # Returns: undef
127             sub _sign_call {
128 46     46   130 my ($self, $request, $path, $args) = @_;
129 46         141 my $date = $request->header('Date');
130 46         1951 my $method = uc($request->method);
131 46         529 my $host = $self->{api_hostname};
132              
133             # Generate the request information that should be signed.
134 46   100     257 my $data = join("\n", $date, $method, $host, $path, $args // q{});
135              
136             # Generate a SHA-1 HMAC as the signature.
137 46         482 my $signature = hmac_sha1_hex($data, $self->{secret_key});
138              
139             # The HTTP Basic Authentication username is the integration key.
140 46         117 my $username = $self->{integration_key};
141              
142             # Set the Authorization header.
143 46         203 $request->authorization_basic($username, $signature);
144 46         15167 return;
145             }
146              
147             # Make a generic Duo API call with no assumptions about its return data type.
148             # This returns the raw HTTP::Response object without any further processing.
149             # For most Duo APIs, use call_json instead, which assumes that the call
150             # returns JSON in a particular structure and checks the status of the HTTP
151             # response.
152             #
153             # $self - Net::Duo object
154             # $method - HTTP method (GET, PUT, POST, or DELETE)
155             # $path - URL path to the REST endpoint to call
156             # $args_ref - Reference to a hash of additional arguments
157             #
158             # Returns: The HTTP::Response object from the API call
159             # Throws: Net::Duo::Exception on any failure
160             sub call {
161 46     46 1 117 my ($self, $method, $path, $args_ref) = @_;
162 46         128 my $host = $self->{api_hostname};
163 46         165 my $args = $self->_canonicalize_args($args_ref);
164 46         118 $method = uc($method);
165              
166             # Verify that the URL path starts with a slash.
167 46 50       234 if ($path !~ m{ \A / }xms) {
168 0         0 my $error = "REST endpoint '$path' does not begin with /";
169 0         0 die Net::Duo::Exception->internal($error);
170             }
171              
172             # Set up the request.
173 46         271 my $request = HTTP::Request->new;
174 46         2652 $request->method($method);
175 46         597 $request->protocol('HTTP/1.1');
176 46         641 $request->date(time());
177 46         4856 $request->header(Host => $host);
178              
179             # Use an undocumented feature of LWP::Protocol::https to ensure that the
180             # certificate subject is from duosecurity.com. This header is not passed
181             # to the remote host, only used internally by LWP.
182 46         2382 my $subject_regex = qr{ CN=[^=]+ [.] duosecurity [.] com \z }xms;
183 46         175 $request->header('If-SSL-Cert-Subject' => $subject_regex);
184              
185             # Sign the request.
186 46         3244 $self->_sign_call($request, $path, $args);
187              
188             # For POST and PUT, send the arguments as form data. Otherwise, add them
189             # to the path as GET parameters.
190 46 100 66     270 if ($method eq 'POST' || $method eq 'PUT') {
    100          
191 22         162 $request->content_type('application/x-www-form-urlencoded');
192 22         609 $request->content($args);
193 22         479 $request->uri('https://' . $host . $path);
194             } elsif (!defined($args)) {
195 11         57 $request->uri('https://' . $host . $path);
196             } else {
197 13         74 $request->uri('https://' . $host . $path . q{?} . $args);
198             }
199              
200             # Make the request and return the response.
201 46         80471 return $self->{agent}->request($request);
202             }
203              
204             # Make a generic Duo API call that returns JSON and do the return status
205             # checking that's common to most of the Duo API calls. There are a few
206             # exceptions, like /logo, which do not return JSON and therefore cannot be
207             # called using this method).
208             #
209             # $self - Net::Duo object
210             # $method - HTTP method (GET, PUT, POST, or DELETE)
211             # $path - URL path to the REST endpoint to call
212             # $args_ref - Reference to a hash of additional arguments
213             #
214             # Returns: Full raw JSON response
215             # Throws: Net::Duo::Exception on any failure
216             sub _call_json_internal {
217 45     45   107 my ($self, $method, $path, $args_ref) = @_;
218              
219             # Use the simpler call() method to do most of the work. This returns the
220             # HTTP::Response object. Retrieve the content of the response as well.
221 45         146 my $response = $self->call($method, $path, $args_ref);
222 45         240 my $content = $response->decoded_content;
223              
224             # If the content was empty, we have a failure of some sort.
225 45 50       7000 if (!defined($content)) {
226 0 0       0 if ($response->is_success) {
227 0         0 die Net::Duo::Exception->protocol('empty response');
228             } else {
229 0         0 die Net::Duo::Exception->http($response);
230             }
231             }
232              
233             # Otherwise, try to decode the JSON. If we cannot, treat this as an
234             # HTTP failure if we didn't get success and a protocol failure
235             # otherwise.
236 45         116 my $data = eval { $self->{json}->decode($content) };
  45         592  
237 45 50       157 if ($@) {
238 0 0       0 if ($response->is_success) {
239 0         0 my $error = 'invalid JSON in reply';
240 0         0 die Net::Duo::Exception->protocol($error, $content);
241             } else {
242 0         0 die Net::Duo::Exception->http($response);
243             }
244             }
245              
246             # Check whether the API call succeeded. If not, throw an exception.
247 45 50 33     288 if (!defined($data->{stat}) || $data->{stat} ne 'OK') {
248 0         0 die Net::Duo::Exception->api($data, $content);
249             }
250              
251             # Return the response portion of the reply and the metadata if available
252             # and called in a list context.
253 45 50       135 if (!defined($data->{response})) {
254 0         0 my $error = 'no response key in JSON reply';
255 0         0 die Net::Duo::Exception->protocol($error, $content);
256             }
257 45         191 return $data;
258             }
259              
260             # Make a generic Duo API call that returns JSON and do the return status
261             # checking that's common to most of the Duo API calls. There are a few
262             # exceptions, like /logo, which do not return JSON and therefore cannot be
263             # called using this method).
264             #
265             # $self - Net::Duo object
266             # $method - HTTP method (GET, PUT, POST, or DELETE)
267             # $path - URL path to the REST endpoint to call
268             # $args_ref - Reference to a hash of additional arguments
269             #
270             # Returns: Full raw JSON response
271             # Throws: Net::Duo::Exception on any failure
272             sub call_json {
273 41     41 1 127 my ($self, $method, $path, $args_ref) = @_;
274 41         146 my $data = $self->_call_json_internal($method, $path, $args_ref);
275 41         162 return $data->{response};
276             }
277              
278             # Make a generic Duo API that returns paginated data.
279             #
280             # $self - Net::Duo object
281             # $method - HTTP method (GET, PUT, POST, or DELETE)
282             # $path - URL path to the REST endpoint to call
283             # $args_ref - Reference to a hash of additional arguments
284             #
285             # Returns: Reference to hash corresponding to the JSON result
286             # Throws: Net::Duo::Exception on any failure
287             sub call_json_paged {
288 2     2 1 8 my ($self, $method, $path, $args_ref) = @_;
289 2         5 my $offset = 0;
290 2         3 my @response;
291 2         4 my $more_data = 1;
292              
293             # Iterate over repeated calls to get paginated data.
294 2         8 while ($more_data) {
295 4 50       12 my $call_args_ref = $args_ref ? { %{$args_ref} } : {};
  0         0  
296 4         11 $call_args_ref->{offset} = $offset;
297 4         10 $call_args_ref->{limit} = 500;
298 4         17 my $data = $self->_call_json_internal($method, $path, $call_args_ref);
299              
300             # For paginated data, the response must be a list.
301 4 50       14 if (ref($data->{response}) ne 'ARRAY') {
302 0         0 my $error = 'body of paginated response not an array';
303 0         0 die Net::Duo::Exception->protocol($error);
304             }
305 4         8 push(@response, @{ $data->{response} });
  4         11  
306              
307             # Continue if we have more paginated data.
308 4 100 66     31 if ($data->{metadata} && exists($data->{metadata}{next_offset})) {
309 2         14 $offset = $data->{metadata}{next_offset};
310             } else {
311 2         10 $more_data = 0;
312             }
313             }
314              
315             # Return accumulated results.
316 2         9 return \@response;
317             }
318              
319             1;
320             __END__