lemonldap-ng/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Lib/WebAuthn.pm

343 lines
11 KiB
Perl

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;