WebAuthn perl modules (#1411)
This commit is contained in:
parent
596e2f1f3d
commit
825e213017
|
@ -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
|
||||
|
|
|
@ -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 user’s 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;
|
122
lemonldap-ng-portal/lib/Lemonldap/NG/Portal/2F/WebAuthn.pm
Normal file
122
lemonldap-ng-portal/lib/Lemonldap/NG/Portal/2F/WebAuthn.pm
Normal 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;
|
342
lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Lib/WebAuthn.pm
Normal file
342
lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Lib/WebAuthn.pm
Normal 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;
|
Loading…
Reference in New Issue
Block a user