################# package Sfa::Duo; ################# use strict; use warnings; use Dir::Self; use JSON; use IO::File; use Text::Template; # We must have this here, so that the -p option # implemented in Sfa.pm works correctly. # It looks directly for this symbol. our($Servicedetails) = { name => __PACKAGE__, description => "Duo Security Inc. http://Duo.com" }; # Here we list all the types of SFA we can handle. # These are called capabilities by Duo and they # are listed with each type of device returned in # the pre_auth call. I'm not sure if these capabilities # are shared across device types (phone and token), but # here I'm going to assume that they are not, so we will use # the capability as our 'method'. So in this following # table the key is the method|capability and the value # is a hash of 'scheme', 'begin' and 'check' function # references and a description. Note that this description # cannot in general just be handed back to the list call # as we often have to add information only garnered from # the return of the pre-auth call and that changes with # each user and device. We use a template to automate # the filling in of this description. At the moment the # only supported 'parameter' to the template call is {$display_name} # fetched from the device hash returned by pre_auth. our($methods) = { "push" => { scheme => Sfa::EXTERNAL, begin => \&beginexternal, check => \&checkexternal, description => Text::Template->new(TYPE => 'STRING', SOURCE => 'Duo push request to {$display_name}'), }, "mobile_otp" => { scheme => Sfa::OTPG, begin => \&beginmobile_otp, check => \&checkpasscode, description => Text::Template->new(TYPE => 'STRING', SOURCE => 'Duo mobile generated OTP {$display_name}'), }, # SMS by Duo sends a list # of codes, with an indication of # which one to send in the pre-auth # call. So I think this is a OTPG # rather than an OTPR. "sms" => { scheme => Sfa::OTPG, begin => \&beginsms, check => \&checkpasscode, description => Text::Template->new(TYPE => 'STRING', SOURCE => 'Duo mobile SMS {$display_name}'), }, "phone" => { scheme => Sfa::EXTERNAL, begin => \&beginexternal, check => \&checkexternal, description => Text::Template->new(TYPE => 'STRING', SOURCE => 'Duo phone call to {$display_name}'), }, }; sub new { my($class) = shift; my($self) = {methods => $methods}; bless($self, $class); return $self; } sub list { my($self) = shift; my($sfa) = @_; # It is my job to query the service for this # user and either: # 1. tell him that the user is permamently barred, so fail # 2. tell him that he doesn't need to SFA, set NOSFA and return # 3. tell him that he needs to SFA, and build a list # of supported methods. We return a list in the order # it which the provider lists it in the preauth return. Each # entry is a hash with a tag, description of the # method, a 'begin' and 'check' code referece and # the scheme of this method. my($mlist) = []; # List of methods returned; my($res); # The result from the client call $sfa->setcode(Sfa::FAIL, "List initialization failure"); $self->details(); eval { $res = $self->json_api_call( 'POST', '/auth/v2/preauth', { username => $sfa->{user} } ); }; # The api_call above can 'croak', we probably don't want that # going to the trigger output, so catch it here. if ($@) { chomp($@); $sfa->setcode(Sfa::FAIL, "HTTP error from api call ($@)"); return; } # We don't get the complete return as described by the API # documentation as the Duo::API fudges with it on a OK # return result. Here's what I typically see: # { # "status_msg":"Account is active", # "devices":[ # { # "device":"DPP1L8I4E8QN188DZFTX", # "display_name":"Android (+XX XXXX XX3862)", # "type":"phone", # "number":"+44 7522 963862", # "capabilities":[ # "auto", # "push", # "sms", # "phone", # "mobile_otp" # ], # "name":"" # } # ], # "result":"auth" # } my($result, $devices, $dev); # We are only interested in these $result = $res->{result}; if ($result eq "deny") { $sfa->setcode(Sfa::FAIL, "Denied access"); return; } if ($result eq "allow") { $sfa->setcode(Sfa::NOSFA, "Second factor authentication with Duo not required"); return; } $devices = $res->{devices}; $Sfa::log->log("Return from preauth call", to_json($res)); foreach $dev (@$devices) { my($cap); foreach $cap (@{$dev->{capabilities}}) { if (exists($methods->{$cap})) { my($method); # Copy, so we get our own fresh one. my($thash) = { "display_name" => $dev->{display_name} }; %$method = %{$methods->{$cap}}; $method->{description} = $methods->{$cap}->{description}->fill_in(HASH => $thash); $method->{method} = { method => $cap, device => $dev->{device}, display_name => $dev->{display_name} }; # sms may return the index of the next code to use. if ($cap eq "sms" && exists($dev->{sms_nextcode})) { $method->{method}->{sms_nextcode} = $dev->{sms_nextcode}; } push(@$mlist, $method); } } } if (scalar(@$mlist) == 0) { $sfa->setcode(Sfa::FAIL, "There are no available methods listed for you"); } else { # return a 'good' status to continue the authentication attempt. $sfa->setmethods($mlist); $sfa->setcode(Sfa::SUCCESS); } } sub beginexternal { my($self) = shift; my($sfa) = @_; my($res); # Lets load up the hidden data keys. $self->details(); eval { $res = $self->json_api_call( 'POST', '/auth/v2/auth', { username => $sfa->{user}, factor => $sfa->{method}->{method}, device => $sfa->{method}->{device}, async => 1 } ); }; if ($@) { chomp($@); $sfa->setcode(Sfa::FAIL, "HTTP error from api call ($@)"); return; } my($token) = { txid => $res->{txid} }; $sfa->settoken($token); # Send this back to the trigger caller. my($mess); if ($sfa->{method}->{method} eq "push") { $mess = "A push request has been sent"; } elsif ($sfa->{method}->{method} eq "phone") { $mess ="A phone call has been instigated"; } else { $sfa->setcode(Sfa::FAIL, "Unexpected method on checkexternal ($sfa->{method}->{method}"); return; } $sfa->setcode(Sfa::SUCCESS); $sfa->setmessage($mess); } # Called to perform a service check after a push/phone request. # We get the transaction id we need for the check # via the token string passed to the trigger. # The token should look like: # { "method" : "push", "token" : { "txid" : "58a49bf4-fbf9-408e-82bd-ee08a2ed403e"} } # sub checkexternal { my($self) = shift; my($sfa) = @_; my($res); $sfa->setcode(Sfa::FAIL, "checkexternal initialization failure"); $self->details(); my($txid) = $sfa->{method}->{token}->{txid}; eval { $res = $self->json_api_call( 'GET', '/auth/v2/auth_status', { txid => $txid } ); }; if ($@) { chomp($@); $sfa->setcode(Sfa::FAIL, "HTTP error from api call ($@)"); return; } my($result) = $res->{result}; if ($result eq "deny") { $sfa->setcode(Sfa::FAIL, "You have been denied access ($res->{status_msg})"); return; } if ($result eq "waiting") { $sfa->setcode(Sfa::NOSFA); $sfa->setmessage("Your authentication request is still pending ($res->{status_msg})"); return; } if ($result eq "allow") { $sfa->setcode(Sfa::SUCCESS); $sfa->setmessage("Success"); return; } # This is an error, the result returned is not one we are expecting. # So fail the request. $sfa->setcode(Sfa::FAIL, "Unexpected result code from checkexternal " . to_json($res)); return; } # Code generated by the phone app. sub beginmobile_otp { my($self) = shift; my($sfa) = @_; # This is the master parent object. $sfa->setmessage("Please enter your phone ($sfa->{method}->{display_name}) generated OTP"); $sfa->setcode(Sfa::SUCCESS); } # Code generated by the phone app or sms list sub checkpasscode { my($self) = shift; my($sfa) = @_; my($res); my($method) = $sfa->{method}->{method}; $sfa->setcode(Sfa::FAIL, "$method initialization failure"); my($passcode) = $sfa->getpasscode(); $self->details(); eval { $res = $self->json_api_call( 'POST', '/auth/v2/auth', { username => $sfa->{user}, factor => "passcode", passcode => $passcode, } ); }; if ($@) { chomp($@); $sfa->setcode(Sfa::FAIL, "HTTP error from api call ($@)"); return; } if ($res->{result} eq "allow") { $sfa->setcode(Sfa::SUCCESS); $sfa->setmessage("Duo $method authentication succeeded"); } else { $sfa->setcode(Sfa::FAIL, "Duo authentication failed ($res->{status_msg})"); } } sub beginsms { my($self) = shift; my($sfa) = @_; # This is the master parent object. my($res); # If the list command gave us sms_nextcode # for this user, we don't have to do anything # but prompt him to enter that code. if (exists($sfa->{method}->{sms_nextcode})) { $sfa->setmessage("Please enter the SMS code index number $sfa->{method}->{sms_nextcode}. (Indexes start at 1)"); $sfa->setcode(Sfa::SUCCESS); } else { # We will have to talk to the provider and request # a new list of codes be sent to the user. # Lets load up the hidden data keys. $self->details(); eval { $res = $self->json_api_call( 'POST', '/auth/v2/auth', { username => $sfa->{user}, factor => "sms", device => $sfa->{method}->{device}, } ); }; if ($@) { chomp($@); $sfa->setcode(Sfa::FAIL, "HTTP error from api call ($@)"); return; } # We expect a "deny" result, which we can ignode. $sfa->setmessage("A new list of codes has been sent via SMS to ($sfa->{method}->{display_name}. Please enter the first code (Index 1)"); $sfa->setcode(Sfa::SUCCESS); } } # Get the access details for the Duo service sub details { my($self) = shift; my($duofile) = __DIR__ . "/duo.json"; # The file my($fh) = IO::File->new(); if (!$fh->open($duofile, "<")) { $self->{ikey} = ""; $self->{skey} = ""; $self->{host} = ""; # This will cause the connection to fail to authenticate return; } my($map) = from_json(join("", $fh->getlines())); $self->{ikey} = $map->{ik}; $self->{skey} = $map->{sk}; $self->{host} = $map->{ho}; } # Rather than pull in an extra module for the Duo support, # I've just copied it in here. That is Duo::API.pm # Unneeded code, like new(), has been cut out with # =begin perforce type constructs. our $VERSION = '1.0'; =head1 NAME Duo::API - Reference client to call Duo Security's API methods. =head1 SYNOPSIS use Duo::API; my $client = Duo::API->new('INTEGRATION KEY', 'SECRET KEY', 'HOSTNAME'); my $res = $client->json_api_call('GET', '/auth/v2/check', {}); =head1 SEE ALSO Duo for Developers: L =head1 COPYRIGHT Copyright (c) 2013 Duo Security This program is free software; you can redistribute it and/or modify it under the same terms as Perl itself. =head1 DESCRIPTION Duo::API objects have the following methods: =over 4 =item new($integration_key, $integration_secret_key, $api_hostname) Returns a handle to sign and send requests. These parameters are obtained when creating an API integration. =item json_api_call($method, $path, \%params) Make a request to an API endpoint with the given HTTPS method and parameters. Returns the parsed result if successful or dies with the error message from the Duo Security service. =item api_call($method, $path, \%params) Make a request without parsing the response. =item canonicalize_params(\%params) Serialize a parameter hash reference to a string to sign or send. =item sign($method, $path, $canon_params, $date) Return the Authorization header for a request. C<$canon_params> is the string returned by L. =back =cut use CGI qw(); use Carp qw(croak); use Digest::HMAC_SHA1 qw(hmac_sha1_hex); =begin perforce use JSON qw(decode_json); =cut use LWP::UserAgent; use MIME::Base64 qw(encode_base64); use POSIX qw(strftime); sub canonicalize_params { my ($self, $params) = @_; my @ret; while (my ($k, $v) = each(%{$params})) { push(@ret, join('=', CGI::escape($k), CGI::escape($v))); } return join('&', sort(@ret)); } sub sign { my ($self, $method, $path, $canon_params, $date) = @_; my $canon = join("\n", $date, uc($method), lc($self->{'host'}), $path, $canon_params); my $sig = hmac_sha1_hex($canon, $self->{'skey'}); my $auth = join(':', $self->{'ikey'}, $sig); $auth = 'Basic ' . encode_base64($auth, ''); return $auth; } sub api_call { my ($self, $method, $path, $params) = @_; $params ||= {}; my $canon_params = $self->canonicalize_params($params); my $date = strftime('%a, %d %b %Y %H:%M:%S -0000', gmtime(time())); my $auth = $self->sign($method, $path, $canon_params, $date); my $ua = LWP::UserAgent->new(); my $req = HTTP::Request->new(); $req->method($method); $req->protocol('HTTP/1.1'); $req->header('If-SSL-Cert-Subject' => qr{CN=[^=]+\.duosecurity.com$}); $req->header('Authorization' => $auth); $req->header('Date' => $date); $req->header('Host' => $self->{'host'}); if (grep(/^$method$/, qw(POST PUT))) { $req->header('Content-type' => 'application/x-www-form-urlencoded'); $req->content($canon_params); } else { $path .= '?' . $canon_params; } $req->uri('https://' . $self->{'host'} . $path); if ($ENV{'DEBUG'}) { print STDERR $req->as_string(); } my $res = $ua->request($req); return $res; } sub json_api_call { my $self = shift; my $res = $self->api_call(@_); my $json = $res->content(); if ($json !~ /^{/) { croak($json); } my $ret = decode_json($json); if (($ret->{'stat'} || '') ne 'OK') { my $msg = join('', 'Error ', $ret->{'code'}, ': ', $ret->{'message'}); if (defined($ret->{'message_detail'})) { $msg .= ' (' . $ret->{'message_detail'} . ')'; } croak($msg); } return $ret->{'response'}; } 1;