349 lines
10 KiB
Perl
349 lines
10 KiB
Perl
# Self WebAuthn registration
|
||
package Lemonldap::NG::Portal::2F::Register::WebAuthn;
|
||
|
||
use strict;
|
||
use Mouse;
|
||
use JSON qw(from_json to_json);
|
||
use MIME::Base64 qw(encode_base64url decode_base64url);
|
||
use Crypt::URandom;
|
||
|
||
our $VERSION = '2.0.15';
|
||
|
||
extends 'Lemonldap::NG::Portal::2F::Register::Base';
|
||
with 'Lemonldap::NG::Portal::Lib::WebAuthn';
|
||
|
||
# INITIALIZATION
|
||
|
||
has prefix => ( is => 'rw', default => 'webauthn' );
|
||
has template => ( is => 'ro', default => 'webauthn2fregister' );
|
||
has welcome => ( is => 'ro', default => 'webauthn2fWelcome' );
|
||
has logo => ( is => 'rw', default => 'webauthn.png' );
|
||
has ott => (
|
||
is => 'ro',
|
||
lazy => 1,
|
||
default => sub {
|
||
my $ott =
|
||
$_[0]->{p}->loadModule('Lemonldap::NG::Portal::Lib::OneTimeToken');
|
||
my $timeout = $_[0]->{conf}->{sfRegisterTimeout}
|
||
// $_[0]->{conf}->{formTimeout};
|
||
$ott->timeout($timeout);
|
||
return $ott;
|
||
}
|
||
);
|
||
|
||
has displayname_attr => (
|
||
is => 'rw',
|
||
lazy => 1,
|
||
default => sub {
|
||
my $self = shift;
|
||
$self->conf->{webauthnDisplayNameAttr}
|
||
|| $self->conf->{portalUserAttr}
|
||
|| $self->conf->{whatToTrace}
|
||
|| '_user';
|
||
}
|
||
);
|
||
|
||
has rpName => (
|
||
is => 'rw',
|
||
lazy => 1,
|
||
default => sub {
|
||
my $self = shift;
|
||
$self->conf->{webauthnRpName} || "LemonLDAP::NG";
|
||
}
|
||
);
|
||
|
||
# RUNNING METHODS
|
||
|
||
# Return a Base64url encoded user handle
|
||
sub getRegistrationUserHandle {
|
||
my ( $self, $req ) = @_;
|
||
|
||
my $current_user_handle = $self->getUserHandle( $req, $req->userData );
|
||
if ($current_user_handle) {
|
||
return $current_user_handle;
|
||
}
|
||
else {
|
||
my $new_user_handle = $self->_generate_user_handle;
|
||
$self->setUserHandle( $req, $new_user_handle );
|
||
return $new_user_handle;
|
||
}
|
||
}
|
||
|
||
# https://www.w3.org/TR/webauthn-2/#sctn-user-handle-privacy
|
||
# It is RECOMMENDED to let the user handle be 64 random bytes, and store this
|
||
# value in the user’s account.
|
||
sub _generate_user_handle {
|
||
my ($self) = @_;
|
||
return encode_base64url( Crypt::URandom::urandom(64) );
|
||
}
|
||
|
||
sub _registrationchallenge {
|
||
my ( $self, $req, $user ) = @_;
|
||
|
||
my @alldevices = $self->find2fDevicesByType( $req, $req->userData );
|
||
my $size = @alldevices;
|
||
my $maxSize = $self->conf->{max2FDevices};
|
||
$self->logger->debug("Registered 2F Device(s): $size / $maxSize");
|
||
if ( $size >= $maxSize ) {
|
||
$self->userLogger->warn("Max number of 2F devices is reached");
|
||
return $self->p->sendError( $req, 'maxNumberof2FDevicesReached', 400 );
|
||
}
|
||
|
||
my $challenge_base64 = encode_base64url( Crypt::URandom::urandom(32) );
|
||
|
||
# Challenge is persisted on the server
|
||
my $token = $self->ott->createToken( {
|
||
registration_options => {
|
||
challenge => $challenge_base64,
|
||
}
|
||
}
|
||
);
|
||
|
||
my $displayName = $req->userData->{ $self->displayname_attr } || $user;
|
||
my $userVerification = $self->conf->{webauthn2fUserVerification};
|
||
|
||
my $request = {
|
||
rp => {
|
||
name => $self->rpName,
|
||
},
|
||
user => {
|
||
name => $user,
|
||
id => $self->getRegistrationUserHandle($req),
|
||
displayName => $displayName,
|
||
},
|
||
challenge => $challenge_base64,
|
||
pubKeyCredParams => [],
|
||
authenticatorSelection => { (
|
||
$userVerification
|
||
? ( userVerification => $userVerification )
|
||
: ()
|
||
)
|
||
}
|
||
};
|
||
|
||
$self->logger->debug( "Register parameters " . to_json($request) );
|
||
return $self->p->sendJSONresponse( $req,
|
||
{ request => $request, state_id => $token } );
|
||
}
|
||
|
||
sub _registration {
|
||
my ( $self, $req, $user ) = @_;
|
||
|
||
# Recover creation parameters, including challenge
|
||
my $state_id = $req->param('state_id');
|
||
unless ($state_id) {
|
||
$self->logger->error("Could not find state ID in WebAuthn response");
|
||
return $self->p->sendError( $req, 'Invalid response', 400 );
|
||
}
|
||
my $state_data;
|
||
unless ( $state_data = $self->ott->getToken($state_id) ) {
|
||
$self->logger->error(
|
||
"Expired or invalid state ID in WebAuthn response: $state_id");
|
||
return $self->p->sendError( $req, 'PE82', 400 );
|
||
}
|
||
my $registration_options = ( $state_data->{registration_options} );
|
||
return $self->p->sendError( $req,
|
||
'Registration options missing from state data', 400 )
|
||
unless ($registration_options);
|
||
|
||
# Data required for WebAuthn verification
|
||
my $credential_json = $req->param('credential');
|
||
$self->logger->debug("Get registered credential data $credential_json");
|
||
|
||
return $self->p->sendError( $req, 'Missing credential parameter', 400 )
|
||
unless ($credential_json);
|
||
|
||
my $validation = eval {
|
||
$self->validateCredential( $req, $registration_options,
|
||
$credential_json );
|
||
};
|
||
if ($@) {
|
||
$self->logger->error("Credential validation error: $@");
|
||
return $self->p->sendError( $req, "webAuthnRegisterFailed", 400 );
|
||
}
|
||
|
||
my $credential_id = $validation->{credential_id};
|
||
my $credential_pubkey = $validation->{credential_pubkey};
|
||
my $signature_count = $validation->{signature_count};
|
||
|
||
$self->logger->debug( "Registering new credential : \n" . "ID: "
|
||
. $credential_id . "\n"
|
||
. "Public key "
|
||
. $credential_pubkey . "\n"
|
||
. "Signature count "
|
||
. $signature_count
|
||
. "\n" );
|
||
|
||
if (
|
||
$self->find2fDevicesByKey(
|
||
$req, $req->userData, $self->type,
|
||
"_credentialId", $credential_id
|
||
)
|
||
)
|
||
{
|
||
return $self->p->sendError( $req, 'webauthnAlreadyRegistered', 400 );
|
||
}
|
||
|
||
my $keyName = $req->param('keyName');
|
||
my $epoch = time();
|
||
|
||
# Set default name if empty, check characters and truncate name if too long
|
||
$keyName ||= $epoch;
|
||
unless ( $keyName =~ /^[\w]+$/ ) {
|
||
$self->userLogger->error('WebAuthn name with bad character(s)');
|
||
return $self->p->sendError( $req, 'badName', 200 );
|
||
}
|
||
$keyName = substr( $keyName, 0, $self->conf->{max2FDevicesNameLength} );
|
||
$self->logger->debug("Key name: $keyName");
|
||
|
||
if (
|
||
$self->add2fDevice(
|
||
$req,
|
||
$req->userData,
|
||
{
|
||
type => $self->type,
|
||
name => $keyName,
|
||
_credentialId => $credential_id,
|
||
_credentialPublicKey => $credential_pubkey,
|
||
_signCount => $signature_count,
|
||
epoch => $epoch
|
||
}
|
||
)
|
||
)
|
||
{
|
||
$self->userLogger->notice(
|
||
"Webauthn key registration of $keyName succeeds for $user");
|
||
|
||
return $self->p->sendJSONresponse( $req, { result => 1 } );
|
||
}
|
||
else {
|
||
return $self->p->sendError( $req, 'Server Error', 500 );
|
||
}
|
||
|
||
}
|
||
|
||
sub _verificationchallenge {
|
||
my ( $self, $req, $user ) = @_;
|
||
|
||
$self->logger->debug('Verification challenge req');
|
||
|
||
my $request = $self->generateChallenge( $req, $req->userData );
|
||
|
||
unless ($request) {
|
||
return $self->p->sendError( $req, "No registered devices", 400 );
|
||
}
|
||
|
||
# Request is persisted on the server
|
||
my $token = $self->ott->createToken( {
|
||
authentication_options => $request,
|
||
}
|
||
);
|
||
|
||
$self->logger->debug( "Authentication parameters: " . to_json($request) );
|
||
return $self->p->sendJSONresponse( $req,
|
||
{ request => $request, state_id => $token } );
|
||
}
|
||
|
||
sub _verification {
|
||
my ( $self, $req, $user ) = @_;
|
||
|
||
my $credential_json = $req->param('credential');
|
||
|
||
return $self->p->sendError( $req, 'Missing credential parameter', 400 )
|
||
unless ($credential_json);
|
||
|
||
my $state_id = $req->param('state_id');
|
||
unless ($state_id) {
|
||
$self->logger->error(
|
||
"Could not find state ID in WebAuthn response: $credential_json");
|
||
return $self->p->sendError( $req, 'Invalid response', 400 );
|
||
}
|
||
|
||
# Recover challenge
|
||
my $state_data;
|
||
unless ( $state_data = $self->ott->getToken($state_id) ) {
|
||
$self->logger->error(
|
||
"Expired or invalid state ID in WebAuthn response: $state_id");
|
||
return $self->p->sendError( $req, 'PE82', 400 );
|
||
}
|
||
|
||
my $signature_options = ( $state_data->{authentication_options} );
|
||
|
||
my $validation_result = eval {
|
||
$self->validateAssertion( $req, $req->userData, $signature_options,
|
||
$credential_json );
|
||
};
|
||
if ($@) {
|
||
$self->logger->error("Webauthn validation error for $user: $@");
|
||
return $self->p->sendJSONresponse( $req, { result => 0 } );
|
||
}
|
||
|
||
if ( $validation_result->{success} == 1 ) {
|
||
return $self->p->sendJSONresponse( $req, { result => 1 } );
|
||
}
|
||
else {
|
||
return $self->p->sendJSONresponse( $req, { result => 0 } );
|
||
}
|
||
}
|
||
|
||
sub _delete {
|
||
my ( $self, $req, $user ) = @_;
|
||
|
||
# Check if unregistration is allowed
|
||
return $self->p->sendError( $req, 'notAuthorized', 200 )
|
||
unless $self->conf->{webauthn2fUserCanRemoveKey};
|
||
|
||
my $epoch = $req->param('epoch')
|
||
or
|
||
return $self->p->sendError( $req, '"epoch" parameter is missing', 400 );
|
||
|
||
if ( $self->del2fDevice( $req, $req->userData, $self->type, $epoch ) ) {
|
||
return $self->p->sendJSONresponse( $req, { result => 1 } );
|
||
}
|
||
else {
|
||
return $self->p->sendError( $req, '2FDeviceNotFound', 400 );
|
||
}
|
||
}
|
||
|
||
# Main method
|
||
sub run {
|
||
my ( $self, $req, $action ) = @_;
|
||
my $user = $req->userData->{ $self->conf->{whatToTrace} };
|
||
|
||
return $self->p->sendError( $req,
|
||
'No ' . $self->conf->{whatToTrace} . ' found in user data', 500 )
|
||
unless $user;
|
||
|
||
# Check if U2F key can be updated
|
||
my $msg = $self->canUpdateSfa( $req, $action );
|
||
return $self->p->sendError( $req, $msg, 400 ) if $msg;
|
||
|
||
if ( $action eq 'registrationchallenge' ) {
|
||
return $self->_registrationchallenge( $req, $user );
|
||
}
|
||
|
||
elsif ( $action eq 'registration' ) {
|
||
return $self->_registration( $req, $user );
|
||
}
|
||
|
||
elsif ( $action eq 'verificationchallenge' ) {
|
||
return $self->_verificationchallenge( $req, $user );
|
||
}
|
||
|
||
elsif ( $action eq 'verification' ) {
|
||
return $self->_verification( $req, $user );
|
||
}
|
||
|
||
elsif ( $action eq 'delete' ) {
|
||
return $self->_delete( $req, $user );
|
||
}
|
||
|
||
else {
|
||
$self->logger->error("Unknown WebAuthn action -> $action");
|
||
return $self->p->sendError( $req, 'unknownAction', 400 );
|
||
}
|
||
|
||
}
|
||
|
||
1;
|