WebAuthn perl modules (#1411)

This commit is contained in:
Maxime Besson 2021-12-21 16:35:04 +01:00
parent 596e2f1f3d
commit 825e213017
4 changed files with 830 additions and 10 deletions

View File

@ -4,21 +4,20 @@ eg/index.cgi
eg/index.fcgi
eg/index.psgi
inc/LWP/Protocol/PSGI.pm
lib/Lemonldap/NG/Portal.pm
lib/Lemonldap/NG/Portal/2F/Engines/Default.pm
lib/Lemonldap/NG/Portal/2F/Ext2F.pm
lib/Lemonldap/NG/Portal/2F/Mail2F.pm
lib/Lemonldap/NG/Portal/2F/Radius.pm
lib/Lemonldap/NG/Portal/2F/Register/TOTP.pm
lib/Lemonldap/NG/Portal/2F/Register/U2F.pm
lib/Lemonldap/NG/Portal/2F/Register/WebAuthn.pm
lib/Lemonldap/NG/Portal/2F/Register/Yubikey.pm
lib/Lemonldap/NG/Portal/2F/REST.pm
lib/Lemonldap/NG/Portal/2F/TOTP.pm
lib/Lemonldap/NG/Portal/2F/U2F.pm
lib/Lemonldap/NG/Portal/2F/UTOTP.pm
lib/Lemonldap/NG/Portal/2F/WebAuthn.pm
lib/Lemonldap/NG/Portal/2F/Yubikey.pm
lib/Lemonldap/NG/Portal/Auth.pod
lib/Lemonldap/NG/Portal/Auth/_WebForm.pm
lib/Lemonldap/NG/Portal/Auth/AD.pm
lib/Lemonldap/NG/Portal/Auth/Apache.pm
lib/Lemonldap/NG/Portal/Auth/CAS.pm
@ -34,9 +33,10 @@ lib/Lemonldap/NG/Portal/Auth/Kerberos.pm
lib/Lemonldap/NG/Portal/Auth/LDAP.pm
lib/Lemonldap/NG/Portal/Auth/LinkedIn.pm
lib/Lemonldap/NG/Portal/Auth/Null.pm
lib/Lemonldap/NG/Portal/Auth/OpenID.pm
lib/Lemonldap/NG/Portal/Auth/OpenIDConnect.pm
lib/Lemonldap/NG/Portal/Auth/OpenID.pm
lib/Lemonldap/NG/Portal/Auth/PAM.pm
lib/Lemonldap/NG/Portal/Auth.pod
lib/Lemonldap/NG/Portal/Auth/Proxy.pm
lib/Lemonldap/NG/Portal/Auth/Radius.pm
lib/Lemonldap/NG/Portal/Auth/Remote.pm
@ -45,6 +45,7 @@ lib/Lemonldap/NG/Portal/Auth/SAML.pm
lib/Lemonldap/NG/Portal/Auth/Slave.pm
lib/Lemonldap/NG/Portal/Auth/SSL.pm
lib/Lemonldap/NG/Portal/Auth/Twitter.pm
lib/Lemonldap/NG/Portal/Auth/_WebForm.pm
lib/Lemonldap/NG/Portal/Auth/WebID.pm
lib/Lemonldap/NG/Portal/CDC.pm
lib/Lemonldap/NG/Portal/CertificateResetByMail/Custom.pm
@ -52,10 +53,10 @@ lib/Lemonldap/NG/Portal/CertificateResetByMail/Demo.pm
lib/Lemonldap/NG/Portal/CertificateResetByMail/LDAP.pm
lib/Lemonldap/NG/Portal/Issuer/CAS.pm
lib/Lemonldap/NG/Portal/Issuer/Get.pm
lib/Lemonldap/NG/Portal/Issuer/OpenID.pm
lib/Lemonldap/NG/Portal/Issuer/OpenIDConnect.pm
lib/Lemonldap/NG/Portal/Issuer/OpenID.pm
lib/Lemonldap/NG/Portal/Issuer/SAML.pm
lib/Lemonldap/NG/Portal/Lib/_tokenRule.pm
lib/Lemonldap/NG/Portal/Lib/Authen/WebAuthn.pm
lib/Lemonldap/NG/Portal/Lib/Captcha.pm
lib/Lemonldap/NG/Portal/Lib/CAS.pm
lib/Lemonldap/NG/Portal/Lib/Choice.pm
@ -66,9 +67,9 @@ lib/Lemonldap/NG/Portal/Lib/Net/LDAP.pm
lib/Lemonldap/NG/Portal/Lib/Notifications/JSON.pm
lib/Lemonldap/NG/Portal/Lib/Notifications/XML.pm
lib/Lemonldap/NG/Portal/Lib/OneTimeToken.pm
lib/Lemonldap/NG/Portal/Lib/OpenIDConnect.pm
lib/Lemonldap/NG/Portal/Lib/OpenID/Server.pm
lib/Lemonldap/NG/Portal/Lib/OpenID/SREG.pm
lib/Lemonldap/NG/Portal/Lib/OpenIDConnect.pm
lib/Lemonldap/NG/Portal/Lib/OtherSessions.pm
lib/Lemonldap/NG/Portal/Lib/OverConf.pm
lib/Lemonldap/NG/Portal/Lib/Remote.pm
@ -78,9 +79,10 @@ lib/Lemonldap/NG/Portal/Lib/SAML.pm
lib/Lemonldap/NG/Portal/Lib/Slave.pm
lib/Lemonldap/NG/Portal/Lib/SMTP.pm
lib/Lemonldap/NG/Portal/Lib/SOAPProxy.pm
lib/Lemonldap/NG/Portal/Lib/_tokenRule.pm
lib/Lemonldap/NG/Portal/Lib/U2F.pm
lib/Lemonldap/NG/Portal/Lib/WebAuthn.pm
lib/Lemonldap/NG/Portal/Lib/Wrapper.pm
lib/Lemonldap/NG/Portal/Main.pm
lib/Lemonldap/NG/Portal/Main/Auth.pm
lib/Lemonldap/NG/Portal/Main/Constants.pm
lib/Lemonldap/NG/Portal/Main/Display.pm
@ -89,6 +91,7 @@ lib/Lemonldap/NG/Portal/Main/Issuer.pm
lib/Lemonldap/NG/Portal/Main/Menu.pm
lib/Lemonldap/NG/Portal/Main/Plugin.pm
lib/Lemonldap/NG/Portal/Main/Plugins.pm
lib/Lemonldap/NG/Portal/Main.pm
lib/Lemonldap/NG/Portal/Main/Process.pm
lib/Lemonldap/NG/Portal/Main/Request.pm
lib/Lemonldap/NG/Portal/Main/Run.pm
@ -132,12 +135,12 @@ lib/Lemonldap/NG/Portal/Plugins/SOAPServer.pm
lib/Lemonldap/NG/Portal/Plugins/Status.pm
lib/Lemonldap/NG/Portal/Plugins/StayConnected.pm
lib/Lemonldap/NG/Portal/Plugins/Upgrade.pm
lib/Lemonldap/NG/Portal.pm
lib/Lemonldap/NG/Portal/Register/AD.pm
lib/Lemonldap/NG/Portal/Register/Base.pm
lib/Lemonldap/NG/Portal/Register/Custom.pm
lib/Lemonldap/NG/Portal/Register/Demo.pm
lib/Lemonldap/NG/Portal/Register/LDAP.pm
lib/Lemonldap/NG/Portal/UserDB.pod
lib/Lemonldap/NG/Portal/UserDB/AD.pm
lib/Lemonldap/NG/Portal/UserDB/CAS.pm
lib/Lemonldap/NG/Portal/UserDB/Choice.pm
@ -148,8 +151,9 @@ lib/Lemonldap/NG/Portal/UserDB/Demo.pm
lib/Lemonldap/NG/Portal/UserDB/Facebook.pm
lib/Lemonldap/NG/Portal/UserDB/LDAP.pm
lib/Lemonldap/NG/Portal/UserDB/Null.pm
lib/Lemonldap/NG/Portal/UserDB/OpenID.pm
lib/Lemonldap/NG/Portal/UserDB/OpenIDConnect.pm
lib/Lemonldap/NG/Portal/UserDB/OpenID.pm
lib/Lemonldap/NG/Portal/UserDB.pod
lib/Lemonldap/NG/Portal/UserDB/Proxy.pm
lib/Lemonldap/NG/Portal/UserDB/Remote.pm
lib/Lemonldap/NG/Portal/UserDB/REST.pm

View File

@ -0,0 +1,352 @@
# 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.12';
extends 'Lemonldap::NG::Portal::Main::Plugin';
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";
}
);
sub init { return 1; }
# 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 users account.
sub _generate_user_handle {
my ($self) = @_;
return encode_base64url( Crypt::URandom::urandom(64) );
}
sub _registrationchallenge {
my ( $self, $req, $user ) = @_;
my @_2fDevices = $self->find2fByType( $req, $req->userData );
# Check if user can register one more 2F device
my $size = @_2fDevices;
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->find2fByKey(
$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;

View File

@ -0,0 +1,122 @@
# WebAuthn second factor authentication
#
# This plugin handle authentications to ask WebAuthn second factor for users that
# have registered their WebAuthn authenticators
package Lemonldap::NG::Portal::2F::WebAuthn;
use strict;
use Mouse;
use JSON qw(from_json to_json);
use MIME::Base64 qw(encode_base64url decode_base64url);
use Crypt::URandom;
use Lemonldap::NG::Portal::Main::Constants qw(
PE_OK
PE_ERROR
PE_SENDRESPONSE
PE_BADCREDENTIALS
);
our $VERSION = '2.0.12';
extends 'Lemonldap::NG::Portal::Main::SecondFactor';
with 'Lemonldap::NG::Portal::Lib::WebAuthn';
# INITIALIZATION
has rule => ( is => 'rw' );
has prefix => ( is => 'ro', default => 'webauthn' );
has logo => ( is => 'rw', default => 'webauthn.png' );
sub init {
my ($self) = @_;
# If self registration is enabled and "activation" is just set to
# "enabled", replace the rule to detect if user has registered its key
if ( $self->conf->{webauthn2fSelfRegistration}
and $self->conf->{webauthn2fActivation} eq '1' )
{
$self->conf->{webauthn2fActivation} =
'$_2fDevices && $_2fDevices =~ /"type"\s*:\s*"WebAuthn"/s';
}
return 0
unless ( $self->Lemonldap::NG::Portal::Main::SecondFactor::init() );
return 1;
}
# RUNNING METHODS
# Main method
sub run {
my ( $self, $req, $token ) = @_;
my $user = $req->user;
my $checkLogins = $req->param('checkLogins');
$self->logger->debug("WebAuthn: checkLogins set") if $checkLogins;
my $stayconnected = $req->param('stayconnected');
$self->logger->debug("WebAuthn: stayconnected set") if $stayconnected;
my $request = $self->generateChallenge( $req, $req->sessionInfo );
unless ($request) {
$self->logger->error(
"No registered WebAuthn devices for " . $req->user );
return PE_ERROR;
}
$self->ott->updateToken( $token, _webauthn_request => $request );
my $tmp = $self->p->sendHtml(
$req,
'webauthn2fcheck',
params => {
MAIN_LOGO => $self->conf->{portalMainLogo},
SKIN => $self->p->getSkin($req),
DATA => to_json( { request => $request } ),
TOKEN => $token,
CHECKLOGINS => $checkLogins,
STAYCONNECTED => $stayconnected
}
);
$req->response($tmp);
return PE_SENDRESPONSE;
}
sub verify {
my ( $self, $req, $session ) = @_;
my $user = $session->{ $self->conf->{whatToTrace} };
my $credential_json = $req->param('credential');
unless ($credential_json) {
$self->logger->error('Missing signature parameter');
return PE_ERROR;
}
my $signature_options = $session->{_webauthn_request};
delete $session->{_webauthn_request};
my $validation_result = eval {
$self->validateAssertion( $req, $session, $signature_options,
$credential_json );
};
if ($@) {
$self->logger->error("Webauthn validation error for $user: $@");
return PE_ERROR;
}
if ( $validation_result->{success} == 1 ) {
return PE_OK;
}
else {
$self->logger->error(
"Webauthn validation did not return success for $user");
return PE_ERROR;
}
}
1;

View File

@ -0,0 +1,342 @@
package Lemonldap::NG::Portal::Lib::WebAuthn;
use strict;
use Mouse::Role;
use MIME::Base64 qw(encode_base64url decode_base64url);
use JSON qw(decode_json from_json to_json);
use Digest::SHA qw(sha256);
use URI;
use Carp;
our $VERSION = '2.0.12';
has rp_id => ( is => 'rw', lazy => 1, builder => "_build_rp_id" );
has origin => ( is => 'rw', lazy => 1, builder => "_build_origin" );
has type => ( is => 'ro', default => 'WebAuthn' );
has verifier => ( is => 'rw', lazy => 1, builder => "_build_verifier" );
sub _build_verifier {
my $self = shift;
return Authen::WebAuthn->new(
rp_id => $self->rp_id,
origin => $self->origin,
);
}
sub _build_rp_id {
my ($self) = @_;
# TODO make this configurable
my $portal_uri = URI->new( $self->{conf}->{portal} );
return $portal_uri->authority;
}
sub _build_origin {
my ($self) = @_;
my $portal_uri = URI->new( $self->{conf}->{portal} );
return ( $portal_uri->scheme . "://" . $portal_uri->authority );
}
around 'init' => sub {
my $orig = shift;
my $self = shift;
eval { require Authen::WebAuthn };
if ($@) {
$self->logger->error("Can't load WebAuthn library: $@");
$self->error("Can't load WebAuthn library: $@");
return 0;
}
return $orig->( $self, @_ );
};
sub getUserHandle {
my ( $self, $req, $data ) = @_;
return $data->{_webAuthnUserHandle};
}
sub setUserHandle {
my ( $self, $req, $user_handle ) = @_;
$self->p->updatePersistentSession( $req,
{ _webAuthnUserHandle => $user_handle } );
return;
}
sub generateChallenge {
my ( $self, $req, $data ) = @_;
# Find webauthn devices for user
my @webauthn_devices = $self->find2fByType( $req, $data, $self->type );
unless (@webauthn_devices) {
return;
}
my $challenge_base64 = encode_base64url( Crypt::URandom::urandom(32) );
my $userVerification = $self->conf->{webauthn2fUserVerification};
return {
challenge => $challenge_base64,
allowCredentials => [
map { { type => "public-key", id => $_->{_credentialId}, } }
@webauthn_devices
],
( $userVerification ? ( userVerification => $userVerification ) : () ),
extensions => {
appid => $self->origin,
},
};
}
sub validateCredential {
my ( $self, $req, $registration_options, $credential_json ) = @_;
my $credential = from_json($credential_json);
my $client_data_json_b64 = $credential->{response}->{clientDataJSON};
my $attestation_object_b64 = $credential->{response}->{attestationObject};
my $requested_uv =
$registration_options->{authenticatorSelection}->{userVerification} || "";
my $challenge_b64 = $registration_options->{challenge};
my $token_binding_id_b64 = encode_base64url(
$req->headers->header('Sec-Provided-Token-Binding-ID') );
return $self->verifier->validate_registration(
challenge_b64 => $challenge_b64,
requested_uv => $requested_uv,
client_data_json_b64 => $client_data_json_b64,
attestation_object_b64 => $attestation_object_b64,
token_binding_id_b64 => $token_binding_id_b64
);
}
sub validateAssertion {
my ( $self, $req, $data, $signature_options, $credential_json ) = @_;
my $user = $data->{ $self->conf->{whatToTrace} };
$self->logger->debug("Get asserted credential $credential_json");
my $credential = from_json($credential_json);
my $credential_id = $credential->{id};
croak("Empty credential id in credential response") unless $credential_id;
# 5. If options.allowCredentials is not empty, verify that credential.id
# identifies one of the public key credentials listed in
# options.allowCredentials.
my @allowed_credential_ids =
map { $_->{id} } @{ $signature_options->{allowCredentials} };
if ( @allowed_credential_ids
and not grep { $_ eq $credential_id } @allowed_credential_ids )
{
croak("Received credential ID $credential_id was not requested");
}
# 6. Identify the user being authenticated and verify that this user is the
# owner of the public key credential source credentialSource identified by
# credential.id If the user was identified before the authentication
# ceremony was initiated, e.g., via a username or cookie, verify that the
# identified user is the owner of credentialSource.
my @webauthn_devices = $self->find2fByType( $req, $data, $self->type );
my @matching_credentials =
grep { $_->{_credentialId} eq $credential_id } @webauthn_devices;
if ( @matching_credentials < 1 ) {
croak("Received credential ID $credential_id does not belong to user");
}
if ( @matching_credentials > 1 ) {
croak("Found multiple credentials with ID $credential_id for user");
}
my $matching_credential = $matching_credentials[0];
# If response.userHandle is present, let userHandle be its value.
# Verify that userHandle also maps to the same user.
if ( $credential->{response}->{userHandle} ) {
my $user_handle = $credential->{response}->{userHandle};
my $current_user_handle = $self->getUserHandle( $req, $data );
unless ( $user_handle eq $current_user_handle ) {
croak(
"Received user handle ($user_handle) does not match current user ($current_user_handle)"
);
}
}
# TODO If the user was not identified before the authentication ceremony
# was initiated, verify that response.userHandle is present, and that the
# user identified by this value is the owner of credentialSource.
# NOTE: irrelevant for now, take this into account when implementing
# Auth::WebAuthn
my $client_data_json_b64 = $credential->{response}->{clientDataJSON};
my $authenticator_data_b64 = $credential->{response}->{authenticatorData};
my $signature_b64 = $credential->{response}->{signature};
my $extension_results = $credential->{clientExtensionResults};
my $requested_uv = $signature_options->{userVerification} || "";
my $token_binding_id_b64 = encode_base64url(
$req->headers->header('Sec-Provided-Token-Binding-ID') );
my $validation_result = $self->verifier->validate_assertion(
challenge_b64 => $signature_options->{challenge},
credential_pubkey_b64 => $matching_credential->{_credentialPublicKey},
stored_sign_count => $matching_credential->{_signCount},
requested_uv => $requested_uv,
client_data_json_b64 => $client_data_json_b64,
authenticator_data_b64 => $authenticator_data_b64,
signature_b64 => $signature_b64,
extension_results => $extension_results,
token_binding_id_b64 => $token_binding_id_b64,
);
if ( $validation_result->{success} == 1 ) {
my $new_signature_count = $validation_result->{signature_count};
$self->userLogger->info(
"Successfully verified signature with count "
. "$new_signature_count for $user" );
# Update storedSignCount to be the value of authData.signCount
$self->update2fDevice( $req, $data, $self->type,
"_credentialId", $credential_id, "_signCount",
$new_signature_count );
}
return $validation_result;
}
sub decode_credential {
my ( $self, $json ) = @_;
my $credential = decode_json($json);
# Decode ClientDataJSON
if ( $credential->{response}->{clientDataJSON} ) {
$credential->{response}->{clientDataJSON} = decode_json(
decode_base64url( $credential->{response}->{clientDataJSON} ) );
}
# Decode attestation object
if ( $credential->{response}->{attestationObject} ) {
$credential->{response}->{attestationObject} =
getAttestationObject( $credential->{response}->{attestationObject} );
}
# Decode authenticator data
if ( $credential->{response}->{authenticatorData} ) {
$credential->{response}->{authenticatorData} =
getAuthData(
decode_base64url( $credential->{response}->{authenticatorData} ) );
}
# Decode rawID
if ( $credential->{rawId} ) {
$credential->{rawId} = decode_base64url( $credential->{rawId} );
}
return $credential;
}
sub update2fDevice {
my ( $self, $req, $info, $type, $key, $value, $update_key, $update_value )
= @_;
my $user = $info->{ $self->conf->{whatToTrace} };
my $_2fDevices = $self->get2fDevices( $req, $info );
return 0 unless $_2fDevices;
my @found =
grep { $_->{type} eq $type and $_->{$key} eq $value } @{$_2fDevices};
for my $device (@found) {
$device->{$update_key} = $update_value;
}
if (@found) {
$self->p->updatePersistentSession( $req,
{ _2fDevices => to_json($_2fDevices) }, $user );
return 1;
}
return 0;
}
sub add2fDevice {
my ( $self, $req, $info, $device ) = @_;
my $_2fDevices = $self->get2fDevices( $req, $info );
push @{$_2fDevices}, $device;
$self->logger->debug(
"Append 2F Device: { type => 'Webauthn', name => $device->{name} }");
$self->p->updatePersistentSession( $req,
{ _2fDevices => to_json($_2fDevices) } );
return 1;
}
sub del2fDevice {
my ( $self, $req, $info, $type, $epoch ) = @_;
my $_2fDevices = $self->get2fDevices( $req, $info );
return 0 unless $_2fDevices;
my @updated_2fDevices =
grep { not( $_->{type} eq $type and $_->{epoch} eq $epoch ) }
@{$_2fDevices};
$self->logger->debug(
"Deleted 2F Device: { type => $type, epoch => $epoch }");
$self->p->updatePersistentSession( $req,
{ _2fDevices => to_json( [@updated_2fDevices] ) } );
return 1;
}
sub find2fByKey {
my ( $self, $req, $info, $type, $key, $value ) = @_;
my $_2fDevices = $self->get2fDevices( $req, $info );
return unless $_2fDevices;
my @found =
grep { $_->{type} eq $type and $_->{$key} eq $value } @{$_2fDevices};
return @found;
}
## @method get2fDevices($req, $info)
# Validate logout request
# @param req Request object
# @param info HashRef of session data
# @return undef or ArrayRef of second factors
sub get2fDevices {
my ( $self, $req, $info ) = @_;
my $_2fDevices;
if ( $info->{_2fDevices} ) {
$_2fDevices =
eval { from_json( $info->{_2fDevices}, { allow_nonref => 1 } ); };
if ($@) {
$self->logger->error("Corrupted session (_2fDevices): $@");
return;
}
}
else {
# Return new ArrayRef
return [];
}
if ( ref($_2fDevices) eq "ARRAY" ) {
return $_2fDevices;
}
else {
return;
}
}
sub find2fByType {
my ( $self, $req, $info, $type ) = @_;
my $_2fDevices = $self->get2fDevices( $req, $info );
return unless $_2fDevices;
return @{$_2fDevices} unless $type;
my @found = grep { $_->{type} eq $type } @{$_2fDevices};
return @found;
}
1;