File Coverage

blib/lib/POE/Component/YubiAuth.pm
Criterion Covered Total %
statement 43 101 42.5
branch 0 22 0.0
condition 0 23 0.0
subroutine 14 25 56.0
pod 1 1 100.0
total 58 172 33.7


line stmt bran cond sub pod time code
1             package POE::Component::YubiAuth;
2              
3 1     1   21484 use feature ':5.10';
  1         2  
  1         99  
4 1     1   6 use warnings;
  1         2  
  1         26  
5 1     1   4 use strict;
  1         5  
  1         73  
6              
7             =head1 NAME
8              
9             POE::Component::YubiAuth - Use Yubico Web Service API to verify YubiKey one time passwords.
10              
11             =cut
12              
13             our $VERSION = '0.08';
14              
15              
16             =head1 SYNOPSIS
17              
18             POE::Component::YubiAuth uses Yubico's public Web Service API to verify One Time Passwords
19             generated by Yubikey.
20              
21             use POE::Component::YubiAuth;
22              
23             # Get your API id and key at https://api.yubico.com/get-api-key/
24             POE::Component::YubiAuth->spawn('', '');
25              
26             # subref as callback
27             POE::Kernel->post('yubi', 'verify', '', sub {
28             print Data::Dumper::Dumper($_[0]);
29             });
30              
31             # session event as callback
32             POE::Session->create(
33             inline_states => {
34             _start => sub {
35             my ($kernel, $heap) = @_[KERNEL, HEAP];
36             $kernel->post('yubi', 'verify', '', 'dump');
37             $kernel->alias_set('foo');
38             },
39             dump => sub {
40             my ($kernel, $heap) = @_[KERNEL, HEAP];
41             print Data::Dumper::Dumper($_[ARG0]);
42             $kernel->alias_remove;
43             $kernel->post('yubi', 'shutdown');
44             },
45             }
46             );
47             POE::Kernel->run();
48              
49             =head1 CONSTRUCTOR
50              
51             =head2 POE::Component::YubiAuth->spawn(, )
52              
53             spawn() takes Yubico API ID and API key as parameters and spawns a
54             POE session named 'yubi' that will respond to various events later:
55              
56             POE::Kernel->post('yubi', 'verify', ...);
57             or
58             $kernel->post('yubi', 'shutdown');
59              
60             Verification requests will be signed with the API key.
61              
62             =cut
63              
64             =head1 EVENTS
65              
66             =head2 verify
67              
68             verify() takes three parameters - One Time Password, a callback event and optional
69             callback data.
70              
71             Callback can be a subroutine reference or a name of a POE event in the current
72             session. Examples on how to use both types of callbacks are provided in the
73             SYNOPSIS.
74              
75             POE::Kernel->post('yubi', 'verify', '', sub {
76             print Data::Dumper::Dumper(\@_);
77             }, 'some callback data');
78              
79             The callback will receive a hash reference with response from Yubico's server
80             and the provided callback data, which may be used to identify the response. For
81             caller's convenience, Yubikey's id extracted from the one time password is added to
82             the hash under the name 'keyid'.
83              
84             If the 'status' key in the response has the value 'OK', then the
85             verification process was successfull. If callback receives an undefined value
86             instead of a hash reference, then some strange error has occured (e.g. no
87             connection to the Yubico's server).
88              
89             Please visit http://www.yubico.com/developers/api/ for more information.
90              
91             =head2 shutdown
92              
93             shutdown() terminates the 'yubi' session.
94              
95             =cut
96              
97              
98             =head1 AUTHOR
99              
100             Kirill Miazine, C<< >>
101              
102             =head1 COPYRIGHT & LICENSE
103              
104             Copyright 2010 Kirill Miazine.
105              
106             This software is distributed under an ISC-style license, please see
107             for details.
108              
109             =cut
110              
111 1     1   1041 use POE::Session;
  1         6706  
  1         7  
112 1     1   1211 use POE::Component::Client::HTTP;
  1         481418  
  1         31  
113              
114 1     1   782 use HTTP::Request;
  1         972  
  1         30  
115 1     1   6 use URI::Escape qw(uri_escape);
  1         2  
  1         82  
116 1     1   858 use MIME::Base64 qw(encode_base64 decode_base64);
  1         4552  
  1         102  
117 1     1   774 use Digest::HMAC_SHA1 qw(hmac_sha1);
  1         7510  
  1         75  
118 1     1   812 use String::Random qw(random_string);
  1         4181  
  1         71  
119 1     1   6 use List::Util qw(shuffle);
  1         2  
  1         121  
120              
121 1     1   5 use constant API_URLS => map { sprintf('http://api%s.yubico.com/wsapi/2.0/verify', $_) } ('', 2..5);
  1         2  
  1         2  
  5         98  
122 1     1   5 use constant PARALLEL => 5;
  1         2  
  1         82  
123 1         1363 use constant STATUSMAP => (
124             OK => 'The OTP is valid.',
125             BAD_OTP => 'The OTP is invalid format.',
126             REPLAYED_OTP => 'The OTP has already been seen by the service.',
127             BAD_SIGNATURE => 'The HMAC signature verification failed.',
128             MISSING_PARAMETER => 'The request lacks a parameter.',
129             NO_SUCH_CLIENT => 'The request id does not exist.',
130             OPERATION_NOT_ALLOWED => 'The request id is not allowed to verify OTPs.',
131             BACKEND_ERROR => 'Unexpected error in our server. Please contact us if you see this error.',
132             NOT_ENOUGH_ANSWERS => 'Server could not get requested number of syncs during before timeout.',
133             REPLAYED_REQUEST => 'Server has seen the OTP/Nonce combination before.',
134 1     1   5 );
  1         2  
135              
136             sub spawn {
137 0     0 1   my $proto = shift;
138 0   0       my $class = ref($proto) || $proto;
139              
140 0 0         my $id = shift or die "Yubico ID is required\n";
141 0 0         my $key = shift or die "Yubico key is required\n";;
142 0   0       my $parallel = int(shift || PARALLEL);
143 0 0         $parallel = 1 if $parallel < 1;
144 0 0         $parallel = PARALLEL if $parallel > PARALLEL;
145              
146             POE::Session->create(
147             inline_states => {
148             _start => sub {
149 0     0     my ($kernel, $heap) = @_[KERNEL, HEAP];
150 0           $kernel->alias_set('yubi');
151 0           POE::Component::Client::HTTP->spawn(Alias => '_yubi_ua', Timeout => 10);
152             },
153       0     _stop => sub { },
154             shutdown => sub {
155 0     0     my ($kernel, $heap) = @_[KERNEL, HEAP];
156 0           $kernel->post('_yubi_ua', 'shutdown');
157 0           $kernel->alias_remove();
158             },
159             verify => sub {
160 0     0     my ($kernel, $sender, $heap) = @_[KERNEL, SENDER, HEAP];
161 0           my ($otp, $callback, $callback_data) = @_[ARG0, ARG1, ARG2];
162 0           my $nonce = random_string('c' x 40);
163             my $query_string = _signedq(
164             $heap->{'key'},
165 0           id => $heap->{'id'},
166             otp => $otp,
167             nonce => $nonce,
168             timestamp => 1,
169             sl => 42,
170             timeout => undef,
171             );
172 0           $heap->{'pending'} = 0;
173 0           for my $url ((shuffle(API_URLS))[0..($heap->{'parallel'}-1)]) {
174 0           my $req = HTTP::Request->new(GET => "$url?$query_string");
175 0 0         $heap->{'pending'}++ if $kernel->post('_yubi_ua', 'request', '_collect', $req,
176             [$otp, $nonce, $sender, $callback, $callback_data]);
177             }
178             },
179             _collect => sub {
180 0     0     my ($kernel, $heap) = @_[KERNEL, HEAP];
181 0           my ($req, $res) = map { $_->[0] } @_[ARG0, ARG1];
  0            
182 0           my ($otp, $nonce, $sender, $callback, $callback_data) = @{$_[ARG0]->[1]};
  0            
183 0           $heap->{'pending'}--;
184 0 0         if ($res->is_success) {
185 0           my %p = _resp2p($res->content);
186 0           my $h = delete $p{'h'};
187 0           my $sig = _b64hmacsig(_sortedq(%p), decode_base64($heap->{'key'}));
188             $heap->{'results'} = \%p
189             if defined $p{'otp'} and $p{'otp'} eq $otp and
190             defined $p{'nonce'} and $p{'nonce'} eq $nonce and
191 0 0 0       defined $p{'status'} and $p{'status'} eq 'OK' and
      0        
      0        
      0        
      0        
      0        
192             $h eq $sig;
193             }
194             $kernel->yield('_verify', $sender, $callback, $callback_data)
195 0 0         if $heap->{'pending'} == 0;
196             },
197             _verify => sub {
198 0     0     my ($kernel, $heap) = @_[KERNEL, HEAP];
199 0           my ($sender, $callback, $callback_data) = @_[ARG0, ARG1, ARG2];
200              
201 0           my $args = $heap->{'results'};
202 0 0         $args->{'keyid'} = substr $args->{'otp'}, 0, 12 if defined $args;
203 0 0         if (ref $callback eq 'CODE') {
    0          
204 0           $callback->($args, $callback_data);
205             } elsif (ref $callback) {
206 0           $callback->postback->($args, $callback_data);
207             } else {
208 0           $kernel->post($sender, $callback, $args, $callback_data);
209             }
210              
211              
212             }
213             },
214 0           heap => {id => $id, key => $key, parallel => $parallel},
215             );
216             }
217              
218             # helpers
219             sub _sortedq {
220 0     0     my %p = @_; join('&', map { join('=', $_, uri_escape($p{$_}, '^A-Za-z0-9:._~-')) }
  0            
221 0           sort grep { defined $p{$_} } keys %p);
  0            
222             }
223              
224             sub _b64hmacsig {
225 0     0     encode_base64(hmac_sha1(@_), '');
226             }
227              
228             sub _signedq {
229 0     0     my $key = shift;
230 0           my $q = _sortedq(@_);
231             # avoid BAD_SIGNATURE,
232             # as in http://code.google.com/p/php-yubico/source/browse/trunk/Yubico.php
233 0           (my $h = _b64hmacsig($q, decode_base64($key))) =~ s/\+/%2B/g;
234 0           return "$q&h=$h";
235             }
236              
237             sub _resp2p {
238 0     0     my $p = {map { split /=/, $_, 2 } grep { /=/ } map { s/(^\s*|\s*$)//g; $_ } split /\r?\n/, $_[0]};
  0            
  0            
  0            
  0            
239 0           return map { $_ => $p->{$_} } qw(otp nonce h t status timestamp sessioncounter sessionuse sl);
  0            
240             }
241              
242             1; # End of POE::Component::YubiAuth