Merge branch 'v2.0'
This commit is contained in:
commit
afd915f64c
|
@ -93,6 +93,9 @@ logLevel = warn
|
|||
|
||||
[configuration]
|
||||
|
||||
; confTimeout: maximum time to get configuration (default 10)
|
||||
;confTimeout = 5
|
||||
|
||||
; GLOBAL CONFIGURATION ACCESS TYPE
|
||||
; (File, REST, SOAP, RDBI/CDBI, LDAP, YAMLFile)
|
||||
; Set here the parameters needed to access to LemonLDAP::NG configuration.
|
||||
|
|
|
@ -403,9 +403,12 @@ sub _launch {
|
|||
my $res;
|
||||
eval {
|
||||
local $SIG{ALRM} = sub { die "TIMEOUT\n" };
|
||||
alarm ($self->{confTimeout} || 10);
|
||||
$res = &{ $self->{type} . "::$sub" }( $self, @_ );
|
||||
eval {
|
||||
alarm( $self->{confTimeout} || 10 );
|
||||
$res = &{ $self->{type} . "::$sub" }( $self, @_ );
|
||||
};
|
||||
alarm 0;
|
||||
die $@ if $@;
|
||||
};
|
||||
$msg .= $@ if $@;
|
||||
return $res;
|
||||
|
|
|
@ -103,6 +103,7 @@ sub defaultValues {
|
|||
'issuerDBOpenIDRule' => 1,
|
||||
'issuerDBSAMLPath' => '^/saml/',
|
||||
'issuerDBSAMLRule' => 1,
|
||||
'issuersTimeout' => 120,
|
||||
'jsRedirect' => 0,
|
||||
'krbAuthnLevel' => 3,
|
||||
'krbRemoveDomain' => 1,
|
||||
|
|
|
@ -65,6 +65,7 @@ our $issuerParameters = {
|
|||
issuerDBOpenID => [qw(issuerDBOpenIDActivation issuerDBOpenIDPath issuerDBOpenIDRule openIdIssuerSecret openIdAttr openIdSPList openIdSreg_fullname openIdSreg_nickname openIdSreg_language openIdSreg_postcode openIdSreg_timezone openIdSreg_country openIdSreg_gender openIdSreg_email openIdSreg_dob)],
|
||||
issuerDBOpenIDConnect => [qw(issuerDBOpenIDConnectActivation issuerDBOpenIDConnectPath issuerDBOpenIDConnectRule)],
|
||||
issuerDBSAML => [qw(issuerDBSAMLActivation issuerDBSAMLPath issuerDBSAMLRule)],
|
||||
issuerOptions => [qw(issuersTimeout)],
|
||||
};
|
||||
our $samlServiceParameters = [qw(samlEntityID samlServicePrivateKeySig samlServicePrivateKeySigPwd samlServicePublicKeySig samlServicePrivateKeyEnc samlServicePrivateKeyEncPwd samlServicePublicKeyEnc samlServiceUseCertificateInResponse samlServiceSignatureMethod samlNameIDFormatMapEmail samlNameIDFormatMapX509 samlNameIDFormatMapWindows samlNameIDFormatMapKerberos samlAuthnContextMapPassword samlAuthnContextMapPasswordProtectedTransport samlAuthnContextMapTLSClient samlAuthnContextMapKerberos samlOrganizationDisplayName samlOrganizationName samlOrganizationURL samlSPSSODescriptorAuthnRequestsSigned samlSPSSODescriptorWantAssertionsSigned samlSPSSODescriptorSingleLogoutServiceHTTPRedirect samlSPSSODescriptorSingleLogoutServiceHTTPPost samlSPSSODescriptorSingleLogoutServiceSOAP samlSPSSODescriptorAssertionConsumerServiceHTTPArtifact samlSPSSODescriptorAssertionConsumerServiceHTTPPost samlSPSSODescriptorArtifactResolutionServiceArtifact samlIDPSSODescriptorWantAuthnRequestsSigned samlIDPSSODescriptorSingleSignOnServiceHTTPRedirect samlIDPSSODescriptorSingleSignOnServiceHTTPPost samlIDPSSODescriptorSingleSignOnServiceHTTPArtifact samlIDPSSODescriptorSingleLogoutServiceHTTPRedirect samlIDPSSODescriptorSingleLogoutServiceHTTPPost samlIDPSSODescriptorSingleLogoutServiceSOAP samlIDPSSODescriptorArtifactResolutionServiceArtifact samlAttributeAuthorityDescriptorAttributeServiceSOAP samlIdPResolveCookie samlMetadataForceUTF8 samlStorage samlStorageOptions samlRelayStateTimeout samlUseQueryStringSpecific samlCommonDomainCookieActivation samlCommonDomainCookieDomain samlCommonDomainCookieReader samlCommonDomainCookieWriter samlDiscoveryProtocolActivation samlDiscoveryProtocolURL samlDiscoveryProtocolPolicy samlDiscoveryProtocolIsPassive samlOverrideIDPEntityID)];
|
||||
our $oidcServiceParameters = [qw(oidcServiceMetaDataIssuer oidcServiceMetaDataAuthorizeURI oidcServiceMetaDataTokenURI oidcServiceMetaDataUserInfoURI oidcServiceMetaDataJWKSURI oidcServiceMetaDataRegistrationURI oidcServiceMetaDataIntrospectionURI oidcServiceMetaDataEndSessionURI oidcServiceMetaDataCheckSessionURI oidcServiceMetaDataFrontChannelURI oidcServiceMetaDataBackChannelURI oidcServiceMetaDataAuthnContext oidcServicePrivateKeySig oidcServicePublicKeySig oidcServiceKeyIdSig oidcServiceAllowDynamicRegistration oidcServiceAllowAuthorizationCodeFlow oidcServiceAllowImplicitFlow oidcServiceAllowHybridFlow oidcStorage oidcStorageOptions)];
|
||||
|
|
|
@ -78,19 +78,18 @@ sub _getCipher {
|
|||
sub encrypt {
|
||||
my ( $self, $data, $low ) = @_;
|
||||
|
||||
# pad $data so that its length be multiple of 16 bytes
|
||||
my $l = bytes::length($data) % 16;
|
||||
$data .= "\0" x ( 16 - $l ) unless ( $l == 0 );
|
||||
|
||||
my $iv =
|
||||
$low
|
||||
? bytes::substr( Digest::SHA::sha1( rand() . time . {} ), 0, IV_LENGTH )
|
||||
: $newIv->();
|
||||
my $hmac = $hash->($data);
|
||||
$data = $hash->($data) . $data;
|
||||
|
||||
# pad $data so that its length be multiple of 16 bytes
|
||||
my $l = bytes::length($data) % 16;
|
||||
$data .= "\0" x ( 16 - $l ) unless ( $l == 0 );
|
||||
eval {
|
||||
$data =
|
||||
encode_base64(
|
||||
$iv . $self->_getCipher->set_iv($iv)->encrypt( $hmac . $data ),
|
||||
encode_base64( $iv . $self->_getCipher->set_iv($iv)->encrypt($data),
|
||||
'' );
|
||||
};
|
||||
if ($@) {
|
||||
|
@ -126,16 +125,16 @@ sub decrypt {
|
|||
}
|
||||
my $hmac = bytes::substr( $data, 0, HMAC_LENGTH );
|
||||
$data = bytes::substr( $data, HMAC_LENGTH );
|
||||
|
||||
# Obscure Perl re bug...
|
||||
$data .= "\0";
|
||||
$data =~ s/\0*$//;
|
||||
if ( $hash->($data) ne $hmac ) {
|
||||
$msg = "Bad MAC";
|
||||
return undef;
|
||||
}
|
||||
else {
|
||||
$msg = '';
|
||||
|
||||
# Obscure Perl re bug...
|
||||
$data .= "\0";
|
||||
$data =~ s/\0*$//;
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -166,19 +166,23 @@ sub _tie_session {
|
|||
|
||||
eval {
|
||||
local $SIG{ALRM} = sub { die "TIMEOUT\n" };
|
||||
alarm $self->timeout;
|
||||
eval {
|
||||
alarm $self->timeout;
|
||||
|
||||
# SOAP/REST session module must be directly tied
|
||||
if ( $self->storageModule =~ /^Lemonldap::NG::Common::Apache::Session/ )
|
||||
{
|
||||
tie %h, $self->storageModule, $self->id,
|
||||
{ %{ $self->options }, %$options, kind => $self->kind };
|
||||
}
|
||||
else {
|
||||
tie %h, 'Lemonldap::NG::Common::Apache::Session', $self->id,
|
||||
{ %{ $self->options }, %$options };
|
||||
}
|
||||
# SOAP/REST session module must be directly tied
|
||||
if ( $self->storageModule =~
|
||||
/^Lemonldap::NG::Common::Apache::Session/ )
|
||||
{
|
||||
tie %h, $self->storageModule, $self->id,
|
||||
{ %{ $self->options }, %$options, kind => $self->kind };
|
||||
}
|
||||
else {
|
||||
tie %h, 'Lemonldap::NG::Common::Apache::Session', $self->id,
|
||||
{ %{ $self->options }, %$options };
|
||||
}
|
||||
};
|
||||
alarm 0;
|
||||
die $@ if $@;
|
||||
|
||||
};
|
||||
if ( $@ or not tied(%h) ) {
|
||||
|
|
|
@ -1385,6 +1385,10 @@ qr/^(?:(?:(?:(?:[a-zA-Z0-9][-a-zA-Z0-9]*)?[a-zA-Z0-9])[.])*(?:[a-zA-Z][-a-zA-Z0-
|
|||
'default' => 1,
|
||||
'type' => 'boolOrExpr'
|
||||
},
|
||||
'issuersTimeout' => {
|
||||
'default' => 120,
|
||||
'type' => 'int'
|
||||
},
|
||||
'jsRedirect' => {
|
||||
'default' => 0,
|
||||
'type' => 'boolOrExpr'
|
||||
|
|
|
@ -676,6 +676,11 @@ sub attributes {
|
|||
type => 'int',
|
||||
documentation => 'Token timeout for forms',
|
||||
},
|
||||
issuersTimeout => {
|
||||
default => 120,
|
||||
type => 'int',
|
||||
documentation => 'Token timeout for issuers',
|
||||
},
|
||||
requireToken => {
|
||||
default => 1,
|
||||
type => 'boolOrExpr',
|
||||
|
|
|
@ -505,6 +505,12 @@ sub tree {
|
|||
'issuerDBGetParameters'
|
||||
]
|
||||
},
|
||||
{
|
||||
title => 'issuerOptions',
|
||||
help => 'start.html#options',
|
||||
form => 'simpleInputContainer',
|
||||
nodes => ['issuersTimeout']
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
@ -565,7 +571,8 @@ sub tree {
|
|||
{
|
||||
title => 'reloadParams',
|
||||
help => 'configlocation.html#configuration_reload',
|
||||
nodes => [ 'reloadUrls', 'reloadTimeout', 'dontCompactConf' ]
|
||||
nodes =>
|
||||
[ 'reloadUrls', 'reloadTimeout', 'dontCompactConf' ]
|
||||
},
|
||||
{
|
||||
title => 'plugins',
|
||||
|
|
|
@ -594,7 +594,7 @@ sub tests {
|
|||
return 1;
|
||||
},
|
||||
|
||||
# Warn if XSRF token TTL is higher than 10s
|
||||
# Warn if XSRF token TTL is higher than 30s
|
||||
formTimeout => sub {
|
||||
return 1 unless ( defined $conf->{formTimeout} );
|
||||
return ( 0, "XSRF form token TTL must be higher than 30s" )
|
||||
|
@ -606,6 +606,18 @@ sub tests {
|
|||
return 1;
|
||||
},
|
||||
|
||||
# Warn if issuers token TTL is higher than 30s
|
||||
issuersTimeout => sub {
|
||||
return 1 unless ( defined $conf->{issuerTimeout} );
|
||||
return ( 0, "Issuers token TTL must be higher than 30s" )
|
||||
unless ( $conf->{issuerTimeout} > 30 );
|
||||
return ( 1, "Issuers token TTL should not be higher than 2mn" )
|
||||
if ( $conf->{issuerTimeout} > 120 );
|
||||
|
||||
# Return
|
||||
return 1;
|
||||
},
|
||||
|
||||
# Warn if number of password reset retries is null
|
||||
passwordResetRetries => sub {
|
||||
return 1 unless ( $conf->{portalDisplayResetPassword} );
|
||||
|
|
|
@ -348,7 +348,9 @@
|
|||
"issuerDBOpenIDConnectActivation":"تفعيل",
|
||||
"issuerDBOpenIDConnectPath":"مسار",
|
||||
"issuerDBOpenIDConnectRule":"استخدام القاعدة",
|
||||
"issuerOptions":"Options",
|
||||
"issuerParams":"وحدات المصدر",
|
||||
"issuersTimeout":"Issuers timeout",
|
||||
"jsRedirect":"إعادة توجيه الرسالة",
|
||||
"jqueryButtonSelector":"زر التحديد ل جي كويري (اختياري)",
|
||||
"jqueryFormSelector":"تحديد الاستمارة ل جي كويري (اختياري)",
|
||||
|
|
|
@ -347,7 +347,9 @@
|
|||
"issuerDBOpenIDConnectActivation":"Activation",
|
||||
"issuerDBOpenIDConnectPath":"Path",
|
||||
"issuerDBOpenIDConnectRule":"Use rule",
|
||||
"issuerOptions":"Options",
|
||||
"issuerParams":"Issuer modules",
|
||||
"issuersTimeout":"Issuers timeout",
|
||||
"jsRedirect":"Redirection message",
|
||||
"jqueryButtonSelector":"jQuery button selector (optional)",
|
||||
"jqueryFormSelector":"jQuery form selector (optional)",
|
||||
|
|
|
@ -347,7 +347,9 @@
|
|||
"issuerDBOpenIDConnectActivation":"Activation",
|
||||
"issuerDBOpenIDConnectPath":"Path",
|
||||
"issuerDBOpenIDConnectRule":"Use rule",
|
||||
"issuerOptions":"Options",
|
||||
"issuerParams":"Issuer modules",
|
||||
"issuersTimeout":"Issuers timeout",
|
||||
"jsRedirect":"Redirection message",
|
||||
"jqueryButtonSelector":"jQuery button selector (optional)",
|
||||
"jqueryFormSelector":"jQuery form selector (optional)",
|
||||
|
|
|
@ -347,7 +347,9 @@
|
|||
"issuerDBOpenIDConnectActivation":"Activation",
|
||||
"issuerDBOpenIDConnectPath":"Chemin",
|
||||
"issuerDBOpenIDConnectRule":"Règle d'utilisation",
|
||||
"issuerOptions":"Options",
|
||||
"issuerParams":"Modules fournisseur",
|
||||
"issuersTimeout":"Délai de validation pour les fournisseurs",
|
||||
"jsRedirect":"Message de redirection",
|
||||
"jqueryButtonSelector":"Sélecteur jQuery du bouton (optionnel)",
|
||||
"jqueryFormSelector":"Sélecteur jQuery du formulaire (optionnel)",
|
||||
|
|
|
@ -347,7 +347,9 @@
|
|||
"issuerDBOpenIDConnectActivation":"Attivazione",
|
||||
"issuerDBOpenIDConnectPath":"Path",
|
||||
"issuerDBOpenIDConnectRule":"Utilizza la regola",
|
||||
"issuerOptions":"Options",
|
||||
"issuerParams":"Moduli emittenti",
|
||||
"issuersTimeout":"Issuers timeout",
|
||||
"jsRedirect":"Messaggio di reindirizzamento",
|
||||
"jqueryButtonSelector":"Selettore del pulsante jQuery (opzionale)",
|
||||
"jqueryFormSelector":"Selettore modulo jQuery (opzionale)",
|
||||
|
|
|
@ -347,7 +347,9 @@
|
|||
"issuerDBOpenIDConnectActivation":"Kích hoạt",
|
||||
"issuerDBOpenIDConnectPath":"Đường dẫn",
|
||||
"issuerDBOpenIDConnectRule":"Quy tắc sử dụng",
|
||||
"issuerOptions":"Options",
|
||||
"issuerParams":"Mô-đun của nhà phát hành",
|
||||
"issuersTimeout":"Issuers timeout",
|
||||
"jsRedirect":"Thông báo chuyển hướng",
|
||||
"jqueryButtonSelector":"nút chọn jQuery (tùy chọn)",
|
||||
"jqueryFormSelector":"trình đơn chọn jQuery (tùy chọn)",
|
||||
|
|
|
@ -347,7 +347,9 @@
|
|||
"issuerDBOpenIDConnectActivation":"激活",
|
||||
"issuerDBOpenIDConnectPath":"Path",
|
||||
"issuerDBOpenIDConnectRule":"Use rule",
|
||||
"issuerOptions":"Options",
|
||||
"issuerParams":"Issuer modules",
|
||||
"issuersTimeout":"Issuers timeout",
|
||||
"jsRedirect":"Redirection message",
|
||||
"jqueryButtonSelector":"jQuery 按钮选择器(可选)",
|
||||
"jqueryFormSelector":"jQuery form selector (optional)",
|
||||
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -79,11 +79,14 @@ sub run {
|
|||
$req,
|
||||
'ext2fcheck',
|
||||
params => {
|
||||
MAIN_LOGO => $self->conf->{portalMainLogo},
|
||||
SKIN => $self->p->getSkin($req),
|
||||
TOKEN => $token,
|
||||
PREFIX => $self->prefix,
|
||||
TARGET => '/' . $self->prefix . '2fcheck',
|
||||
MAIN_LOGO => $self->conf->{portalMainLogo},
|
||||
SKIN => $self->p->getSkin($req),
|
||||
TOKEN => $token,
|
||||
PREFIX => $self->prefix,
|
||||
TARGET => '/'
|
||||
. $self->prefix
|
||||
. '2fcheck?skin='
|
||||
. $self->p->getSkin($req),
|
||||
CHECKLOGINS => $checkLogins
|
||||
}
|
||||
);
|
||||
|
|
|
@ -109,11 +109,14 @@ sub run {
|
|||
$req,
|
||||
'ext2fcheck',
|
||||
params => {
|
||||
MAIN_LOGO => $self->conf->{portalMainLogo},
|
||||
SKIN => $self->p->getSkin($req),
|
||||
TOKEN => $token,
|
||||
PREFIX => $self->prefix,
|
||||
TARGET => '/' . $self->prefix . '2fcheck',
|
||||
MAIN_LOGO => $self->conf->{portalMainLogo},
|
||||
SKIN => $self->p->getSkin($req),
|
||||
TOKEN => $token,
|
||||
PREFIX => $self->prefix,
|
||||
TARGET => '/'
|
||||
. $self->prefix
|
||||
. '2fcheck?skin='
|
||||
. $self->p->getSkin($req),
|
||||
LEGEND => 'enterMail2fCode',
|
||||
CHECKLOGINS => $checkLogins
|
||||
}
|
||||
|
|
|
@ -90,11 +90,14 @@ sub run {
|
|||
$req,
|
||||
'ext2fcheck',
|
||||
params => {
|
||||
MAIN_LOGO => $self->conf->{portalMainLogo},
|
||||
SKIN => $self->p->getSkin($req),
|
||||
TOKEN => $token,
|
||||
PREFIX => $self->prefix,
|
||||
TARGET => '/' . $self->prefix . '2fcheck',
|
||||
MAIN_LOGO => $self->conf->{portalMainLogo},
|
||||
SKIN => $self->p->getSkin($req),
|
||||
TOKEN => $token,
|
||||
PREFIX => $self->prefix,
|
||||
TARGET => '/'
|
||||
. $self->prefix
|
||||
. '2fcheck?skin='
|
||||
. $self->p->getSkin($req),
|
||||
LEGEND => 'enterRest2fCode',
|
||||
CHECKLOGINS => $checkLogins
|
||||
}
|
||||
|
|
|
@ -64,11 +64,14 @@ sub run {
|
|||
$req,
|
||||
'ext2fcheck',
|
||||
params => {
|
||||
MAIN_LOGO => $self->conf->{portalMainLogo},
|
||||
SKIN => $self->p->getSkin($req),
|
||||
TOKEN => $token,
|
||||
PREFIX => $self->prefix,
|
||||
TARGET => '/' . $self->prefix . '2fcheck',
|
||||
MAIN_LOGO => $self->conf->{portalMainLogo},
|
||||
SKIN => $self->p->getSkin($req),
|
||||
TOKEN => $token,
|
||||
PREFIX => $self->prefix,
|
||||
TARGET => '/'
|
||||
. $self->prefix
|
||||
. '2fcheck?skin='
|
||||
. $self->p->getSkin($req),
|
||||
LEGEND => 'enterRadius2fCode',
|
||||
CHECKLOGINS => $checkLogins
|
||||
}
|
||||
|
|
|
@ -108,7 +108,7 @@ sub run {
|
|||
MAIN_LOGO => $self->conf->{portalMainLogo},
|
||||
SKIN => $self->p->getSkin($req),
|
||||
TOKEN => $token,
|
||||
TARGET => '/yubikey2fcheck',
|
||||
TARGET => '/yubikey2fcheck?skin=' . $self->p->getSkin($req),
|
||||
INPUTLOGO => 'yubikey.png',
|
||||
LEGEND => 'clickOnYubikey',
|
||||
CHECKLOGINS => $checkLogins
|
||||
|
|
|
@ -255,6 +255,11 @@ sub loadIDPs {
|
|||
}
|
||||
|
||||
# Add this IDP to Lasso::Server
|
||||
# TODO: when Lasso issue #35061 is fixed in all distros,
|
||||
# we could load the metadata into a new LassoProvider, extract the
|
||||
# parsed-and-decoded entityID from this Provider, and then add the
|
||||
# Provider into the server with $server->add_provider2, instead of
|
||||
# decoding HTML entities ourselves below
|
||||
my $result = $self->addIDP( $self->lassoServer, $idp_metadata );
|
||||
|
||||
unless ($result) {
|
||||
|
@ -267,6 +272,7 @@ sub loadIDPs {
|
|||
( $idp_metadata =~ /entityID=(['"])(.+?)\1/si );
|
||||
|
||||
# Decode HTML entities from entityID
|
||||
# TODO: see Lasso comment above
|
||||
decode_entities($entityID);
|
||||
|
||||
my $name = $self->getOrganizationName( $self->lassoServer, $entityID )
|
||||
|
@ -356,6 +362,11 @@ sub loadSPs {
|
|||
}
|
||||
|
||||
# Add this SP to Lasso::Server
|
||||
# TODO: when Lasso issue #35061 is fixed in all distros,
|
||||
# we could load the metadata into a new LassoProvider, extract the
|
||||
# parsed-and-decoded entityID from this Provider, and then add the
|
||||
# Provider into the server with $server->add_provider2, instead of
|
||||
# decoding HTML entities ourselves below
|
||||
my $result = $self->addSP( $self->lassoServer, $sp_metadata );
|
||||
|
||||
unless ($result) {
|
||||
|
@ -367,6 +378,7 @@ sub loadSPs {
|
|||
my ( $tmp, $entityID ) = ( $sp_metadata =~ /entityID=(['"])(.+?)\1/si );
|
||||
|
||||
# Decode HTML entities from entityID
|
||||
# TODO: see Lasso comment above
|
||||
decode_entities($entityID);
|
||||
|
||||
my $name = $self->getOrganizationName( $self->lassoServer, $entityID )
|
||||
|
|
|
@ -35,7 +35,8 @@ has _ott => (
|
|||
lazy => 1,
|
||||
default => sub {
|
||||
my $ott = $_[0]->{p}->loadModule('::Lib::OneTimeToken');
|
||||
$ott->timeout( $_[0]->{conf}->{formTimeout} );
|
||||
my $timeout = $_[0]->{conf}->{issuersTimeout} // $_[0]->{conf}->{formTimeout};
|
||||
$ott->timeout( $timeout );
|
||||
return $ott;
|
||||
}
|
||||
);
|
||||
|
|
|
@ -9,6 +9,8 @@ use Lemonldap::NG::Portal::Main::Constants qw(
|
|||
PE_PASSWORD_OK
|
||||
PE_PASSWORD_MISMATCH
|
||||
PE_PP_MUST_SUPPLY_OLD_PASSWORD
|
||||
PE_PP_PASSWORD_TOO_SHORT
|
||||
PE_PP_INSUFFICIENT_PASSWORD_QUALITY
|
||||
);
|
||||
|
||||
extends 'Lemonldap::NG::Portal::Main::Plugin';
|
||||
|
@ -52,6 +54,45 @@ sub _modifyPassword {
|
|||
unless ( $self->confirm( $req, $req->data->{oldpassword} ) );
|
||||
}
|
||||
|
||||
# Min size
|
||||
if ( $self->conf->{passwordPolicyMinSize}
|
||||
and length( $req->data->{newpassword} ) <
|
||||
$self->conf->{passwordPolicyMinSize} )
|
||||
{
|
||||
$self->logger->error("Password too short");
|
||||
return PE_PP_PASSWORD_TOO_SHORT;
|
||||
}
|
||||
|
||||
# Min lower
|
||||
if ( $self->conf->{passwordPolicyMinLower} ) {
|
||||
my $lower = 0;
|
||||
$lower++ while ( $req->data->{newpassword} =~ m/\p{lowercase}/g );
|
||||
if ( $lower < $self->conf->{passwordPolicyMinLower} ) {
|
||||
$self->logger->error("Password has not enough lower characters");
|
||||
return PE_PP_INSUFFICIENT_PASSWORD_QUALITY;
|
||||
}
|
||||
}
|
||||
|
||||
# Min upper
|
||||
if ( $self->conf->{passwordPolicyMinUpper} ) {
|
||||
my $upper = 0;
|
||||
$upper++ while ( $req->data->{newpassword} =~ m/\p{uppercase}/g );
|
||||
if ( $upper < $self->conf->{passwordPolicyMinUpper} ) {
|
||||
$self->logger->error("Password has not enough upper characters");
|
||||
return PE_PP_INSUFFICIENT_PASSWORD_QUALITY;
|
||||
}
|
||||
}
|
||||
|
||||
# Min digit
|
||||
if ( $self->conf->{passwordPolicyMinDigit} ) {
|
||||
my $digit = 0;
|
||||
$digit++ while ( $req->data->{newpassword} =~ m/\d/g );
|
||||
if ( $digit < $self->conf->{passwordPolicyMinDigit} ) {
|
||||
$self->logger->error("Password has not enough digit characters");
|
||||
return PE_PP_INSUFFICIENT_PASSWORD_QUALITY;
|
||||
}
|
||||
}
|
||||
|
||||
# Call password package
|
||||
my $res = $self->modifyPassword( $req, $req->data->{newpassword} );
|
||||
if ( $res == PE_PASSWORD_OK ) {
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
html,body{height:100%;background:radial-gradient(circle at 50% 0,#fff 0,#ddd 100%) no-repeat scroll 0 0 #ddd}#wrap{min-height:100%;height:auto;margin:0 auto -80px;padding:20px 0 80px}#footer{height:80px;background-color:#fff;background-color:rgba(255,255,255,0.9);text-align:center;padding-top:10px;overflow:hidden}#header img{background-color:#fff;background-color:rgba(255,255,255,0.8);margin-bottom:20px}.card,.navbar-light{background-color:#fff;background-color:rgba(255,255,255,0.9);background-image:none}.login,.password{text-align:center;padding:20px}div.form{margin:0 auto;max-width:330px}div.actions{margin:10px 0 0 0}div.actions a{margin-top:10px}.buttons{text-align:center;margin:10px 0 0 0;cursor:pointer}.btn{white-space:normal}.btn span.fa{padding-right:8px}li.ui-state-active{background-color:#fafafa;background-color:rgba(250,250,250,0.9)}#appslist,#password,#loginHistory,#logout,#oidcConsents{margin-top:20px}div.category{margin:10px 0;cursor:grab}div.application{margin:5px 0;overflow:hidden}div.application a,div.application a:hover{text-decoration:none}p.notifCheck label{margin-left:5px;margin-top:3px;display:inline-block}img.langicon{cursor:pointer}button.idploop{max-width:300px}button.idploop img{max-height:30px}div.oidc_consent_message>ul{text-align:left;list-style:circle}@media(min-width:768px){div.application{height:80px}div.application h4.appname{margin:0}#wrap{margin:0 auto -60px}#footer{height:60px}}.hiddenFrame{border:0;display:hidden;margin:0}.noborder{border:0}.max{width:100%}.link{cursor:pointer}.nodecor:hover,.nodecor:active.nodecor:focus{text-decoration:none}.fa.icon-blue{color:blue}.progress-bar-animated{width:100%}
|
|
@ -23,7 +23,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="buttons mt-3">
|
||||
<a href="<TMPL_VAR NAME="PORTAL_URL">?cancel=1" class="btn btn-primary" role="button">
|
||||
<a href="<TMPL_VAR NAME="PORTAL_URL">?cancel=1&skin=<TMPL_VAR NAME="SKIN">" class="btn btn-primary" role="button">
|
||||
<span class="fa fa-home"></span>
|
||||
<span trspan="cancel">Cancel</span>
|
||||
</a>
|
||||
|
|
|
@ -64,7 +64,7 @@
|
|||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<a href="<TMPL_VAR NAME="PORTAL_URL">?cancel=1" class="btn btn-primary" role="button">
|
||||
<a href="<TMPL_VAR NAME="PORTAL_URL">?cancel=1&skin=<TMPL_VAR NAME="SKIN">" class="btn btn-primary" role="button">
|
||||
<span class="fa fa-home"></span>
|
||||
<span trspan="goToPortal">Go to portal</span>
|
||||
</a>
|
||||
|
|
|
@ -47,12 +47,12 @@
|
|||
|
||||
<div class="buttons">
|
||||
<TMPL_IF RAW_ERROR>
|
||||
<a href="<TMPL_VAR NAME="PORTAL_URL">2fregisters" class="btn btn-info" role="button">
|
||||
<a href="<TMPL_VAR NAME="PORTAL_URL">2fregisters?skin=<TMPL_VAR NAME="SKIN">" class="btn btn-info" role="button">
|
||||
<span class="fa fa-shield"></span>
|
||||
<span trspan="sfaManager">sfaManager</span>
|
||||
</a>
|
||||
</TMPL_IF>
|
||||
<a href="<TMPL_VAR NAME="PORTAL_URL">?cancel=1<TMPL_IF NAME="AUTH_URL">&url=<TMPL_VAR NAME="AUTH_URL"></TMPL_IF>" class="btn btn-primary" role="button">
|
||||
<a href="<TMPL_VAR NAME="PORTAL_URL">?cancel=1&skin=<TMPL_VAR NAME="SKIN"><TMPL_IF NAME="AUTH_URL">&url=<TMPL_VAR NAME="AUTH_URL"></TMPL_IF>" class="btn btn-primary" role="button">
|
||||
<span class="fa fa-home"></span>
|
||||
<span trspan="goToPortal">Go to portal</span>
|
||||
</a>
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
<div class="form">
|
||||
<input type="hidden" id="token" name="token" value="<TMPL_VAR NAME="TOKEN">" />
|
||||
<input type="hidden" id="checkLogins" name="checkLogins" value="<TMPL_VAR NAME="CHECKLOGINS">" />
|
||||
<input type="hidden" name="skin" value="<TMPL_VAR NAME="SKIN">" />
|
||||
<div class="input-group mb-3">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-lock"></i> </span>
|
||||
|
@ -24,7 +25,7 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<a href="<TMPL_VAR NAME="PORTAL_URL">?cancel=1" class="btn btn-primary" role="button">
|
||||
<a href="<TMPL_VAR NAME="PORTAL_URL">?cancel=1&skin=<TMPL_VAR NAME="SKIN">" class="btn btn-primary" role="button">
|
||||
<span class="fa fa-home"></span>
|
||||
<span trspan="cancel">Cancel</span>
|
||||
</a>
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
<div class="form">
|
||||
<input type="hidden" id="token" name="token" value="<TMPL_VAR NAME="TOKEN">" />
|
||||
<input type="hidden" id="checkLogins" name="checkLogins" value="<TMPL_VAR NAME="CHECKLOGINS">" />
|
||||
<input type="hidden" name="skin" value="<TMPL_VAR NAME="SKIN">" />
|
||||
<div class="input-group mb-3">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-lock"></i> </span>
|
||||
|
@ -24,7 +25,7 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<a href="<TMPL_VAR NAME="PORTAL_URL">?cancel=1" class="btn btn-primary" role="button">
|
||||
<a href="<TMPL_VAR NAME="PORTAL_URL">?cancel=1&skin=<TMPL_VAR NAME="SKIN">" class="btn btn-primary" role="button">
|
||||
<span class="fa fa-home"></span>
|
||||
<span trspan="cancel">Cancel</span>
|
||||
</a>
|
||||
|
|
|
@ -43,11 +43,11 @@
|
|||
</main>
|
||||
|
||||
<div class="buttons">
|
||||
<a href="<TMPL_VAR NAME="PORTAL_URL">2fregisters" class="btn btn-info" role="button">
|
||||
<a href="<TMPL_VAR NAME="PORTAL_URL">2fregisters?skin=<TMPL_VAR NAME="SKIN">" class="btn btn-info" role="button">
|
||||
<span class="fa fa-shield"></span>
|
||||
<span trspan="sfaManager">sfaManager</span>
|
||||
</a>
|
||||
<a id="goback" href="<TMPL_VAR NAME="PORTAL_URL">?cancel=1<TMPL_IF NAME="AUTH_URL">&url=<TMPL_VAR NAME="AUTH_URL"></TMPL_IF>" class="btn btn-primary" role="button">
|
||||
<a id="goback" href="<TMPL_VAR NAME="PORTAL_URL">?cancel=1&skin=<TMPL_VAR NAME="SKIN"><TMPL_IF NAME="AUTH_URL">&url=<TMPL_VAR NAME="AUTH_URL"></TMPL_IF>" class="btn btn-primary" role="button">
|
||||
<span class="fa fa-home"></span>
|
||||
<span trspan="goToPortal">Go to portal</span>
|
||||
</a>
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
<input type="hidden" id="verify-challenge" name="challenge" value="">
|
||||
<input type="hidden" id="token" name="token" value="<TMPL_VAR NAME="TOKEN">">
|
||||
<input type="hidden" id="checkLogins" name="checkLogins" value="<TMPL_VAR NAME="CHECKLOGINS">">
|
||||
<input type="hidden" name="skin" value="<TMPL_VAR NAME="SKIN">" />
|
||||
</form>
|
||||
<script type="application/init">
|
||||
<TMPL_VAR NAME="DATA">
|
||||
|
@ -28,7 +29,7 @@
|
|||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<a href="<TMPL_VAR NAME="PORTAL_URL">?cancel=1" class="btn btn-primary" role="button">
|
||||
<a href="<TMPL_VAR NAME="PORTAL_URL">?cancel=1&skin=<TMPL_VAR NAME="SKIN">" class="btn btn-primary" role="button">
|
||||
<span class="fa fa-home"></span>
|
||||
<span trspan="cancel">Cancel</span>
|
||||
</a>
|
||||
|
|
|
@ -37,12 +37,12 @@
|
|||
</main>
|
||||
|
||||
<div class="buttons">
|
||||
<a href="<TMPL_VAR NAME="PORTAL_URL">2fregisters" class="btn btn-info" role="button">
|
||||
<a href="<TMPL_VAR NAME="PORTAL_URL">2fregisters?skin=<TMPL_VAR NAME="SKIN">" class="btn btn-info" role="button">
|
||||
<span class="fa fa-shield"></span>
|
||||
<span trspan="sfaManager">sfaManager</span>
|
||||
</a>
|
||||
|
||||
<a id="goback" href="<TMPL_VAR NAME="PORTAL_URL">?cancel=1<TMPL_IF NAME="AUTH_URL">&url=<TMPL_VAR NAME="AUTH_URL"></TMPL_IF>" class="btn btn-primary" role="button">
|
||||
<a id="goback" href="<TMPL_VAR NAME="PORTAL_URL">?cancel=1&skin=<TMPL_VAR NAME="SKIN"><TMPL_IF NAME="AUTH_URL">&url=<TMPL_VAR NAME="AUTH_URL"></TMPL_IF>" class="btn btn-primary" role="button">
|
||||
<span class="fa fa-home"></span>
|
||||
<span trspan="goToPortal">Go to portal</span>
|
||||
</a>
|
||||
|
|
|
@ -0,0 +1,192 @@
|
|||
use Test::More;
|
||||
use strict;
|
||||
use IO::String;
|
||||
use JSON;
|
||||
use Lemonldap::NG::Portal::Main::Constants
|
||||
qw(PE_PP_PASSWORD_TOO_SHORT PE_PP_INSUFFICIENT_PASSWORD_QUALITY);
|
||||
|
||||
require 't/test-lib.pm';
|
||||
|
||||
my $res;
|
||||
|
||||
my $client = LLNG::Manager::Test->new( {
|
||||
ini => {
|
||||
logLevel => 'error',
|
||||
passwordDB => 'Demo',
|
||||
portalRequireOldPassword => 1,
|
||||
passwordPolicyMinSize => 6,
|
||||
passwordPolicyMinLower => 3,
|
||||
passwordPolicyMinUpper => 3,
|
||||
passwordPolicyMinDigit => 1,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
# Try to authenticate
|
||||
# -------------------
|
||||
ok(
|
||||
$res = $client->_post(
|
||||
'/',
|
||||
IO::String->new('user=dwho&password=dwho'),
|
||||
length => 23
|
||||
),
|
||||
'Auth query'
|
||||
);
|
||||
count(1);
|
||||
expectOK($res);
|
||||
my $id = expectCookie($res);
|
||||
|
||||
# Test min size
|
||||
# -------------
|
||||
ok(
|
||||
$res = $client->_post(
|
||||
'/',
|
||||
IO::String->new(
|
||||
'oldpassword=dwho&newpassword=test&confirmpassword=test'),
|
||||
cookie => "lemonldap=$id",
|
||||
accept => 'application/json',
|
||||
length => 54
|
||||
),
|
||||
'Password min size not respected'
|
||||
);
|
||||
expectBadRequest($res);
|
||||
my $json;
|
||||
ok( $json = eval { from_json( $res->[2]->[0] ) }, 'Response is JSON' )
|
||||
or print STDERR "$@\n" . Dumper($res);
|
||||
ok(
|
||||
$json->{error} == PE_PP_PASSWORD_TOO_SHORT,
|
||||
'Response is PE_PP_PASSWORD_TOO_SHORT'
|
||||
) or explain( $json, "error => 29" );
|
||||
count(3);
|
||||
|
||||
ok(
|
||||
$res = $client->_post(
|
||||
'/',
|
||||
IO::String->new(
|
||||
'oldpassword=dwho&newpassword=TESTis0k&confirmpassword=TESTis0k'),
|
||||
cookie => "lemonldap=$id",
|
||||
accept => 'application/json',
|
||||
length => 62
|
||||
),
|
||||
'Password min size respected'
|
||||
);
|
||||
expectOK($res);
|
||||
count(1);
|
||||
|
||||
# Test min lower
|
||||
# --------------
|
||||
ok(
|
||||
$res = $client->_post(
|
||||
'/',
|
||||
IO::String->new(
|
||||
'oldpassword=dwho&newpassword=TESTLOWer&confirmpassword=TESTLOWer'),
|
||||
cookie => "lemonldap=$id",
|
||||
accept => 'application/json',
|
||||
length => 64
|
||||
),
|
||||
'Password min lower not respected'
|
||||
);
|
||||
expectBadRequest($res);
|
||||
my $json;
|
||||
ok( $json = eval { from_json( $res->[2]->[0] ) }, 'Response is JSON' )
|
||||
or print STDERR "$@\n" . Dumper($res);
|
||||
ok(
|
||||
$json->{error} == PE_PP_INSUFFICIENT_PASSWORD_QUALITY,
|
||||
'Response is PE_PP_INSUFFICIENT_PASSWORD_QUALITY'
|
||||
) or explain( $json, "error => 28" );
|
||||
count(3);
|
||||
|
||||
ok(
|
||||
$res = $client->_post(
|
||||
'/',
|
||||
IO::String->new(
|
||||
'oldpassword=dwho&newpassword=TESTl0wer&confirmpassword=TESTl0wer'),
|
||||
cookie => "lemonldap=$id",
|
||||
accept => 'application/json',
|
||||
length => 64
|
||||
),
|
||||
'Password min lower respected'
|
||||
);
|
||||
expectOK($res);
|
||||
count(1);
|
||||
|
||||
# Test min upper
|
||||
# --------------
|
||||
ok(
|
||||
$res = $client->_post(
|
||||
'/',
|
||||
IO::String->new(
|
||||
'oldpassword=dwho&newpassword=testUPper&confirmpassword=testUPper'),
|
||||
cookie => "lemonldap=$id",
|
||||
accept => 'application/json',
|
||||
length => 64
|
||||
),
|
||||
'Password min upper not respected'
|
||||
);
|
||||
expectBadRequest($res);
|
||||
my $json;
|
||||
ok( $json = eval { from_json( $res->[2]->[0] ) }, 'Response is JSON' )
|
||||
or print STDERR "$@\n" . Dumper($res);
|
||||
ok(
|
||||
$json->{error} == PE_PP_INSUFFICIENT_PASSWORD_QUALITY,
|
||||
'Response is PE_PP_INSUFFICIENT_PASSWORD_QUALITY'
|
||||
) or explain( $json, "error => 28" );
|
||||
count(3);
|
||||
|
||||
ok(
|
||||
$res = $client->_post(
|
||||
'/',
|
||||
IO::String->new(
|
||||
'oldpassword=dwho&newpassword=t3stUPPER&confirmpassword=t3stUPPER'),
|
||||
cookie => "lemonldap=$id",
|
||||
accept => 'application/json',
|
||||
length => 64
|
||||
),
|
||||
'Password min upper respected'
|
||||
);
|
||||
expectOK($res);
|
||||
count(1);
|
||||
|
||||
# Test min digit
|
||||
# --------------
|
||||
ok(
|
||||
$res = $client->_post(
|
||||
'/',
|
||||
IO::String->new(
|
||||
'oldpassword=dwho&newpassword=testDIGIT&confirmpassword=testDIGIT'),
|
||||
cookie => "lemonldap=$id",
|
||||
accept => 'application/json',
|
||||
length => 64
|
||||
),
|
||||
'Password min digit not respected'
|
||||
);
|
||||
expectBadRequest($res);
|
||||
my $json;
|
||||
ok( $json = eval { from_json( $res->[2]->[0] ) }, 'Response is JSON' )
|
||||
or print STDERR "$@\n" . Dumper($res);
|
||||
ok(
|
||||
$json->{error} == PE_PP_INSUFFICIENT_PASSWORD_QUALITY,
|
||||
'Response is PE_PP_INSUFFICIENT_PASSWORD_QUALITY'
|
||||
) or explain( $json, "error => 28" );
|
||||
count(3);
|
||||
|
||||
ok(
|
||||
$res = $client->_post(
|
||||
'/',
|
||||
IO::String->new(
|
||||
'oldpassword=dwho&newpassword=t3stDIGIT&confirmpassword=t3stDIGIT'),
|
||||
cookie => "lemonldap=$id",
|
||||
accept => 'application/json',
|
||||
length => 64
|
||||
),
|
||||
'Password min digit respected'
|
||||
);
|
||||
expectOK($res);
|
||||
count(1);
|
||||
|
||||
# Test $client->logout
|
||||
$client->logout($id);
|
||||
|
||||
clean_sessions();
|
||||
|
||||
done_testing( count() );
|
|
@ -65,7 +65,7 @@ ok( $res->[2]->[0] =~ qr%<img src="/static/common/logos/logo_llng_old.png"%,
|
|||
count(1);
|
||||
|
||||
my ( $host, $url, $query ) =
|
||||
expectForm( $res, undef, '/rest2fcheck', 'token', 'code', 'checkLogins' );
|
||||
expectForm( $res, undef, '/rest2fcheck?skin=bootstrap', 'token', 'code', 'checkLogins' );
|
||||
$query =~ s/code=/code=1234/;
|
||||
|
||||
ok(
|
||||
|
|
|
@ -93,7 +93,7 @@ ok(
|
|||
count(1);
|
||||
|
||||
my ( $host, $url, $query ) =
|
||||
expectForm( $res, undef, '/ext2fcheck', 'token', 'code', 'checkLogins' );
|
||||
expectForm( $res, undef, '/ext2fcheck?skin=bootstrap', 'token', 'code', 'checkLogins' );
|
||||
|
||||
ok(
|
||||
$res->[2]->[0] =~
|
||||
|
@ -134,7 +134,7 @@ ok(
|
|||
count(1);
|
||||
|
||||
( $host, $url, $query ) =
|
||||
expectForm( $res, undef, '/ext2fcheck', 'token', 'code', 'checkLogins' );
|
||||
expectForm( $res, undef, '/ext2fcheck?skin=bootstrap', 'token', 'code', 'checkLogins' );
|
||||
|
||||
ok(
|
||||
$res->[2]->[0] =~
|
||||
|
|
|
@ -36,7 +36,7 @@ ok(
|
|||
count(1);
|
||||
|
||||
my ( $host, $url, $query ) =
|
||||
expectForm( $res, undef, '/ext2fcheck', 'token', 'code' );
|
||||
expectForm( $res, undef, '/ext2fcheck?skin=bootstrap', 'token', 'code' );
|
||||
|
||||
ok(
|
||||
$res->[2]->[0] =~
|
||||
|
|
|
@ -38,7 +38,7 @@ ok(
|
|||
count(1);
|
||||
|
||||
my ( $host, $url, $query ) =
|
||||
expectForm( $res, undef, '/ext2fcheck', 'token', 'code' );
|
||||
expectForm( $res, undef, '/ext2fcheck?skin=bootstrap', 'token', 'code' );
|
||||
|
||||
ok(
|
||||
$res->[2]->[0] =~
|
||||
|
|
|
@ -77,7 +77,7 @@ ok(
|
|||
);
|
||||
|
||||
( $host, $url, $query ) =
|
||||
expectForm( $res, undef, '/ext2fcheck', 'token', 'code', 'checkLogins' );
|
||||
expectForm( $res, undef, '/ext2fcheck?skin=bootstrap', 'token', 'code', 'checkLogins' );
|
||||
|
||||
ok(
|
||||
$res->[2]->[0] =~
|
||||
|
|
|
@ -52,7 +52,7 @@ count(1);
|
|||
|
||||
# Only "work" option is available
|
||||
my ( $host, $url, $query ) =
|
||||
expectForm( $res, undef, '/work2fcheck', 'token', 'code' );
|
||||
expectForm( $res, undef, '/work2fcheck?skin=bootstrap', 'token', 'code' );
|
||||
|
||||
ok(
|
||||
$res->[2]->[0] =~
|
||||
|
@ -137,7 +137,7 @@ ok(
|
|||
count(1);
|
||||
|
||||
my ( $host, $url, $query ) =
|
||||
expectForm( $res, undef, '/home2fcheck', 'token', 'code' );
|
||||
expectForm( $res, undef, '/home2fcheck?skin=bootstrap', 'token', 'code' );
|
||||
|
||||
ok(
|
||||
$res->[2]->[0] =~
|
||||
|
|
|
@ -35,7 +35,7 @@ ok(
|
|||
count(1);
|
||||
|
||||
my ( $host, $url, $query ) =
|
||||
expectForm( $res, undef, '/mail2fcheck', 'token', 'code' );
|
||||
expectForm( $res, undef, '/mail2fcheck?skin=bootstrap', 'token', 'code' );
|
||||
|
||||
ok(
|
||||
$res->[2]->[0] =~
|
||||
|
|
|
@ -34,7 +34,7 @@ ok(
|
|||
count(1);
|
||||
|
||||
my ( $host, $url, $query ) =
|
||||
expectForm( $res, undef, '/mail2fcheck', 'token', 'code' );
|
||||
expectForm( $res, undef, '/mail2fcheck?skin=bootstrap', 'token', 'code' );
|
||||
|
||||
ok(
|
||||
$res->[2]->[0] =~
|
||||
|
|
|
@ -105,7 +105,7 @@ count(1);
|
|||
$pdata = expectCookie( $res, 'lemonldappdata' );
|
||||
|
||||
( $host, $url, $query ) =
|
||||
expectForm( $res, undef, '/mail2fcheck', 'token', 'code' );
|
||||
expectForm( $res, undef, '/mail2fcheck?skin=bootstrap', 'token', 'code' );
|
||||
|
||||
ok(
|
||||
$res->[2]->[0] =~
|
||||
|
|
Loading…
Reference in New Issue