#!/usr/bin/perl
# Copyright (c) 2017 Steven Haigh <netwiz@crc.id.au>
# https://www.crc.id.au
# Licensed under the GPL version 2

# PURPOSE: This script automatically can be used to handle authentication
# of Yubikey enabled accounts for One Time Password (OTP) use.

# INSTALL NOTES:
# Place this script in /etc/openvpn and use the following in your server
# configuration file:

# script-security 2
# client-connect          /etc/openvpn/yubikey-auth-tokens
# client-disconnect       /etc/openvpn/yubikey-auth-tokens
# auth-user-pass-verify   /etc/openvpn/yubikey-auth-tokens via-file
# client-cert-not-required

# If you wish to use Client Certificate + username + OTP for authentication
# exclude the 'client-cert-not-required' option and use any other guide on
# how to manage the certificate side of authentication.

# USAGE NOTES:
# To set up your Yubikey in Yubico OTP mode, use the personalisation tool
# in "Yubico OTP mode", and choose 'Quick' setup. Be sure to generate new
# values and then use the 'Upload to Yubico' to activate your key within
# the Yubico Validation Server.

# Edit the %yubikeys definition below to include your chosen username and
# the first 12 digits of one of your Yubico OTP passwords.

# If you have a Yubico API ID, you may substitute the $yubico_id value
# for your own, or use the default '16'.

# The token store specified in $tokenstore should point to a file in a
# directory that openvpn can write to. Tokens will be stored here between
# renegotions. The token will be deleted at last access + 2 hours, or
# when the client disconnects.

# NOTE: Tokens are only removed when something triggers a read of the
# tokens file. This can be a new client connecting, a renegotiation or
# a client disconnecting.

# Modified 2018 by Rob Johnson <rob.johnson@diffblue.com> to incorporate
# signature validation.

# ------------------ CODE BEGINS HERE -----------------
use strict;
use warnings;
use Fcntl qw( :flock );
use Storable qw( retrieve_fd store_fd );
use LWP::UserAgent 6;
use LWP::Protocol::https;
use MIME::Base64 qw( encode_base64 decode_base64 );
use Digest::HMAC_SHA1 qw( hmac_sha1 );
use URI::Escape qw( uri_escape );
use Data::Dumper;
use Time::Piece;
use Time::Seconds;
$Data::Dumper::Sortkeys = 1;

#To access the Yubico API, and also a secret for HMAC validation
my $yubico_id = '';
my $yubico_secret = '';
my %yubikeys = (
	# username          #yubikey ID
	''	=> ""
);
my $tokenstore = "/etc/openvpn/jail/token_store.bin";
my $ca_bundle = "/etc/ssl/certs/ca-certificates.crt";
my $debug = 0;
my $TIMEOUT_SECS = 10;

sub load_tokenstore()
{
	## If the token store doesn't exist, then create an empty one.
	if ( ! -f $tokenstore )
	{
		open ( my $fd, ">", $tokenstore );
		my %tokens = ();
		store_fd \%tokens, \*$fd;
		close $fd;
	}

	## Open the tokenstore and read in the contents.
	open ( my $fd, "+<", $tokenstore ) or die "Unable to open token store $tokenstore: $!\n";
	flock $fd, LOCK_EX;
	my %tokens = %{ retrieve_fd($fd) };

	## Expire tokens that should be expired.
	foreach my $key ( keys %tokens )
	{
		if ( $tokens{$key}{expires} < time )
		{
			delete $tokens{$key};
		}
	}

	if ( $debug )
	{ 
		print "Loaded Tokens:\n" . Dumper(\%tokens);
	}

	return $fd, %tokens;
}

sub write_tokenstore($\%)
{
	my $fd = shift;
	my $hashref = shift;
	seek $fd, 0, 0;
	store_fd $hashref, \*$fd;
	close $fd;
	if ( $debug )
	{ 
		print "Saved Tokens:\n" . Dumper($hashref);
	}
}

# if ( $debug )
# {
# 	print "DEBUG: Environment follows:\n" . Dumper(\%ENV);
# }

if ( $ENV{'script_type'} eq "user-pass-verify" )
{
	my $auth_file = $ARGV[0];

	## Read the auth file.
	open my $fh, '<', $auth_file;
	chomp(my @lines = <$fh>);
	close $fh;

	my $username = $lines[0];
	my $password = $lines[1];

	## Ensure we have a valid entry for the expected username.
	if ( ! defined( $yubikeys{$username} ) )
	{
		print "No yubikey enabled for $username\n";
		exit 1;
	}

	## If we have the start of a yubikey (first 12 characters are the static key ID), validate the OTP against yubico.
	if ( $password =~ m/^${yubikeys{$username}}/ )
	{
		## Check the yubikey entry is valid.
		if ($debug)
		{
			print "Attempting Yubikey authentication for user: $username\n";
		}
		my $ua = LWP::UserAgent->new;
		#Obviously make sure we're connecting to a known valid SSL host
		$ua->ssl_opts( verify_hostname => 1, SSL_ca_file => $ca_bundle );
		#Nonce generation - from https://www.codeproject.com/Articles/3681/Generating-quot-Random-quot-Strings-for-PERL-based
		my @alphanumeric = ('a'..'z', 'A'..'Z', 0..9);
		my $nonce = join '', map $alphanumeric[rand @alphanumeric], 16..40;

		my %request_parameters = ('id' => $yubico_id, 'otp' => $password, 'nonce' => $nonce, 'sl' => 'secure', 'timeout' => $TIMEOUT_SECS);
		my $query_string = join("&", map { "$_=$request_parameters{$_}" } sort keys %request_parameters);
		my $query_signature = uri_escape(encode_base64(hmac_sha1($query_string, decode_base64($yubico_secret))));
		$ua->timeout($TIMEOUT_SECS);
		my @api_sources = ('api', 'api2', 'api3', 'api4', 'api5');
		my $request_time = Time::Piece->new; #Now

		my $response;
		for my $api_source (@api_sources)
		{
			#Query string doesn't contain the signature, so append to the end
			my $api_url = "https://$api_source.yubico.com/wsapi/2.0/verify?$query_string&h=$query_signature";
			if ($debug)
			{
				print "Resolved query URL: $api_url\nRunning query...\n";
			}
			$response = $ua->get($api_url);
			if ($response)
			{
				last;
			}
		}
	
		## Only go further if the HTTP request was successful (returns 200). Actual YubiCloud validation follows
		if ( ! $response->is_success )
		{
			print "Error querying the YubiCo Servers: " . $response->status_line . " - " . $response->decoded_content . "\n";
			exit 1;
		}

		if ($debug)
		{
			print "DEBUG: YubiCloud response follows:\n" . $response->content;
		}

		my $content = $response->content;
		chomp $content; #Remove trailing whitespace, it's annoying

		#Split the string on newlines and then on the first = into key-value pairs, makes a neat hash
		my %response_parameters = map{split(/=/, $_, 2)}(split(/\R/, $content));

		## Allow access ONLY on an OK response - no point validating a failed response.
		if ( $response_parameters{'status'} eq "OK" )
		{
			## Verify the data returned from YubiCo.
			if ($response_parameters{'otp'} ne $password)
			{
				print "Error: OTPs do not match! Possible replay attack! Sent: $password, received: ".$response_parameters{'otp'}."\n";
				exit 1;
			}
			if ($response_parameters{'nonce'} ne $nonce)
			{
				print "Error: nonce values do not match! Possible replay attack! Sent: $nonce, received: ".$response_parameters{'nonce'}."\n";
				exit 1;
			}

			#Check the response was made from this actual request and is not a replay
			#Strip the Z off the end, it doesn't parse. Fortunately the time is in UTC already.
			my $response_time = $response_parameters{'t'} =~ s/Z.+$//r;
			my $parsed_time = Time::Piece->strptime($response_time, '%Y-%m-%dT%T');
			if ($debug)
			{
				print "DEBUG: parsed time as:\n".$parsed_time->strftime('%y/%m/%d %H:%M')."\n";
			}
			my $max_tolerance_seconds = $TIMEOUT_SECS * scalar(@api_sources); #e.g. 50 seconds
			if (abs($parsed_time - $request_time) > $max_tolerance_seconds)
			{
				print "Error: tolerance exceeded - response is greater than $max_tolerance_seconds seconds old. Possible replay attack!\n";
				exit 1;
			}

			#HMAC validation - same as above, except without URL-safe'ing.
			#Also need to remove the returned signature from the string as it obviously can't contain itself
			my $response_signature = $response_parameters{'h'};
			delete $response_parameters{'h'};
			my $response_string = join("&", map { "$_=$response_parameters{$_}" } sort keys %response_parameters);
			if ($debug)
			{
				print "DEBUG: response variable string:\n$response_string\n";
			}
			my $verify_signature = encode_base64(hmac_sha1($response_string, decode_base64($yubico_secret)));
			chomp $verify_signature;

			if ($debug)
			{
				print "DEBUG: calculated signature:\n$verify_signature\n";
			}

			if ($verify_signature ne $response_signature)
			{
				print "Error: signature in YubiCloud response is invalid!\n";
				exit 1;
			}

			#Otherwise, fully validated
			print "Yubikey authentication successful for user: $username\n";
			exit 0;
		}
		else
		{
			print "Yubikey authentication for user $username failed with result: ".$response_parameters{'status'}."\n";
			exit 1;
		}
	}

	## We may have a token, not an OTP... Check.
	if ( $password =~ m/^Token:/ )
	{
		print "Performing token authentication for user: $username\n";
		$password =~ s/^Token://g;

		## Load the token database.
		my ($tokenstore_fh, %tokens) = load_tokenstore(); 
		my $key = $ENV{'dev'} . '-' . $ENV{'trusted_ip'} . '-' . $ENV{'trusted_port'};
		$tokens{$key}{'expires'} = time + 7200; ## Push the expiry back another 2 hours
		write_tokenstore($tokenstore_fh, %tokens);

		if ( $password eq $tokens{$key}{'token'} )
		{
			print "Token authentication successful for user: $username\n";
			exit 0;
		}
		else
		{
			print "Token authentication failed for user: $username\n";
			exit 1;
		}
	}

	## Fall back to an authentication failed error.
	print "Authentication failed for user $username\n";
	exit 1;
} 

## Handle generation of the auth-token to be pushed to the client.
elsif ( $ENV{'script_type'} eq "client-connect" )
{
	my $key = $ENV{'dev'} . '-' . $ENV{'trusted_ip'} . '-' . $ENV{'trusted_port'};
	
	## Create our random data.
	open ( my $random, "<", "/dev/random");
	read $random, my $random_data, 128;
	close $random;
	my $token = encode_base64($random_data, "");

	my $push = 'push "auth-token Token:' . $token . '"' . "\n";
	print "Pushing Auth-Token: $token\n";

	## Add the token to the token store.
	my ($tokenstore_fh, %tokens) = load_tokenstore(); 
	$tokens{$key}{'token'} = $token;
	$tokens{$key}{'expires'} = time + 7200; ## Expire in now + 2 hours.
	write_tokenstore($tokenstore_fh, %tokens);

	open my $fh, '>', $ARGV[0];
	print $fh $push . "\n";
	close $fh;
	exit 0;
}

## Handle client disconnects by nuking the token.
elsif ( $ENV{'script_type'} eq "client-disconnect" )
{
	print "Handling client disconnect. Nuking token\n";
	my $key = $ENV{'dev'} . '-' . $ENV{'trusted_ip'} . '-' . $ENV{'trusted_port'};
	my ($tokenstore_fh, %tokens) = load_tokenstore(); 
	if ( defined( $tokens{$key} ) ) {
		delete $tokens{$key};
	}
	write_tokenstore($tokenstore_fh, %tokens);
	exit 0;
}

else
{
	print "We should never get here unless we have the wrong config!\n";
	exit 1;
}

exit 1;
