Group
Extension

Net-OpenVPN-Manager/lib/Net/OpenVPN/Manager/Plugin/TOTP.pm

package Net::OpenVPN::Manager::Plugin::TOTP;

use utf8;
use namespace::autoclean;
use Moose;
use Authen::OATH;
use Convert::Base32 qw(encode_base32 decode_base32);
use AnyEvent::Util qw(fork_call);
use Net::OpenVPN::Manager::Plugin;
use JSON;

with 'Net::OpenVPN::Manager::Startable';
with 'Net::OpenVPN::Manager::2Authenticable';
with 'Net::OpenVPN::Manager::Challengable';
with 'Net::OpenVPN::Manager::Disconnectable';

has '+order' => (
    default => 10,
);

has 'digits' => (
    is => 'ro',
    isa => 'Int',
    default => 6,
);

has 'period' => (
    is => 'ro',
    isa => 'Int',
    default => 30,
);

has 'required' => (
    is => 'ro',
    isa => 'Int',
    default => 0,
);

has 'trust_delay' => (
    is => 'ro',
    isa => 'Int',
    default => 0,
);

has 'sliding_window' => (
    is => 'ro',
    isa => 'Int',
    default => 0,
);

has 'pendings' => (
    is => 'ro',
    isa => 'HashRef',
    default => sub { {} },
);

sub generate_base32 {
    return uc(encode_base32(pack ("n10",
        rand(65536), rand(65536), rand(65536), rand(65536), rand(65536),
        rand(65536), rand(65536), rand(65536), rand(65536), rand(65536))));
}

sub start {
    my $self = shift;
    my $cv = AE::cv;

    $self->manager->add_dispatcher(
        sub {
            my $env = shift;

            '/api/totp/...' => sub {
                '' => sub {
                    $self->manager->middleware_apikey;
                },
                'GET + /token/*' => sub {
                    shift;
                    my ($username) = @_;

                    [sub {
                        my $responder = shift;
                        $self->manager->dbh->exec('SELECT activated FROM otp WHERE username=?;', $username, sub {
                            my ($dbh, $rows, $rv) = @_;
                            if (scalar(@$rows) == 0) {
                                my $secret = generate_base32;
                                my ($digits, $period) = ($self->digits, $self->period);
                                $self->manager->dbh->exec('INSERT INTO otp (username, secret) VALUES(?,?);', $username, $secret, sub {
                                    my $res ="otpauth://totp/UPPA%20VPN:$username?secret=$secret&algorithm=SHA1&digits=$digits&period=$period";
                                    $responder->([ 200, [ 'Content-type', 'application/json' ], [ to_json({"uri" => $res}) ] ]);
                                });
                            } elsif (!$rows->[0]->[0]) {
                                my $secret = generate_base32;
                                my ($digits, $period) = ($self->digits, $self->period);
                                $self->manager->dbh->exec('UPDATE otp SET secret=? WHERE username=?;', $secret, $username, sub {
                                    my $res ="otpauth://totp/UPPA%20VPN:$username?secret=$secret&algorithm=SHA1&digits=$digits&period=$period";
                                    $responder->([ 200, [ 'Content-type', 'application/json' ], [ to_json({"uri" => $res}) ] ]);
                                });
                            } else {
                                $responder->([
                                    403,
                                    [ 'Content-type', 'application/json' ],
                                    [ to_json({"error" => "token exist for user $username"}) ] 
                                ]);
                            }
                        });
                    }]
                },
                'GET + /hastoken/*' => sub {
                    shift;
                    my ($username) = @_;

                    [sub {
                        my $responder = shift;
                        $self->manager->dbh->exec('SELECT secret FROM otp WHERE username=? AND activated=TRUE;', $username, sub {
                            my ($dbh, $rows, $rv) = @_;
                            $responder->([ 200, [ 'Content-type', 'application/json' ], [ to_json({"hastoken" => scalar(@$rows) ? \1 : \0}) ] ]);
                        });
                    }]
                },
                'DELETE + /token/*' => sub {
                    shift;
                    my ($username) = @_;

                    [sub {
                        my $responder = shift;
                        $self->manager->dbh->exec('DELETE FROM otp WHERE username=?;', $username, sub {
                            my ($dbh, $rows, $rv) = @_;
                            if ($rv >= 1) {
                                $responder->([ 200, [ 'Content-type', 'application/json' ], [ to_json({"status" => \1}) ] ]);
                            } else {
                                $responder->([
                                    404,
                                    [ 'Content-type', 'application/json' ],
                                    [ to_json({"error" => "token does not exist for user $username"}) ] 
                                ]);
                            }
                        });
                    }]
                },
                'GET + /testtoken/*/*' => sub {
                    shift;
                    my ($username, $code) = @_;

                    [sub {
                        my $responder = shift;
                        $self->_validate_code($username, $code)->cb(sub {
                            my $res = shift->recv;
                            $responder->([ 200, [ 'Content-type', 'application/json' ], [ to_json({"validated" => $res ? \1 : \0}) ] ]);
                        });
                    }]
                },
            },
        }
    );

    $self->manager->dbh->exec('CREATE TABLE IF NOT EXISTS otp(username VARCHAR(64) NOT NULL PRIMARY KEY, secret VARCHAR(32) NOT NULL, activated BOOLEAN DEFAULT FALSE, lastsuccesstime INTEGER, lastsuccessip VARCHAR(64));', sub {
        $cv->send(PLUG_OK);
    });

    $cv
}

sub authenticate_phase2 {
    my ($self, $client) = @_;
    my $cv = AE::cv;
    my ($password, $challenge) = $client->password;

    $self->manager->dbh->exec('SELECT secret, activated, lastsuccesstime, lastsuccessip FROM otp WHERE username=?;', $client->username, sub {
        my ($dbh, $rows, $rv) = @_;

        my $hascrtext = $client->get_env('IV_SSO') =~ /crtext/;
        my $hastotp = scalar(@$rows) == 1;
        my $haschallenge = $hascrtext || $challenge;

        # handle errors
        if ($hastotp && !$haschallenge) {
            $self->log("TOTP defined but no static challenge or crtext support", $client);
            return $cv->send(PLUG_ERROR);
        } elsif (!$hastotp && $challenge) {
            $self->log("TOTP secret not found but static challenge provided", $client);
            return $cv->send(PLUG_ERROR);
        } elsif ($self->required && (!$hastotp && !$haschallenge)) {
            $self->log("TOTP required but not set or not challengable", $client);
            return $cv->send(PLUG_ERROR);
        } elsif (!$hastotp) {
            $self->log("No TOTP secret, ignoring it", $client);
            return $cv->send(PLUG_NOOP);
        }

        my ($secret, $activated, $lasttime, $lastip) = @{$rows->[0]};

        if ($activated == 0) {
            $self->log("TOTP secret not activated", $client);
            return $cv->send(PLUG_ERROR);
        }

        # trust client ?
        if ($self->trust_delay && $lasttime && $lastip && (time() - $lasttime < $self->trust_delay) && $lastip eq $client->ip) {
            $self->log("TOTP not required because trusted by client ip and last verification time", $client);
            if ($self->sliding_window) {
                $self->manager->dbh->exec('UPDATE otp SET lastsuccesstime = ? WHERE username=?;', time(), $client->username, sub {
                    $cv->send(PLUG_OK);
                });
            } else {
                $cv->send(PLUG_OK);
            }
            return;
        }

        # handle static or dynamic challange
        if ($challenge) {
            $self->_check_response($challenge, $secret, sub {
                my $res = shift->recv;
                $self->log($res ? "code validated" : "bad TOTP code", $client);
                $cv->send($res ? PLUG_OK : PLUG_ERROR);
            });
        } else {
            my $pendingcv = AE::cv;
            $self->pendings->{$client} = $pendingcv;
            return $cv->send([$pendingcv, "CR_TEXT:ER:TOTP", $self->period*2]);
        }
    });

    $cv
}

sub challenge {
    my ($self, $client, $challenge) = @_;

    my $cv = $self->pendings->{$client};

    if ($cv) {
        delete $self->pendings->{$client};
        $self->manager->dbh->exec('SELECT secret FROM otp WHERE username=? AND activated=TRUE;', $client->username, sub {
            my ($dbh, $rows, $rv) = @_;

            if (scalar(@$rows) != 1) {
                $self->log("TOTP secret not found or not activated", $client);
                return $cv->send(PLUG_ERROR);
            }

            my $secret = $rows->[0]->[0];

            $self->_check_response($challenge, $secret, sub {
                my $res = shift->recv;

                $self->log($res ? "code validated" : "bad TOTP code", $client);
                return $cv->send(PLUG_ERROR) unless($res);

                $self->manager->dbh->exec('UPDATE otp SET lastsuccesstime = ?, lastsuccessip = ? WHERE username=?;', time(), $client->ip, $client->username, sub {
                    $cv->send(PLUG_OK);
                });
            });
        });

        return PLUG_OK;
    }

    $cv->send(PLUG_NOOP);
    return PLUG_NOOP;
}

sub disconnect {
    my ($self, $client, $challenge) = @_;

    my $cv = $self->pendings->{$client};

    if ($cv) {
        $self->log("deleting unresolved pending request");
        $cv->cb(undef);
        delete $self->pendings->{$client};
        return PLUG_OK;
    }

    return PLUG_NOOP;
}

sub _validate_code {
    my ($self, $username, $challenge) = @_;
    my $cv = AE::cv;

    $self->manager->dbh->exec('SELECT secret FROM otp WHERE username=?;', $username, sub {
        my ($dbh, $rows, $rv) = @_;

        if (scalar(@$rows) != 1) {
            return $cv->send(0);
        }

        my $secret = $rows->[0]->[0];

        $self->_check_response($challenge, $secret, sub {
            my $res = shift->recv;
            return $cv->send(0) unless ($res);

            $self->manager->dbh->exec('UPDATE otp SET activated=TRUE WHERE username=?;', $username, sub {
                $cv->send(1);
            });
        });
    });

    $cv
}

sub _check_response {
    my ($self, $challenge, $secret, $cb) = @_;
    my $cv = AE::cv;
    $cv->cb($cb);

    fork_call {
        my $oath = Authen::OATH->new(digits => $self->digits, timestep => $self->period);
        my $totp = $oath->totp(decode_base32($secret));
        return ($totp eq $challenge);
    } sub {
        $cv->send($_[0]);
    };
}

1;

__END__

=head1 NAME

Net::OpenVPN::Manager::Plugin::TOTP - TOTP authentication plugin for openvpn-manager

=head1 SYNOPSIS

=head1 Attributes

=head2 digits

Number of digits for the TOTP password (default to 6).

=head2 period

Validity in seconds for a TOTP password (default to 30).

=head2 required

Force a user to provide a TOTP password (default to 0).

=head2 trust_delay

Don't ask a TOTP password if the user connects from the same IP within this delay in seconds (default to 0).

0 means the client is never trusted.

=head2 sliding_window

Reset the trust delay each time the user connects successfully.

=cut


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.