package POE::Component::YubiAuth;

use feature ':5.10';
use warnings;
use strict;

=head1 NAME

POE::Component::YubiAuth - Use Yubico Web Service API to verify YubiKey one time passwords.

=cut

our $VERSION = '0.09';


=head1 SYNOPSIS

POE::Component::YubiAuth uses Yubico's public Web Service API to verify One Time Passwords
generated by Yubikey.

    use POE::Component::YubiAuth;

    # Get your API id and key at https://api.yubico.com/get-api-key/
    POE::Component::YubiAuth->spawn('<api id>', '<api key>');

    # subref as callback
    POE::Kernel->post('yubi', 'verify', '<otp data>', sub {
        print Data::Dumper::Dumper($_[0]);
    });

    # session event as callback
    POE::Session->create(
        inline_states => {
            _start => sub {
                my ($kernel, $heap) = @_[KERNEL, HEAP];
                $kernel->post('yubi', 'verify', '<otp data>', 'dump');
                $kernel->alias_set('foo');
            },
            dump => sub {
                my ($kernel, $heap) = @_[KERNEL, HEAP];
                print Data::Dumper::Dumper($_[ARG0]);
                $kernel->alias_remove;
                $kernel->post('yubi', 'shutdown');
            },
        }
    );
    POE::Kernel->run();

=head1 CONSTRUCTOR

=head2 POE::Component::YubiAuth->spawn(<api id>, <api key>)

spawn() takes Yubico API ID and API key as parameters and spawns a
POE session named 'yubi' that will respond to various events later:

    POE::Kernel->post('yubi', 'verify', ...);
        or
    $kernel->post('yubi', 'shutdown');

Verification requests will be signed with the API key.

=cut

=head1 EVENTS

=head2 verify

verify() takes three parameters - One Time Password, a callback event and optional
callback data.

Callback can be a subroutine reference or a name of a POE event in the current
session. Examples on how to use both types of callbacks are provided in the
SYNOPSIS.

    POE::Kernel->post('yubi', 'verify', '<otp data>', sub {
        print Data::Dumper::Dumper(\@_);
    }, 'some callback data');

The callback will receive a hash reference with response from Yubico's server
and the provided callback data, which may be used to identify the response. For
caller's convenience, Yubikey's id extracted from the one time password is added to
the hash under the name 'keyid'.

If the 'status' key in the response has the value 'OK', then the
verification process was successfull. If callback receives an undefined value
instead of a hash reference, then some strange error has occured (e.g. no
connection to the Yubico's server).

Please visit http://www.yubico.com/developers/api/ for more information.

=head2 shutdown

shutdown() terminates the 'yubi' session.

=cut


=head1 AUTHOR

Kirill Miazine, C<< <km@krot.org> >>

=head1 COPYRIGHT & LICENSE

Copyright 2010 Kirill Miazine.

This software is distributed under an ISC-style license, please see
<http://km.krot.org/code/license.txt> for details.

=cut

use POE::Session;
use POE::Component::Client::HTTP;

use HTTP::Request;
use URI::Escape qw(uri_escape);
use MIME::Base64 qw(encode_base64 decode_base64);
use Digest::HMAC_SHA1 qw(hmac_sha1);
use String::Random qw(random_string);
use List::Util qw(shuffle);

use constant API_URLS => map { sprintf('http://api%s.yubico.com/wsapi/2.0/verify', $_) } ('', 2..5);
use constant PARALLEL => 5;
use constant STATUSMAP => (
    OK => 'The OTP is valid.',
    BAD_OTP => 'The OTP is invalid format.',
    REPLAYED_OTP => 'The OTP has already been seen by the service.',
    BAD_SIGNATURE => 'The HMAC signature verification failed.',
    MISSING_PARAMETER => 'The request lacks a parameter.',
    NO_SUCH_CLIENT => 'The request id does not exist.',
    OPERATION_NOT_ALLOWED => 'The request id is not allowed to verify OTPs.',
    BACKEND_ERROR => 'Unexpected error in our server. Please contact us if you see this error.',
    NOT_ENOUGH_ANSWERS => 'Server could not get requested number of syncs during before timeout.',
    REPLAYED_REQUEST => 'Server has seen the OTP/Nonce combination before.',
);

sub spawn {
    my $proto = shift;
    my $class = ref($proto) || $proto;

    my $id = shift or die "Yubico ID is required\n";
    my $key = shift or die "Yubico key is required\n";;
    my $parallel = int(shift || PARALLEL);
    $parallel = 1 if $parallel < 1;
    $parallel = PARALLEL if $parallel > PARALLEL;

    POE::Session->create(
        inline_states => {
            _start => sub {
                my ($kernel, $heap) = @_[KERNEL, HEAP];
                $kernel->alias_set('yubi');
                POE::Component::Client::HTTP->spawn(Alias => '_yubi_ua', Timeout => 10);
            },
            _stop => sub { },
            shutdown => sub {
                my ($kernel, $heap) = @_[KERNEL, HEAP];
                $kernel->post('_yubi_ua', 'shutdown');
                $kernel->alias_remove();
            },
            verify => sub {
                my ($kernel, $sender, $heap) = @_[KERNEL, SENDER, HEAP];
                my ($otp, $callback, $callback_data) = @_[ARG0, ARG1, ARG2];
                my $nonce = random_string('c' x 40);
                my $query_string = _signedq(
                    $heap->{'key'},
                    id => $heap->{'id'},
                    otp => $otp,
                    nonce => $nonce,
                    timestamp => 1,
                    sl => 42,
                    timeout => undef,
                );
                $heap->{'pending'} = 0;
                for my $url ((shuffle(API_URLS))[0..($heap->{'parallel'}-1)]) {
                    my $req = HTTP::Request->new(GET => "$url?$query_string");
                    $heap->{'pending'}++ if $kernel->post('_yubi_ua', 'request', '_collect', $req,
                        [$otp, $nonce, $sender, $callback, $callback_data]);
                }
            },
            _collect => sub {
                my ($kernel, $heap) = @_[KERNEL, HEAP];
                my ($req, $res) = map { $_->[0] } @_[ARG0, ARG1];
                my ($otp, $nonce, $sender, $callback, $callback_data) = @{$_[ARG0]->[1]};
                $heap->{'pending'}--;
                if ($res->is_success) {
                    my %p = _resp2p($res->content);
                    my $h = delete $p{'h'};
                    my $sig = _b64hmacsig(_sortedq(%p), decode_base64($heap->{'key'}));
                    $heap->{'results'} = \%p
                        if defined $p{'otp'} and $p{'otp'} eq $otp and
                           defined $p{'nonce'} and $p{'nonce'} eq $nonce and
                           defined $p{'status'} and $p{'status'} eq 'OK' and
                           $h eq $sig;
                }
                $kernel->yield('_verify', $sender, $callback, $callback_data)
                    if $heap->{'pending'} == 0;
            },
            _verify => sub {
                my ($kernel, $heap) = @_[KERNEL, HEAP];
                my ($sender, $callback, $callback_data) = @_[ARG0, ARG1, ARG2];

                my $args = $heap->{'results'};
                $args->{'keyid'} = substr $args->{'otp'}, 0, 12 if defined $args;
                if (ref $callback eq 'CODE') {
                    $callback->($args, $callback_data);
                } elsif (ref $callback) {
                    $callback->postback->($args, $callback_data);
                } else {
                    $kernel->post($sender, $callback, $args, $callback_data);
                }


            }
        },
        heap => {id => $id, key => $key, parallel => $parallel},
    );
}

# helpers
sub _sortedq {
    my %p = @_; join('&', map { join('=', $_, uri_escape($p{$_}, '^A-Za-z0-9:._~-')) }
                               sort grep { defined $p{$_} } keys %p);
}

sub _b64hmacsig {
    encode_base64(hmac_sha1(@_), '');
}

sub _signedq {
    my $key = shift;
    my $q = _sortedq(@_);
    # avoid BAD_SIGNATURE,
    # as in http://code.google.com/p/php-yubico/source/browse/trunk/Yubico.php
    (my $h = _b64hmacsig($q, decode_base64($key))) =~ s/\+/%2B/g;
    return "$q&h=$h";
}

sub _resp2p {
    my $p = {map { split /=/, $_, 2 } grep { /=/ } map { s/(^\s*|\s*$)//g; $_ } split /\r?\n/, $_[0]};
    return map { $_ => $p->{$_} } qw(otp nonce h t status timestamp sessioncounter sessionuse sl);
}

1; # End of POE::Component::YubiAuth
