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