From ff3003468838eeaf3b47535f5db228a62cfba237 Mon Sep 17 00:00:00 2001 From: Xavier Guimard Date: Mon, 16 Apr 2018 20:06:32 +0200 Subject: [PATCH 1/3] Reorganize tests --- lemonldap-ng-portal/MANIFEST | 4 +- lemonldap-ng-portal/t/71-2F-U2F.t | 198 ++++++++++++++++++ lemonldap-ng-portal/t/73-2F-UTOTP-TOTP-only.t | 126 +++++++++++ 3 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 lemonldap-ng-portal/t/71-2F-U2F.t create mode 100644 lemonldap-ng-portal/t/73-2F-UTOTP-TOTP-only.t diff --git a/lemonldap-ng-portal/MANIFEST b/lemonldap-ng-portal/MANIFEST index ed41d0e98..10deb57d7 100644 --- a/lemonldap-ng-portal/MANIFEST +++ b/lemonldap-ng-portal/MANIFEST @@ -425,9 +425,9 @@ t/63-History.t t/64-StayConnected.t t/65-AutoSignin.t t/70-2F-TOTP.t -t/71-2F-UTOTP-TOTP-only.t +t/71-2F-U2F.t t/72-2F-REST.t -t/73-2F-U2F.t +t/73-2F-UTOTP-TOTP-only.t t/90-Translations.t t/99-pod.t t/lmConf-1.json diff --git a/lemonldap-ng-portal/t/71-2F-U2F.t b/lemonldap-ng-portal/t/71-2F-U2F.t new file mode 100644 index 000000000..0456d3ac2 --- /dev/null +++ b/lemonldap-ng-portal/t/71-2F-U2F.t @@ -0,0 +1,198 @@ +use Test::More; +use strict; +use IO::String; + +require 't/test-lib.pm'; +my $maintests = 17; + +SKIP: { + eval { require Crypt::U2F::Server; require Authen::U2F::Tester }; + if ( $@ or $Crypt::U2F::Server::VERSION < 0.42 ) { + skip 'Missing libraries', $maintests; + } + use_ok('Lemonldap::NG::Common::FormEncode'); + + my $client = LLNG::Manager::Test->new( + { + ini => { + logLevel => 'error', + u2fSelfRegistration => 1, + u2fActivation => 1, + } + } + ); + my $res; + + # Try to authenticate + # ------------------- + ok( + $res = $client->_post( + '/', + IO::String->new('user=dwho&password=dwho'), + length => 23 + ), + 'Auth query' + ); + my $id = expectCookie($res); + + # U2F form + ok( + $res = $client->_get( + '/2fregisters', + cookie => "lemonldap=$id", + accept => 'text/html', + ), + 'Form registration' + ); + expectRedirection( $res, qr#/2fregisters/u$# ); + + ok( + $res = $client->_get( + '/2fregisters/u', + cookie => "lemonldap=$id", + accept => 'text/html', + ), + 'Form registration' + ); + ok( $res->[2]->[0] =~ /u2fregistration\.(?:min\.)?js/, 'Found U2F js' ); + + # Ajax registration request + ok( + $res = $client->_post( + '/2fregisters/u/register', IO::String->new(''), + accept => 'application/json', + cookie => "lemonldap=$id", + length => 0, + ), + 'Get registration challenge' + ); + expectOK($res); + my $data; + eval { $data = JSON::from_json( $res->[2]->[0] ) }; + ok( not($@), ' Content is JSON' ) + or explain( [ $@, $res->[2] ], 'JSON content' ); + ok( ( $data->{challenge} and $data->{appId} ), ' Get challenge and appId' ) + or explain( $data, 'challenge and appId' ); + + # Build U2F tester + my $tester = Authen::U2F::Tester->new( + certificate => Crypt::OpenSSL::X509->new_from_string( + '-----BEGIN CERTIFICATE----- +MIIB6DCCAY6gAwIBAgIJAJKuutkN2sAfMAoGCCqGSM49BAMCME8xCzAJBgNVBAYT +AlVTMQ4wDAYDVQQIDAVUZXhhczEaMBgGA1UECgwRVW50cnVzdGVkIFUyRiBPcmcx +FDASBgNVBAMMC3ZpcnR1YWwtdTJmMB4XDTE4MDMyODIwMTc1OVoXDTI3MTIyNjIw +MTc1OVowTzELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMRowGAYDVQQKDBFV +bnRydXN0ZWQgVTJGIE9yZzEUMBIGA1UEAwwLdmlydHVhbC11MmYwWTATBgcqhkjO +PQIBBggqhkjOPQMBBwNCAAQTij+9mI1FJdvKNHLeSQcOW4ob3prvIXuEGJMrQeJF +6OYcgwxrVqsmNMl5w45L7zx8ryovVOti/mtqkh2pQjtpo1MwUTAdBgNVHQ4EFgQU +QXKKf+rrZwA4WXDCU/Vebe4gYXEwHwYDVR0jBBgwFoAUQXKKf+rrZwA4WXDCU/Ve +be4gYXEwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiEAiCdOEmw5 +hknzHR1FoyFZKRrcJu17a1PGcqTFMJHTC70CIHeCZ8KVuuMIPjoofQd1l1E221rv +RJY1Oz1fUNbrIPsL +-----END CERTIFICATE-----', Crypt::OpenSSL::X509::FORMAT_PEM() + ), + key => Crypt::PK::ECC->new( + \'-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIOdbZw1swQIL+RZoDQ9zwjWY5UjA1NO81WWjwbmznUbgoAoGCCqGSM49 +AwEHoUQDQgAEE4o/vZiNRSXbyjRy3kkHDluKG96a7yF7hBiTK0HiRejmHIMMa1ar +JjTJecOOS+88fK8qL1TrYv5rapIdqUI7aQ== +-----END EC PRIVATE KEY-----' + ), + ); + my $r = $tester->register( $data->{appId}, $data->{challenge} ); + ok( $r->is_success, ' Good challenge value' ) or diag( $r->error_message ); + + my $registrationData = JSON::to_json( + { + clientData => $r->client_data, + errorCode => 0, + registrationData => $r->registration_data, + version => "U2F_V2" + } + ); + my ( $host, $url, $query ); + $query = Lemonldap::NG::Common::FormEncode::build_urlencoded( + registration => $registrationData, + challenge => $res->[2]->[0], + ); + + ok( + $res = $client->_post( + '/2fregisters/u/registration', IO::String->new($query), + length => length($query), + accept => 'application/json', + cookie => "lemonldap=$id", + ), + 'Push registration data' + ); + expectOK($res); + eval { $data = JSON::from_json( $res->[2]->[0] ) }; + ok( not($@), ' Content is JSON' ) + or explain( [ $@, $res->[2] ], 'JSON content' ); + ok( $data->{result} == 1, 'Key is registered' ) + or explain( $data, '"result":1' ); + + # Try to sing-in + $client->logout($id); + ok( + $res = $client->_post( + '/', + IO::String->new('user=dwho&password=dwho'), + length => 23, + accept => 'text/html', + cookie => "lemonldap=$id", + ), + 'Auth query' + ); + ( $host, $url, $query ) = expectForm( $res, undef, '/u2fcheck', 'token' ); + + # Get challenge + ok( $res->[2]->[0] =~ /^.*"keyHandle".*$/m, ' get keyHandle' ); + $data = $&; + eval { $data = JSON::from_json($data) }; + ok( not($@), ' Content is JSON' ) + or explain( [ $@, $data ], 'JSON content' ); + + # Build U2F signature + $r = + $tester->sign( $data->{appId}, $data->{challenge}, $data->{keyHandle} ); + ok( $r->is_success, ' Good challenge value' ) or diag( $r->error_message ); + my $sign = JSON::to_json( + { + errorCode => 0, + signatureData => $r->signature_data, + clientData => $r->client_data, + keyHandle => $data->{keyHandle}, + } + ); + $sign = + Lemonldap::NG::Common::FormEncode::build_urlencoded( signature => $sign ); + $query =~ s/signature=/$sign/e; + $query =~ s/challenge=/challenge=$data->{challenge}/; + + # POST result + ok( + $res = $client->_post( + '/u2fcheck', + IO::String->new($query), + length => length($query), + ), + 'Push U2F signature' + ); + + # See https://github.com/mschout/perl-authen-u2f-tester/issues/2 + if ( $Authen::U2F::Tester::VERSION >= 0.03 ) { + expectCookie($res); + } + else { + count(1); + pass( +'Authen::2F::Tester-0.02 signatures are not recognized by Yubico library' + ); + } +} +count($maintests); + +clean_sessions(); + +done_testing( count() ); diff --git a/lemonldap-ng-portal/t/73-2F-UTOTP-TOTP-only.t b/lemonldap-ng-portal/t/73-2F-UTOTP-TOTP-only.t new file mode 100644 index 000000000..27551f563 --- /dev/null +++ b/lemonldap-ng-portal/t/73-2F-UTOTP-TOTP-only.t @@ -0,0 +1,126 @@ +use Test::More; +use strict; +use IO::String; + +require 't/test-lib.pm'; +my $maintests = 16; + +SKIP: { + eval { require Convert::Base32; require Crypt::U2F::Server::Simple; }; + if ($@) { + skip 'Missing libraries', $maintests; + } + require Lemonldap::NG::Common::TOTP; + + my $client = LLNG::Manager::Test->new( + { + ini => { + logLevel => 'error', + utotp2fActivation => 1, + totp2fSelfRegistration => 1, + } + } + ); + my $res; + + # Try to authenticate + # ------------------- + ok( + $res = $client->_post( + '/', + IO::String->new('user=dwho&password=dwho'), + length => 23 + ), + 'Auth query' + ); + my $id = expectCookie($res); + + # TOTP form + ok( + $res = $client->_get( + '/2fregisters', + cookie => "lemonldap=$id", + accept => 'text/html', + ), + 'Form registration' + ); + expectRedirection( $res, qr#/2fregisters/totp$# ); + ok( + $res = $client->_get( + '/2fregisters/totp', + cookie => "lemonldap=$id", + accept => 'text/html', + ), + 'Form registration' + ); + ok( $res->[2]->[0] =~ /totpregistration\.(?:min\.)?js/, 'Found TOTP js' ); + + # JS query + ok( + $res = $client->_post( + '/2fregisters/totp/getkey', IO::String->new(''), + cookie => "lemonldap=$id", + length => 0, + ), + 'Get new key' + ); + eval { $res = JSON::from_json( $res->[2]->[0] ) }; + ok( not($@), 'Content is JSON' ) + or explain( $res->[2]->[0], 'JSON content' ); + my ( $key, $token ); + ok( $key = $res->{secret}, 'Found secret' ); + ok( $token = $res->{token}, 'Found token' ); + $key = Convert::Base32::decode_base32($key); + + # Post code + my $code; + ok( $code = Lemonldap::NG::Common::TOTP::_code( undef, $key, 0, 30, 6 ), + 'Code' ); + ok( $code =~ /^\d{6}$/, 'Code contains 6 digits' ); + my $s = "code=$code&token=$token"; + ok( + $res = $client->_post( + '/2fregisters/totp/verify', + IO::String->new($s), + length => length($s), + cookie => "lemonldap=$id", + ), + 'Post code' + ); + eval { $res = JSON::from_json( $res->[2]->[0] ) }; + ok( not($@), 'Content is JSON' ) + or explain( $res->[2]->[0], 'JSON content' ); + ok( $res->{result} = 1, 'Key is registered' ); + + # Try to sing-in + $client->logout($id); + ok( + $res = $client->_post( + '/', + IO::String->new('user=dwho&password=dwho'), + length => 23, + accept => 'text/html', + ), + 'Auth query' + ); + my ( $host, $url, $query ) = + expectForm( $res, undef, '/utotp2fcheck', 'token' ); + ok( $code = Lemonldap::NG::Common::TOTP::_code( undef, $key, 0, 30, 6 ), + 'Code' ); + $query =~ s/code=/code=$code/; + ok( + $res = $client->_post( + '/utotp2fcheck', IO::String->new($query), + length => length($query), + ), + 'Post code' + ); + $id = expectCookie($res); + $client->logout($id); +} +count($maintests); + +clean_sessions(); + +done_testing( count() ); + From ca2ea37223954515b78af97e228a66568912cb4e Mon Sep 17 00:00:00 2001 From: Xavier Guimard Date: Mon, 16 Apr 2018 20:09:13 +0200 Subject: [PATCH 2/3] Add complete UTOTP test (#1391) --- lemonldap-ng-portal/MANIFEST | 1 + lemonldap-ng-portal/t/71-2F-UTOTP-TOTP-only.t | 126 --------------- ...73-2F-U2F.t => 73-2F-UTOTP-TOTP-and-U2F.t} | 144 ++++++++++++++++-- 3 files changed, 134 insertions(+), 137 deletions(-) delete mode 100644 lemonldap-ng-portal/t/71-2F-UTOTP-TOTP-only.t rename lemonldap-ng-portal/t/{73-2F-U2F.t => 73-2F-UTOTP-TOTP-and-U2F.t} (59%) diff --git a/lemonldap-ng-portal/MANIFEST b/lemonldap-ng-portal/MANIFEST index 10deb57d7..f0d46b69e 100644 --- a/lemonldap-ng-portal/MANIFEST +++ b/lemonldap-ng-portal/MANIFEST @@ -427,6 +427,7 @@ t/65-AutoSignin.t t/70-2F-TOTP.t t/71-2F-U2F.t t/72-2F-REST.t +t/73-2F-UTOTP-TOTP-and-U2F.t t/73-2F-UTOTP-TOTP-only.t t/90-Translations.t t/99-pod.t diff --git a/lemonldap-ng-portal/t/71-2F-UTOTP-TOTP-only.t b/lemonldap-ng-portal/t/71-2F-UTOTP-TOTP-only.t deleted file mode 100644 index 27551f563..000000000 --- a/lemonldap-ng-portal/t/71-2F-UTOTP-TOTP-only.t +++ /dev/null @@ -1,126 +0,0 @@ -use Test::More; -use strict; -use IO::String; - -require 't/test-lib.pm'; -my $maintests = 16; - -SKIP: { - eval { require Convert::Base32; require Crypt::U2F::Server::Simple; }; - if ($@) { - skip 'Missing libraries', $maintests; - } - require Lemonldap::NG::Common::TOTP; - - my $client = LLNG::Manager::Test->new( - { - ini => { - logLevel => 'error', - utotp2fActivation => 1, - totp2fSelfRegistration => 1, - } - } - ); - my $res; - - # Try to authenticate - # ------------------- - ok( - $res = $client->_post( - '/', - IO::String->new('user=dwho&password=dwho'), - length => 23 - ), - 'Auth query' - ); - my $id = expectCookie($res); - - # TOTP form - ok( - $res = $client->_get( - '/2fregisters', - cookie => "lemonldap=$id", - accept => 'text/html', - ), - 'Form registration' - ); - expectRedirection( $res, qr#/2fregisters/totp$# ); - ok( - $res = $client->_get( - '/2fregisters/totp', - cookie => "lemonldap=$id", - accept => 'text/html', - ), - 'Form registration' - ); - ok( $res->[2]->[0] =~ /totpregistration\.(?:min\.)?js/, 'Found TOTP js' ); - - # JS query - ok( - $res = $client->_post( - '/2fregisters/totp/getkey', IO::String->new(''), - cookie => "lemonldap=$id", - length => 0, - ), - 'Get new key' - ); - eval { $res = JSON::from_json( $res->[2]->[0] ) }; - ok( not($@), 'Content is JSON' ) - or explain( $res->[2]->[0], 'JSON content' ); - my ( $key, $token ); - ok( $key = $res->{secret}, 'Found secret' ); - ok( $token = $res->{token}, 'Found token' ); - $key = Convert::Base32::decode_base32($key); - - # Post code - my $code; - ok( $code = Lemonldap::NG::Common::TOTP::_code( undef, $key, 0, 30, 6 ), - 'Code' ); - ok( $code =~ /^\d{6}$/, 'Code contains 6 digits' ); - my $s = "code=$code&token=$token"; - ok( - $res = $client->_post( - '/2fregisters/totp/verify', - IO::String->new($s), - length => length($s), - cookie => "lemonldap=$id", - ), - 'Post code' - ); - eval { $res = JSON::from_json( $res->[2]->[0] ) }; - ok( not($@), 'Content is JSON' ) - or explain( $res->[2]->[0], 'JSON content' ); - ok( $res->{result} = 1, 'Key is registered' ); - - # Try to sing-in - $client->logout($id); - ok( - $res = $client->_post( - '/', - IO::String->new('user=dwho&password=dwho'), - length => 23, - accept => 'text/html', - ), - 'Auth query' - ); - my ( $host, $url, $query ) = - expectForm( $res, undef, '/utotp2fcheck', 'token' ); - ok( $code = Lemonldap::NG::Common::TOTP::_code( undef, $key, 0, 30, 6 ), - 'Code' ); - $query =~ s/code=/code=$code/; - ok( - $res = $client->_post( - '/utotp2fcheck', IO::String->new($query), - length => length($query), - ), - 'Post code' - ); - $id = expectCookie($res); - $client->logout($id); -} -count($maintests); - -clean_sessions(); - -done_testing( count() ); - diff --git a/lemonldap-ng-portal/t/73-2F-U2F.t b/lemonldap-ng-portal/t/73-2F-UTOTP-TOTP-and-U2F.t similarity index 59% rename from lemonldap-ng-portal/t/73-2F-U2F.t rename to lemonldap-ng-portal/t/73-2F-UTOTP-TOTP-and-U2F.t index 59259198a..0b7b390b7 100644 --- a/lemonldap-ng-portal/t/73-2F-U2F.t +++ b/lemonldap-ng-portal/t/73-2F-UTOTP-TOTP-and-U2F.t @@ -3,21 +3,29 @@ use strict; use IO::String; require 't/test-lib.pm'; -my $maintests = 17; +my $maintests = 37; SKIP: { - eval { require Crypt::U2F::Server; require Authen::U2F::Tester }; - if ( $@ or $Crypt::U2F::Server::VERSION < 0.42 ) { + eval { + require Convert::Base32; + require Crypt::U2F::Server::Simple; + require Authen::U2F::Tester; + }; + use_ok('Lemonldap::NG::Common::FormEncode'); + if ($@) { skip 'Missing libraries', $maintests; } - use_ok('Lemonldap::NG::Common::FormEncode'); + require Lemonldap::NG::Common::TOTP; my $client = LLNG::Manager::Test->new( { ini => { - logLevel => 'error', - u2fSelfRegistration => 1, - u2fActivation => 1, + logLevel => 'error', + utotp2fActivation => 1, + totp2fSelfRegistration => 1, + u2fSelfRegistration => 1, + u2fSelfRegistration => + '$_2fDevices =~ /"type":\s*"(?:TOTP|U2F)"/s', } } ); @@ -44,7 +52,90 @@ SKIP: { ), 'Form registration' ); - expectRedirection( $res, qr#/2fregisters/u$# ); + expectRedirection( $res, qr#/2fregisters/totp$# ); + ok( + $res = $client->_get( + '/2fregisters/totp', + cookie => "lemonldap=$id", + accept => 'text/html', + ), + 'Form registration' + ); + ok( $res->[2]->[0] =~ /totpregistration\.(?:min\.)?js/, 'Found TOTP js' ); + + # JS query + ok( + $res = $client->_post( + '/2fregisters/totp/getkey', IO::String->new(''), + cookie => "lemonldap=$id", + length => 0, + ), + 'Get new key' + ); + eval { $res = JSON::from_json( $res->[2]->[0] ) }; + ok( not($@), 'Content is JSON' ) + or explain( $res->[2]->[0], 'JSON content' ); + my ( $key, $token ); + ok( $key = $res->{secret}, 'Found secret' ); + ok( $token = $res->{token}, 'Found token' ); + $key = Convert::Base32::decode_base32($key); + + # Post code + my $code; + ok( $code = Lemonldap::NG::Common::TOTP::_code( undef, $key, 0, 30, 6 ), + 'Code' ); + ok( $code =~ /^\d{6}$/, 'Code contains 6 digits' ); + my $s = "code=$code&token=$token"; + ok( + $res = $client->_post( + '/2fregisters/totp/verify', + IO::String->new($s), + length => length($s), + cookie => "lemonldap=$id", + ), + 'Post code' + ); + eval { $res = JSON::from_json( $res->[2]->[0] ) }; + ok( not($@), 'Content is JSON' ) + or explain( $res->[2]->[0], 'JSON content' ); + ok( $res->{result} = 1, 'Key is registered' ); + + # Try to sing-in + $client->logout($id); + ok( + $res = $client->_post( + '/', + IO::String->new('user=dwho&password=dwho'), + length => 23, + accept => 'text/html', + ), + 'Auth query' + ); + my ( $host, $url, $query ) = + expectForm( $res, undef, '/utotp2fcheck', 'token' ); + ok( $code = Lemonldap::NG::Common::TOTP::_code( undef, $key, 0, 30, 6 ), + 'Code' ); + $query =~ s/code=/code=$code/; + ok( + $res = $client->_post( + '/utotp2fcheck', IO::String->new($query), + length => length($query), + ), + 'Post code' + ); + $id = expectCookie($res); + + # U2F form + ok( + $res = $client->_get( + '/2fregisters', + cookie => "lemonldap=$id", + accept => 'text/html', + ), + 'Form registration' + ); + expectOK($res); + ok( $res->[2]->[0] =~ m##, 'Get U2F choice' ); ok( $res = $client->_get( @@ -110,7 +201,6 @@ JjTJecOOS+88fK8qL1TrYv5rapIdqUI7aQ== version => "U2F_V2" } ); - my ( $host, $url, $query ); $query = Lemonldap::NG::Common::FormEncode::build_urlencoded( registration => $registrationData, challenge => $res->[2]->[0], @@ -132,8 +222,9 @@ JjTJecOOS+88fK8qL1TrYv5rapIdqUI7aQ== ok( $data->{result} == 1, 'Key is registered' ) or explain( $data, '"result":1' ); - # Try to sing-in + # Try to sing-in with TOTP $client->logout($id); + ok( $res = $client->_post( '/', @@ -144,7 +235,37 @@ JjTJecOOS+88fK8qL1TrYv5rapIdqUI7aQ== ), 'Auth query' ); - ( $host, $url, $query ) = expectForm( $res, undef, '/u2fcheck', 'token' ); + ( $host, $url, $query ) = + expectForm( $res, undef, '/utotp2fcheck', 'token' ); + ok( $res->[2]->[0] =~ /input name="code"/, ' get TOTP form' ); + + # Use TOTP + ok( $code = Lemonldap::NG::Common::TOTP::_code( undef, $key, 0, 30, 6 ), + 'Code' ); + $query =~ s/code=/code=$code/; + ok( + $res = $client->_post( + '/utotp2fcheck', IO::String->new($query), + length => length($query), + ), + 'Post code' + ); + $id = expectCookie($res); + $client->logout($id); + + # Try to sign-in with U2F + ok( + $res = $client->_post( + '/', + IO::String->new('user=dwho&password=dwho'), + length => 23, + accept => 'text/html', + cookie => "lemonldap=$id", + ), + 'Auth query' + ); + ( $host, $url, $query ) = + expectForm( $res, undef, '/utotp2fcheck', 'token' ); # Get challenge ok( $res->[2]->[0] =~ /^.*"keyHandle".*$/m, ' get keyHandle' ); @@ -196,3 +317,4 @@ count($maintests); clean_sessions(); done_testing( count() ); + From abd48c19ca4364bcee16afdaed56505561064b55 Mon Sep 17 00:00:00 2001 From: Xavier Guimard Date: Mon, 16 Apr 2018 20:16:53 +0200 Subject: [PATCH 3/3] Count error (#1391) --- lemonldap-ng-portal/t/73-2F-UTOTP-TOTP-and-U2F.t | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lemonldap-ng-portal/t/73-2F-UTOTP-TOTP-and-U2F.t b/lemonldap-ng-portal/t/73-2F-UTOTP-TOTP-and-U2F.t index 0b7b390b7..77358188a 100644 --- a/lemonldap-ng-portal/t/73-2F-UTOTP-TOTP-and-U2F.t +++ b/lemonldap-ng-portal/t/73-2F-UTOTP-TOTP-and-U2F.t @@ -11,10 +11,10 @@ SKIP: { require Crypt::U2F::Server::Simple; require Authen::U2F::Tester; }; - use_ok('Lemonldap::NG::Common::FormEncode'); if ($@) { skip 'Missing libraries', $maintests; } + use_ok('Lemonldap::NG::Common::FormEncode'); require Lemonldap::NG::Common::TOTP; my $client = LLNG::Manager::Test->new(