diff --git a/lemonldap-ng-common/lib/Lemonldap/NG/Common/Conf/Constants.pm b/lemonldap-ng-common/lib/Lemonldap/NG/Common/Conf/Constants.pm index 9362915df..85a7d8e51 100644 --- a/lemonldap-ng-common/lib/Lemonldap/NG/Common/Conf/Constants.pm +++ b/lemonldap-ng-common/lib/Lemonldap/NG/Common/Conf/Constants.pm @@ -24,7 +24,7 @@ use constant MANAGERSECTION => "manager"; use constant SESSIONSEXPLORERSECTION => "sessionsExplorer"; use constant APPLYSECTION => "apply"; our $hashParameters = qr/^(?:(?:l(?:o(?:ca(?:lSessionStorageOption|tionRule)|goutService)|dapExportedVar|wp(?:Ssl)?Opt)|(?:(?:d(?:emo|bi)|facebook|webID)ExportedVa|exported(?:Heade|Va)|issuerDBGetParamete)r|re(?:moteGlobalStorageOption|st2f(?:Verify|Init)Arg|loadUrl)|g(?:r(?:antSessionRule|oup)|lobalStorageOption)|n(?:otificationStorageOption|ginxCustomHandler)|macro)s|o(?:idc(?:RPMetaData(?:(?:Option(?:sExtraClaim)?|ExportedVar)s|Node)|OPMetaData(?:(?:ExportedVar|Option)s|J(?:SON|WKS)|Node)|S(?:erviceMetaDataAuthnContext|torageOptions))|penIdExportedVars)|s(?:aml(?:S(?:PMetaData(?:(?:ExportedAttribute|Option)s|Node|XML)|torageOptions)|IDPMetaData(?:(?:ExportedAttribute|Option)s|Node|XML))|essionDataToRemember|laveExportedVars)|c(?:as(?:S(?:rvMetaData(?:(?:ExportedVar|Option)s|Node)|torageOptions)|A(?:ppMetaData(?:(?:ExportedVar|Option)s|Node)|ttributes))|(?:ustomAddParam|ombModule)s)|p(?:ersistentStorageOptions|o(?:rtalSkinRules|st))|a(?:ut(?:hChoiceMod|oSigninR)ules|pplicationList)|v(?:hostOptions|irtualHost)|S(?:MTPTLSOpts|SLVarIf))$/; -our $boolKeys = qr/^(?:s(?:aml(?:IDP(?:MetaDataOptions(?:(?:Check(?:S[LS]OMessageSignatur|Audienc|Tim)|IsPassiv)e|A(?:llow(?:LoginFromIDP|ProxiedAuthn)|daptSessionUtime)|Force(?:Authn|UTF8)|StoreSAMLToken|RelayStateURL)|SSODescriptorWantAuthnRequestsSigned)|S(?:P(?:MetaDataOptions(?:(?:CheckS[LS]OMessageSignatur|OneTimeUs)e|EnableIDPInitiatedURL|ForceUTF8)|SSODescriptor(?:WantAssertion|AuthnRequest)sSigned)|erviceUseCertificateInResponse)|DiscoveryProtocol(?:Activation|IsPassive)|CommonDomainCookieActivation|UseQueryStringSpecific|MetadataForceUTF8)|ingle(?:Session(?:UserByIP)?|(?:UserBy)?IP)|oap(?:Session|Config)Server|t(?:ayConnecte|orePasswor)d|kipRenewConfirmation|howLanguages|slByAjax)|o(?:idc(?:ServiceAllow(?:(?:AuthorizationCode|Implicit|Hybrid)Flow|DynamicRegistration)|RPMetaDataOptions(?:LogoutSessionRequired|BypassConsent|RequirePKCE|Public)|OPMetaDataOptions(?:(?:CheckJWTSignatur|UseNonc)e|StoreIDToken))|ldNotifFormat)|p(?:ortal(?:ErrorOn(?:ExpiredSession|MailNotFound)|DisplayRe(?:setPassword|gister)|(?:CheckLogin|Statu)s|OpenLinkInNewWindow|RequireOldPassword|ForceAuthn|AntiFrame)|roxyUseSoap)|l(?:dap(?:(?:Group(?:DecodeSearchedValu|Recursiv)|UsePasswordResetAttribut)e|(?:AllowResetExpired|Set)Password|ChangePasswordAsUser|PpolicyControl)|oginHistoryEnabled)|c(?:a(?:ptcha_(?:register|login|mail)_enabled|sSrvMetaDataOptions(?:Gateway|Renew))|heck(?:User(?:Display(?:PersistentInfo|EmptyValues))?|State|XSS)|da)|i(?:ssuerDB(?:OpenID(?:Connect)?|SAML|CAS|Get)Activation|mpersonation(?:SkipEmptyValue|MergeSSOgroup)s)|to(?:tp2f(?:UserCan(?:Chang|Remov)eKey|DisplayExistingSecret)|kenUseGlobalStorage)|u(?:se(?:RedirectOn(?:Forbidden|Error)|SafeJail)|2fUserCanRemoveKey|pgradeSession)|no(?:tif(?:ication(?:Server)?|y(?:Deleted|Other))|AjaxHook)|(?:mai(?:lOnPasswordChang|ntenanc)|vhostMaintenanc)e|(?:(?:rest(?:Session|Config)|wsdl)Serv|activeTim)er|h(?:ideOldPassword|ttpOnly)|yubikey2fUserCanRemoveKey|krb(?:RemoveDomain|ByJs)|dbiDynamicHashEnabled|bruteForceProtection)$/; +our $boolKeys = qr/^(?:s(?:aml(?:IDP(?:MetaDataOptions(?:(?:Check(?:S[LS]OMessageSignatur|Audienc|Tim)|IsPassiv)e|A(?:llow(?:LoginFromIDP|ProxiedAuthn)|daptSessionUtime)|Force(?:Authn|UTF8)|StoreSAMLToken|RelayStateURL)|SSODescriptorWantAuthnRequestsSigned)|S(?:P(?:MetaDataOptions(?:(?:CheckS[LS]OMessageSignatur|OneTimeUs)e|EnableIDPInitiatedURL|ForceUTF8)|SSODescriptor(?:WantAssertion|AuthnRequest)sSigned)|erviceUseCertificateInResponse)|DiscoveryProtocol(?:Activation|IsPassive)|CommonDomainCookieActivation|UseQueryStringSpecific|MetadataForceUTF8)|ingle(?:Session(?:UserByIP)?|(?:UserBy)?IP)|oap(?:Session|Config)Server|t(?:ayConnecte|orePasswor)d|kipRenewConfirmation|howLanguages|slByAjax)|o(?:idc(?:ServiceAllow(?:(?:AuthorizationCode|Implicit|Hybrid)Flow|DynamicRegistration)|RPMetaDataOptions(?:LogoutSessionRequired|BypassConsent|RequirePKCE|Public)|OPMetaDataOptions(?:(?:CheckJWTSignatur|UseNonc)e|StoreIDToken))|ldNotifFormat)|p(?:ortal(?:ErrorOn(?:ExpiredSession|MailNotFound)|DisplayRe(?:setPassword|gister)|(?:CheckLogin|Statu)s|OpenLinkInNewWindow|RequireOldPassword|ForceAuthn|AntiFrame)|roxyUseSoap)|l(?:dap(?:(?:Group(?:DecodeSearchedValu|Recursiv)|UsePasswordResetAttribut)e|(?:AllowResetExpired|Set)Password|ChangePasswordAsUser|PpolicyControl)|oginHistoryEnabled)|c(?:a(?:ptcha_(?:register|login|mail)_enabled|sSrvMetaDataOptions(?:Gateway|Renew))|heck(?:User(?:Display(?:PersistentInfo|EmptyValues))?|State|XSS)|orsEnabled|da)|i(?:ssuerDB(?:OpenID(?:Connect)?|SAML|CAS|Get)Activation|mpersonation(?:SkipEmptyValue|MergeSSOgroup)s)|to(?:tp2f(?:UserCan(?:Chang|Remov)eKey|DisplayExistingSecret)|kenUseGlobalStorage)|u(?:se(?:RedirectOn(?:Forbidden|Error)|SafeJail)|2fUserCanRemoveKey|pgradeSession)|no(?:tif(?:ication(?:Server)?|y(?:Deleted|Other))|AjaxHook)|(?:mai(?:lOnPasswordChang|ntenanc)|vhostMaintenanc)e|(?:(?:rest(?:Session|Config)|wsdl)Serv|activeTim)er|h(?:ideOldPassword|ttpOnly)|yubikey2fUserCanRemoveKey|krb(?:RemoveDomain|ByJs)|dbiDynamicHashEnabled|bruteForceProtection)$/; our @sessionTypes = ( 'remoteGlobal', 'global', 'localSession', 'persistent', 'saml', 'oidc', 'cas' ); diff --git a/lemonldap-ng-common/lib/Lemonldap/NG/Common/Conf/DefaultValues.pm b/lemonldap-ng-common/lib/Lemonldap/NG/Common/Conf/DefaultValues.pm index 8b98eed6f..94ac1a63d 100644 --- a/lemonldap-ng-common/lib/Lemonldap/NG/Common/Conf/DefaultValues.pm +++ b/lemonldap-ng-common/lib/Lemonldap/NG/Common/Conf/DefaultValues.pm @@ -33,6 +33,13 @@ sub defaultValues { 'checkXSS' => 1, 'confirmFormMethod' => 'post', 'cookieName' => 'lemonldap', + 'corsAllow_Credentials' => 'true', + 'corsAllow_Headers' => '*', + 'corsAllow_Methods' => 'POST,GET', + 'corsAllow_Origin' => '*', + 'corsEnabled' => 1, + 'corsExpose_Headers' => '*', + 'corsMax_Age' => '86400', 'cspConnect' => '\'self\'', 'cspDefault' => '\'self\'', 'cspFont' => '\'self\'', diff --git a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Attributes.pm b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Attributes.pm index c4ec30894..58fd39130 100644 --- a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Attributes.pm +++ b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Attributes.pm @@ -943,6 +943,34 @@ qr/(?:(?:https?):\/\/(?:(?:(?:(?:(?:(?:[a-zA-Z0-9][-a-zA-Z0-9]*)?[a-zA-Z0-9])[.] 'test' => qr/^[a-zA-Z][a-zA-Z0-9_-]*$/, 'type' => 'text' }, + 'corsAllow_Credentials' => { + 'default' => 'true', + 'type' => 'text' + }, + 'corsAllow_Headers' => { + 'default' => '*', + 'type' => 'text' + }, + 'corsAllow_Methods' => { + 'default' => 'POST,GET', + 'type' => 'text' + }, + 'corsAllow_Origin' => { + 'default' => '*', + 'type' => 'text' + }, + 'corsEnabled' => { + 'default' => 1, + 'type' => 'bool' + }, + 'corsExpose_Headers' => { + 'default' => '*', + 'type' => 'text' + }, + 'corsMax_Age' => { + 'default' => '86400', + 'type' => 'text' + }, 'cspConnect' => { 'default' => '\'self\'', 'type' => 'text' diff --git a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Build/Attributes.pm b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Build/Attributes.pm index 9b2ea1a1a..693448871 100644 --- a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Build/Attributes.pm +++ b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Build/Attributes.pm @@ -717,6 +717,47 @@ sub attributes { type => 'password', documentation => 'Secret key', }, + corsEnabled => { + default => 1, + type => 'bool', + documentation => 'Enable Cross-Origin Resource Sharing', + }, + corsAllow_Credentials => { + type => 'text', + default => 'true', + documentation => + 'Allow credentials for Cross-Origin Resource Sharing', + }, + corsAllow_Headers => { + type => 'text', + default => '*', + documentation => + 'Allowed headers for Cross-Origin Resource Sharing', + }, + corsAllow_Methods => { + type => 'text', + default => 'POST,GET', + documentation => + 'Allowed methods for Cross-Origin Resource Sharing', + }, + corsAllow_Origin => { + type => 'text', + default => '*', + documentation => + 'Allowed origine for Cross-Origin Resource Sharing', + }, + corsExpose_Headers => { + type => 'text', + default => '*', + documentation => + 'Exposed headers for Cross-Origin Resource Sharing', + }, + corsMax_Age => { + type => 'text', + default => '86400', # 24 hours + documentation => + 'MAx-age for Cross-Origin Resource Sharing', + }, cspDefault => { type => 'text', default => "'self'", diff --git a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Build/Tree.pm b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Build/Tree.pm index 7cb18a670..51499121d 100644 --- a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Build/Tree.pm +++ b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Build/Tree.pm @@ -817,6 +817,17 @@ sub tree { 'cspConnect', ] }, + { + title => 'crossOrigineResourceSharing', + help => 'security.html#portal', + form => 'simpleInputContainer', + nodes => [ + 'corsEnabled', 'corsAllow_Credentials', + 'corsAllow_Headers', 'corsAllow_Methods', + 'corsAllow_Origin', 'corsExpose_Headers', + 'corsMax_Age', + ] + }, ] }, { diff --git a/lemonldap-ng-manager/site/coffee/sessions.coffee b/lemonldap-ng-manager/site/coffee/sessions.coffee index c037ecb53..2a8060775 100644 --- a/lemonldap-ng-manager/site/coffee/sessions.coffee +++ b/lemonldap-ng-manager/site/coffee/sessions.coffee @@ -9,8 +9,10 @@ max = 25 # of opened nodes in the tree schemes = _whatToTrace: [ + # First level: display 1 letter (t,v) -> "groupBy=substr(#{t},1)" + # Second level (if no overScheme), display usernames (t,v) -> "#{t}=#{v}*&groupBy=#{t}" (t,v) -> @@ -59,12 +61,18 @@ schemes = q.replace(/\&groupBy.*$/, '') + "&ipAddr=#{v}" ] +# When number of children nodes exceeds "max" value and if "overScheme." +# is available and does not return "null", a level is added. See +# "$scope.updateTree" method overScheme = _whatToTrace: (t,v,level,over) -> - if level == 1 and v.length < max + # "v.length > over" avoids a loop if one user opened more than "max" + # sessions + if level == 1 and v.length > over "#{t}=#{v}*&groupBy=substr(#{t},#{(level+over+1)})" else null + # Note: IPv4 only ipAddr: (t,v,level,over) -> if level > 0 and level < 4 "#{t}=#{v}*&groupBy=net(#{t},#{16*level+4*(over+1)},2)" diff --git a/lemonldap-ng-manager/site/htdocs/static/js/sessions.js b/lemonldap-ng-manager/site/htdocs/static/js/sessions.js index 50c03b177..12e503275 100644 --- a/lemonldap-ng-manager/site/htdocs/static/js/sessions.js +++ b/lemonldap-ng-manager/site/htdocs/static/js/sessions.js @@ -1,4 +1,4 @@ -// Generated by CoffeeScript 1.12.7 +// Generated by CoffeeScript 1.12.8 /* * Sessions explorer @@ -74,7 +74,7 @@ overScheme = { _whatToTrace: function(t, v, level, over) { - if (level === 1 && v.length < max) { + if (level === 1 && v.length > over) { return t + "=" + v + "*&groupBy=substr(" + t + "," + (level + over + 1) + ")"; } else { return null; diff --git a/lemonldap-ng-manager/site/htdocs/static/js/sessions.min.js b/lemonldap-ng-manager/site/htdocs/static/js/sessions.min.js index ce37f3060..fd81b7d01 100644 --- a/lemonldap-ng-manager/site/htdocs/static/js/sessions.min.js +++ b/lemonldap-ng-manager/site/htdocs/static/js/sessions.min.js @@ -1 +1 @@ -(function(){var categories,hiddenAttributes,llapp,max,menu,overScheme,schemes;max=25;schemes={_whatToTrace:[function(t,v){return"groupBy=substr("+t+",1)"},function(t,v){return t+"="+v+"*&groupBy="+t},function(t,v){return t+"="+v}],ipAddr:[function(t,v){return"groupBy=net("+t+",16,1)"},function(t,v){if(!v.match(/:/)){v=v+"."}return t+"="+v+"*&groupBy=net("+t+",32,2)"},function(t,v){if(!v.match(/:/)){v=v+"."}return t+"="+v+"*&groupBy=net("+t+",48,3)"},function(t,v){if(!v.match(/:/)){v=v+"."}return t+"="+v+"*&groupBy=net("+t+",128,4)"},function(t,v){return t+"="+v+"&groupBy=_whatToTrace"},function(t,v,q){return q.replace(/\&groupBy.*$/,"")+("&_whatToTrace="+v)}],_startTime:[function(t,v){return"groupBy=substr("+t+",8)"},function(t,v){return t+"="+v+"*&groupBy=substr("+t+",10)"},function(t,v){return t+"="+v+"*&groupBy=substr("+t+",11)"},function(t,v){return t+"="+v+"*&groupBy=substr("+t+",12)"},function(t,v){return t+"="+v+"*&groupBy=_whatToTrace"},function(t,v,q){console.log(t);console.log(v);console.log(q);return q.replace(/\&groupBy.*$/,"")+("&_whatToTrace="+v)}],doubleIp:[function(t,v){return t},function(t,v){return"_whatToTrace="+v+"&groupBy=ipAddr"},function(t,v,q){return q.replace(/\&groupBy.*$/,"")+("&ipAddr="+v)}]};overScheme={_whatToTrace:function(t,v,level,over){if(level===1&&v.length0&&level<4){return t+"="+v+"*&groupBy=net("+t+","+(16*level+4*(over+1))+",2)"}else{return null}},_startTime:function(t,v,level,over){if(level>3){return t+"="+v+"*&groupBy=substr("+t+","+(9+level+over+1)+")"}else{return null}}};hiddenAttributes="_password";categories={dateTitle:["_utime","_startTime","_updateTime","_lastAuthnUTime","_lastSeen"],connectionTitle:["ipAddr","_timezone","_url"],authenticationTitle:["_session_id","_user","_password","authenticationLevel"],modulesTitle:["_auth","_userDB","_passwordDB","_issuerDB","_authChoice","_authMulti","_userDBMulti"],saml:["_idp","_idpConfKey","_samlToken","_lassoSessionDump","_lassoIdentityDump"],groups:["groups","hGroups"],ldap:["dn"],BrowserID:["_browserIdAnswer","_browserIdAnswerRaw"],OpenIDConnect:["_oidc_id_token","_oidc_OP","_oidc_access_token"],sfaTitle:["_2fDevices"],oidcConsents:["_oidcConsents"]};menu={session:[{title:"deleteSession",icon:"trash"}],home:[]};llapp=angular.module("llngSessionsExplorer",["ui.tree","ui.bootstrap","llApp"]);llapp.controller("SessionsExplorerCtrl",["$scope","$translator","$location","$q","$http",function($scope,$translator,$location,$q,$http){var autoId,c,pathEvent,sessionType;$scope.links=links;$scope.menulinks=menulinks;$scope.staticPrefix=staticPrefix;$scope.scriptname=scriptname;$scope.formPrefix=formPrefix;$scope.impPrefix=impPrefix;$scope.sessionTTL=sessionTTL;$scope.availableLanguages=availableLanguages;$scope.waiting=true;$scope.showM=false;$scope.showT=true;$scope.data=[];$scope.currentScope=null;$scope.currentSession=null;$scope.menu=menu;$scope.translateP=$translator.translateP;$scope.translate=$translator.translate;$scope.translateTitle=function(node){return $translator.translateField(node,"title")};sessionType="global";$scope.menuClick=function(button){if(button.popup){window.open(button.popup)}else{if(!button.action){button.action=button.title}switch(typeof button.action){case"function":button.action($scope.currentNode,$scope);break;case"string":$scope[button.action]();break;default:console.log(typeof button.action)}}return $scope.showM=false};$scope.deleteOIDCConsent=function(rp,epoch){var item;item=angular.element(".data-"+epoch);item.remove();$scope.waiting=true;$http["delete"](scriptname+"sessions/OIDCConsent/"+sessionType+"/"+$scope.currentSession.id+"?rp="+rp+"&epoch="+epoch).then(function(response){return $scope.waiting=false},function(resp){return $scope.waiting=false});return $scope.showT=false};$scope.deleteSession=function(){$scope.waiting=true;return $http["delete"](scriptname+"sessions/"+sessionType+"/"+$scope.currentSession.id).then(function(response){$scope.currentSession=null;$scope.currentScope.remove();return $scope.waiting=false},function(resp){$scope.currentSession=null;$scope.currentScope.remove();return $scope.waiting=false})};$scope.stoggle=function(scope){var node;node=scope.$modelValue;if(node.nodes.length===0){$scope.updateTree(node.value,node.nodes,node.level,node.over,node.query,node.count)}return scope.toggle()};$scope.displaySession=function(scope){var sessionId,transformSession;transformSession=function(session){var _insert,array,attr,attrs,category,cv,element,epoch,i,id,j,k,key,l,len,len1,len2,len3,len4,len5,m,name,o,oidcConsent,p,real,ref,ref1,res,sfDevice,spoof,subres,time,title,tmp,value;_insert=function(re,title){var key,reg,tmp,value;tmp=[];reg=new RegExp(re);for(key in session){value=session[key];if(key.match(reg)&&value){tmp.push({title:key,value:value});delete session[key]}}if(tmp.length>0){return res.push({title:title,nodes:tmp})}};time=session._utime;id=session._session_id;for(key in session){value=session[key];if(!value){delete session[key]}else{if(typeof session==="string"&&value.match(/; /)){session[key]=value.split("; ")}if(typeof session[key]!=="object"){if(hiddenAttributes.match(new RegExp("\b"+key+"\b"))){session[key]="********"}else if(key.match(/^(_utime|_lastAuthnUTime|_lastSeen|notification)$/)){session[key]=$scope.localeDate(value)}else if(key.match(/^(_startTime|_updateTime)$/)){session[key]=$scope.strToLocaleDate(value)}}}}res=[];for(category in categories){attrs=categories[category];subres=[];for(i=0,len=attrs.length;i0){res.push({title:"__"+category+"__",nodes:subres})}}_insert("^openid","OpenID");_insert("^notification_(.+)","__notificationsDone__");if(session._loginHistory){tmp=[];if(session._loginHistory.successLogin){ref=session._loginHistory.successLogin;for(m=0,len3=ref.length;mb.title){return 1}else if(a.title real attribute");real.push(element)}else{spoof.push(element)}}tmp=spoof.concat(real);res.push({title:"__attributesAndMacros__",nodes:tmp});return{_utime:time,id:id,nodes:res}};$scope.currentScope=scope;sessionId=scope.$modelValue.session;$http.get(scriptname+"sessions/"+sessionType+"/"+sessionId).then(function(response){return $scope.currentSession=transformSession(response.data)});return $scope.showT=false};$scope.localeDate=function(s){var d;d=new Date(s*1e3);return d.toLocaleString()};$scope.isValid=function(epoch,type){var isValid,now,path;path=$location.path();now=Date.now()/1e3;console.log("Path",path);console.log("Session epoch",epoch);console.log("Current date",now);console.log("Session TTL",sessionTTL);isValid=now-epochmax&&overScheme[$scope.type]){if(tmp=overScheme[$scope.type]($scope.type,value,level,over,currentQuery)){over++;query=tmp;level=level-1}else{over=0}}else{over=0}return $http.get(scriptname+"sessions/"+sessionType+"?"+query).then(function(response){var data,i,len,n,ref;data=response.data;if(data.result){ref=data.values;for(i=0,len=ref.length;i0&&level<4){return t+"="+v+"*&groupBy=net("+t+","+(16*level+4*(over+1))+",2)"}else{return null}},_startTime:function(t,v,level,over){if(level>3){return t+"="+v+"*&groupBy=substr("+t+","+(9+level+over+1)+")"}else{return null}}};hiddenAttributes="_password";categories={dateTitle:["_utime","_startTime","_updateTime","_lastAuthnUTime","_lastSeen"],connectionTitle:["ipAddr","_timezone","_url"],authenticationTitle:["_session_id","_user","_password","authenticationLevel"],modulesTitle:["_auth","_userDB","_passwordDB","_issuerDB","_authChoice","_authMulti","_userDBMulti"],saml:["_idp","_idpConfKey","_samlToken","_lassoSessionDump","_lassoIdentityDump"],groups:["groups","hGroups"],ldap:["dn"],BrowserID:["_browserIdAnswer","_browserIdAnswerRaw"],OpenIDConnect:["_oidc_id_token","_oidc_OP","_oidc_access_token"],sfaTitle:["_2fDevices"],oidcConsents:["_oidcConsents"]};menu={session:[{title:"deleteSession",icon:"trash"}],home:[]};llapp=angular.module("llngSessionsExplorer",["ui.tree","ui.bootstrap","llApp"]);llapp.controller("SessionsExplorerCtrl",["$scope","$translator","$location","$q","$http",function($scope,$translator,$location,$q,$http){var autoId,c,pathEvent,sessionType;$scope.links=links;$scope.menulinks=menulinks;$scope.staticPrefix=staticPrefix;$scope.scriptname=scriptname;$scope.formPrefix=formPrefix;$scope.impPrefix=impPrefix;$scope.sessionTTL=sessionTTL;$scope.availableLanguages=availableLanguages;$scope.waiting=true;$scope.showM=false;$scope.showT=true;$scope.data=[];$scope.currentScope=null;$scope.currentSession=null;$scope.menu=menu;$scope.translateP=$translator.translateP;$scope.translate=$translator.translate;$scope.translateTitle=function(node){return $translator.translateField(node,"title")};sessionType="global";$scope.menuClick=function(button){if(button.popup){window.open(button.popup)}else{if(!button.action){button.action=button.title}switch(typeof button.action){case"function":button.action($scope.currentNode,$scope);break;case"string":$scope[button.action]();break;default:console.log(typeof button.action)}}return $scope.showM=false};$scope.deleteOIDCConsent=function(rp,epoch){var item;item=angular.element(".data-"+epoch);item.remove();$scope.waiting=true;$http["delete"](scriptname+"sessions/OIDCConsent/"+sessionType+"/"+$scope.currentSession.id+"?rp="+rp+"&epoch="+epoch).then(function(response){return $scope.waiting=false},function(resp){return $scope.waiting=false});return $scope.showT=false};$scope.deleteSession=function(){$scope.waiting=true;return $http["delete"](scriptname+"sessions/"+sessionType+"/"+$scope.currentSession.id).then(function(response){$scope.currentSession=null;$scope.currentScope.remove();return $scope.waiting=false},function(resp){$scope.currentSession=null;$scope.currentScope.remove();return $scope.waiting=false})};$scope.stoggle=function(scope){var node;node=scope.$modelValue;if(node.nodes.length===0){$scope.updateTree(node.value,node.nodes,node.level,node.over,node.query,node.count)}return scope.toggle()};$scope.displaySession=function(scope){var sessionId,transformSession;transformSession=function(session){var _insert,array,attr,attrs,category,cv,element,epoch,i,id,j,k,key,l,len,len1,len2,len3,len4,len5,m,name,o,oidcConsent,p,real,ref,ref1,res,sfDevice,spoof,subres,time,title,tmp,value;_insert=function(re,title){var key,reg,tmp,value;tmp=[];reg=new RegExp(re);for(key in session){value=session[key];if(key.match(reg)&&value){tmp.push({title:key,value:value});delete session[key]}}if(tmp.length>0){return res.push({title:title,nodes:tmp})}};time=session._utime;id=session._session_id;for(key in session){value=session[key];if(!value){delete session[key]}else{if(typeof session==="string"&&value.match(/; /)){session[key]=value.split("; ")}if(typeof session[key]!=="object"){if(hiddenAttributes.match(new RegExp("\b"+key+"\b"))){session[key]="********"}else if(key.match(/^(_utime|_lastAuthnUTime|_lastSeen|notification)$/)){session[key]=$scope.localeDate(value)}else if(key.match(/^(_startTime|_updateTime)$/)){session[key]=$scope.strToLocaleDate(value)}}}}res=[];for(category in categories){attrs=categories[category];subres=[];for(i=0,len=attrs.length;i0){res.push({title:"__"+category+"__",nodes:subres})}}_insert("^openid","OpenID");_insert("^notification_(.+)","__notificationsDone__");if(session._loginHistory){tmp=[];if(session._loginHistory.successLogin){ref=session._loginHistory.successLogin;for(m=0,len3=ref.length;mb.title){return 1}else if(a.title real attribute");real.push(element)}else{spoof.push(element)}}tmp=spoof.concat(real);res.push({title:"__attributesAndMacros__",nodes:tmp});return{_utime:time,id:id,nodes:res}};$scope.currentScope=scope;sessionId=scope.$modelValue.session;$http.get(scriptname+"sessions/"+sessionType+"/"+sessionId).then(function(response){return $scope.currentSession=transformSession(response.data)});return $scope.showT=false};$scope.localeDate=function(s){var d;d=new Date(s*1e3);return d.toLocaleString()};$scope.isValid=function(epoch,type){var isValid,now,path;path=$location.path();now=Date.now()/1e3;console.log("Path",path);console.log("Session epoch",epoch);console.log("Current date",now);console.log("Session TTL",sessionTTL);isValid=now-epochmax&&overScheme[$scope.type]){if(tmp=overScheme[$scope.type]($scope.type,value,level,over,currentQuery)){over++;query=tmp;level=level-1}else{over=0}}else{over=0}return $http.get(scriptname+"sessions/"+sessionType+"?"+query).then(function(response){var data,i,len,n,ref;data=response.data;if(data.result){ref=data.values;for(i=0,len=ref.length;iparam('checkLogins'); + my $spoofId = $req->param('spoofId') || ''; $self->logger->debug("2F checkLogins set") if ($checkLogins); # Skip 2F unless a module has been registered @@ -186,6 +187,8 @@ sub run { $req->sessionInfo->{_2fRealSession} = $req->id; $req->sessionInfo->{_2fUrldc} = $req->urldc; $req->sessionInfo->{_2fUtime} = $req->{sessionInfo}->{_utime}; + $req->sessionInfo->{_impSpoofId} = $spoofId; + $req->sessionInfo->{_impUser} = $req->user; my $token = $self->ott->createToken( $req->sessionInfo ); delete $req->{authResult}; diff --git a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Auth/Choice.pm b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Auth/Choice.pm index 372a919a5..61163df9e 100644 --- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Auth/Choice.pm +++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Auth/Choice.pm @@ -26,18 +26,13 @@ sub extractFormInfo { my ( $self, $req ) = @_; unless ( $self->checkChoice($req) ) { $self->logger->debug("Initializing Auth modules..."); - - foreach my $mod ( values %{ $self->modules } ) { - if ( $mod->can('setSecurity') ) { - $mod->setSecurity($req); - last; - } - } + $self->setSecurity($req); $self->logger->debug( "Send init/script -> " . $req->data->{customScript} ) if $req->data->{customScript}; return PE_FIRSTACCESS; } + my $res = $req->data->{enabledMods0}->[0]->extractFormInfo($req); delete $req->pdata->{_choice} if ( $res > 0 ); return $res; @@ -65,4 +60,14 @@ sub authLogout { return $res; } +sub setSecurity { + my ( $self, $req ) = @_; + foreach my $mod ( values %{ $self->modules } ) { + if ( $mod->can('setSecurity') ) { + $mod->setSecurity($req); + last; + } + } +} + 1; diff --git a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Init.pm b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Init.pm index a8c302da3..5987de0b5 100644 --- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Init.pm +++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Init.pm @@ -80,9 +80,12 @@ has spRules => ( # Custom template parameters has customParameters => ( is => 'rw', default => sub { {} } ); -# Content-Security-Policy header +# Content-Security-Policy headers has csp => ( is => 'rw' ); +# Cross-Origine Resource Sharing headers +has cors => ( is => 'rw' ); + # INITIALIZATION sub init { @@ -179,13 +182,29 @@ sub reloadConf { $self->{conf}->{$key} ||= $conf->{$key}; } - # Initialize content-security-policy header + # Initialize content-security-policy headers my $csp = ''; foreach (qw(default img src style font connect script)) { my $prm = $self->conf->{ 'csp' . ucfirst($_) }; $csp .= "$_-src $prm;" if ($prm); } $self->csp($csp); + $self->logger->debug( "Initialized CSP headers : " . $self->csp ); + + # Initialize Cross-Origin Resource Sharing headers + my $cors = ''; + foreach ( + qw(Allow_Origin Allow_Credentials Allow_Headers Allow_Methods Expose_Headers Max_Age) + ) + { + my $header = $_; + my $prm = $self->conf->{ 'cors' . $_ }; + $header =~ s/_/-/; + $prm =~ s/\s+//; + $cors .= "Access-Control-$header;$prm;"; + } + $self->cors($cors); + $self->logger->debug( "Initialized CORS headers : " . $self->cors ); # Initialize templateDir $self->{templateDir} = diff --git a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Run.pm b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Run.pm index 695ad2b40..ed6f6d945 100644 --- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Run.pm +++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Run.pm @@ -790,6 +790,9 @@ sub sendHtml { 'Pragma' => 'no-cache', # HTTP 1.0 'Expires' => '0'; # Proxies + my @cors = split /;/, $self->cors; + push @{ $res->[1] }, @cors if $self->conf->{corsEnabled}; + # Set authorized URL for POST my $csp = $self->csp . "form-action " . $self->conf->{cspFormAction}; if ( my $url = $req->urldc ) { diff --git a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/Impersonation.pm b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/Impersonation.pm index 31e0d2b40..dc00d066a 100644 --- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/Impersonation.pm +++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/Impersonation.pm @@ -37,17 +37,16 @@ sub init { $self->rule($rule); # Parse identity rule - $self->logger->debug( "Impersonation identities rule -> " + $self->logger->debug( "Impersonation identity rule -> " . $self->conf->{impersonationIdRule} ); $rule = $hd->buildSub( $hd->substitute( $self->conf->{impersonationIdRule} ) ); unless ($rule) { $self->error( - "Bad impersonation identities rule -> " . $hd->tsv->{jail}->error ); + "Bad impersonation identity rule -> " . $hd->tsv->{jail}->error ); return 0; } $self->idRule($rule); - return 1; } @@ -55,10 +54,19 @@ sub init { sub run { my ( $self, $req ) = @_; - my $spoofId = $req->param('spoofId') || $req->{user}; + + return $req->authResult if $req->authResult > PE_OK; # Skip Impersonation if error during Auth process + + my $statut = PE_OK; + my $loginHistory = + $req->{sessionInfo}->{_loginHistory}; # Store login history + $req->{user} ||= $req->{sessionInfo}->{_impUser}; # If 2FA is enabled + my $spoofId = $req->param('spoofId') # Impersonation required + || $req->{sessionInfo}->{_impSpoofId} # If 2FA is enabled + || $req->{user}; # NO Impersonation required + $self->logger->debug("No impersonation required") if ( $spoofId eq $req->{user} ); - my $statut = PE_OK; if ( $spoofId !~ /$self->{conf}->{userControl}/o ) { $self->userLogger->error('Malformed spoofed Id'); @@ -69,7 +77,7 @@ sub run { # Check activation rule if ( $spoofId ne $req->{user} ) { - $self->logger->debug("Spoofied Id: $spoofId / Real Id: $req->{user}"); + $self->logger->debug("Spoof Id: $spoofId / Real Id: $req->{user}"); unless ( $self->rule->( $req, $req->sessionInfo ) ) { $self->userLogger->error('Impersonation service not authorized'); $spoofId = $req->{user}; @@ -86,7 +94,9 @@ sub run { next unless defined $req->{sessionInfo}->{$k}; } $spk = "$self->{conf}->{impersonationPrefix}$k"; - unless ( $self->hAttr =~ /\b$k\b/ ) { + unless ( $self->hAttr =~ /\b$k\b/ + || $k =~ /^(?:_imp|token|_type)\w*\b/ ) + { $realSession->{$spk} = $req->{sessionInfo}->{$k}; $self->logger->debug("-> Store $k in realSession key: $spk"); } @@ -94,7 +104,7 @@ sub run { delete $req->{sessionInfo}->{$k}; } - $spoofSession = $self->_userDatas( $req, $spoofId, $realSession ); + $spoofSession = $self->_userData( $req, $spoofId, $realSession ); if ( $req->error ) { if ( $req->error == PE_BADCREDENTIALS ) { $statut = PE_BADCREDENTIALS; @@ -104,8 +114,8 @@ sub run { } } - # Update spoofed session - $self->logger->debug("Populating spoofed session..."); + # Update spoof session + $self->logger->debug("Populating spoof session..."); foreach (qw (_auth _userDB)) { $self->logger->debug("Processing $_..."); $spk = "$self->{conf}->{impersonationPrefix}$_"; @@ -138,9 +148,11 @@ sub run { # Main session $self->p->updateSession( $req, $spoofSession ); + $req->{sessionInfo}->{_loginHistory} = + $loginHistory; # Restore login history $req->steps( [ $self->p->validSession, @{ $self->p->endAuth } ] ); - # Restore _httpSession for double Cookies + # Restore _httpSession for Double Cookies if ( $self->conf->{securedCookie} >= 2 ) { $self->p->updateSession( $req, $spoofSession, $req->{sessionInfo}->{real__httpSession} ); @@ -150,13 +162,13 @@ sub run { return $statut; } -sub _userDatas { +sub _userData { my ( $self, $req, $spoofId, $realSession ) = @_; my $realId = $req->{user}; $req->{user} = $spoofId; my $raz = 0; - # Compute Macros and Groups with real and spoofed sessions + # Compute Macros and Groups with real and spoof sessions $req->{sessionInfo} = {%$realSession}; # Search user in database @@ -178,7 +190,7 @@ sub _userDatas { $raz = 1; } - # Check identity rule if impersonation required + # Check identity rule if Impersonation required if ( $realId ne $spoofId ) { unless ( $self->idRule->( $req, $req->sessionInfo ) ) { $self->userLogger->warn( @@ -190,7 +202,7 @@ sub _userDatas { } } - # Same real and spoofed session - Compute Macros and Groups + # Same real and spoof session - Compute Macros and Groups if ($raz) { $req->{sessionInfo} = {}; $req->{sessionInfo} = {%$realSession}; @@ -201,14 +213,13 @@ sub _userDatas { 'setLocalGroups' ] ); - $self->logger->debug('Spoofed session equal real session'); + $self->logger->debug('Spoof session equal real session'); $req->error(PE_BADCREDENTIALS); if ( my $error = $self->p->process($req) ) { $self->logger->debug("Process returned error: $error"); $req->error($error); } } - return $req->{sessionInfo}; } diff --git a/lemonldap-ng-portal/t/01-CSP-and-CORS-headers.t b/lemonldap-ng-portal/t/01-CSP-and-CORS-headers.t new file mode 100644 index 000000000..5283bc080 --- /dev/null +++ b/lemonldap-ng-portal/t/01-CSP-and-CORS-headers.t @@ -0,0 +1,153 @@ +use Test::More; +use strict; +use IO::String; + +require 't/test-lib.pm'; + +my $res; + +my $client = LLNG::Manager::Test->new( { + ini => { + logLevel => 'error', + useSafeJail => 1, + 'corsAllow_Origin' => '', + 'corsAllow_Methods' => 'POST', + 'cspFormAction' => '*' + } + } +); + +# Test normal first access +# ------------------------ +ok( $res = $client->_get('/'), 'Unauth JSON request' ); +count(1); +expectReject($res); + +# Test "first access" with good url +ok( + $res = + $client->_get( '/', query => 'url=aHR0cDovL3Rlc3QxLmV4YW1wbGUuY29tLw==' ), + 'Unauth ajax request with good url' +); +count(1); +expectReject($res); + +ok( $res = $client->_get( '/', accept => 'text/html' ), 'Get Menu' ); +ok( $res->[2]->[0] =~ m%%, ' Language icons found' ) + or print STDERR Dumper( $res->[2]->[0] ); +count(2); + +# CORS +ok( $res->[1]->[12] eq 'Access-Control-Allow-Origin', ' CORS origin found' ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[13] eq '', " CORS origin ''" ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[14] eq 'Access-Control-Allow-Credentials', + ' CORS credentials found' ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[15] eq 'true', " CORS credentials 'true'" ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[16] eq 'Access-Control-Allow-Headers', " CORS headers found" ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[17] eq '*', " CORS headers '*'" ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[18] eq 'Access-Control-Allow-Methods', " CORS methods found" ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[19] eq 'POST', " CORS methods 'POST'" ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[20] eq 'Access-Control-Expose-Headers', + " CORS expose-headers found" ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[21] eq '*', " CORS expose-headers '*'" ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[22] eq 'Access-Control-Max-Age', ' CORS max-age found' ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[23] == 86400, ' CORS max-age 86400' ) + or print STDERR Dumper( $res->[1] ); +count(12); + +#CSP +ok( $res->[1]->[26] eq 'Content-Security-Policy', ' CSP found' ) + or print STDERR Dumper( $res->[1] ); +ok( + $res->[1]->[27] =~ +/default-src 'self';img-src 'self' data:;style-src 'self';font-src 'self';connect-src 'self';script-src 'self';form-action \*;frame-ancestors 'none'/, + ' CSP headers found' +) or print STDERR Dumper( $res->[1] ); +count(2); + +# Try to authenticate with good password +# -------------------------------------- +ok( + $res = $client->_post( + '/', + IO::String->new('user=dwho&password=dwho'), + length => 23, + ), + 'Auth query' +); +count(1); +expectOK($res); +my $id = expectCookie($res); + +# Try to get a redirection for an auth user with a valid url +# ---------------------------------------------------------- +ok( + $res = $client->_get( + '/', + query => 'url=aHR0cDovL3Rlc3QxLmV4YW1wbGUuY29tLw==', + cookie => "lemonldap=$id", + accept => 'text/html' + ), + 'Auth ajax request with good url' +); +count(1); +expectRedirection( $res, 'http://test1.example.com/' ); +expectAuthenticatedAs( $res, 'dwho' ); + +ok( + $res = $client->_get( + 'http://test1.example.com/', + cookie => "lemonldap=$id", + accept => 'text/html' + ), + 'Get test1' +); +count(1); + +ok( $res->[1]->[14] eq 'Access-Control-Allow-Origin', ' CORS origin found' ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[15] eq '', " CORS origin ''" ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[16] eq 'Access-Control-Allow-Credentials', + ' CORS credentials found' ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[17] eq 'true', " CORS credentials 'true'" ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[18] eq 'Access-Control-Allow-Headers', " CORS headers found" ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[19] eq '*', " CORS headers '*'" ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[20] eq 'Access-Control-Allow-Methods', " CORS methods found" ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[21] eq 'POST', " CORS methods 'POST'" ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[22] eq 'Access-Control-Expose-Headers', + " CORS expose-headers found" ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[23] eq '*', " CORS expose-headers '*'" ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[24] eq 'Access-Control-Max-Age', ' CORS max-age found' ) + or print STDERR Dumper( $res->[1] ); +ok( $res->[1]->[25] == 86400, ' CORS max-age 86400' ) + or print STDERR Dumper( $res->[1] ); +count(12); + +# Test logout +$client->logout($id); + +#print STDERR Dumper($res); + +clean_sessions(); + +done_testing( count() ); diff --git a/lemonldap-ng-portal/t/28-AuthChoice-and-password.t b/lemonldap-ng-portal/t/28-AuthChoice-and-password.t index 2d62d4781..835167bdb 100644 --- a/lemonldap-ng-portal/t/28-AuthChoice-and-password.t +++ b/lemonldap-ng-portal/t/28-AuthChoice-and-password.t @@ -58,7 +58,7 @@ SKIP: { ) { - # Try yo authenticate + # Try to authenticate # ------------------- ok( $res = $client->_get( '/', accept => 'text/html' ), 'Get menu' ); my @form = ( $res->[2]->[0] =~ m##sg ); diff --git a/lemonldap-ng-portal/t/28-AuthChoice-with-captcha.t b/lemonldap-ng-portal/t/28-AuthChoice-with-captcha.t new file mode 100644 index 000000000..481cfb9a6 --- /dev/null +++ b/lemonldap-ng-portal/t/28-AuthChoice-with-captcha.t @@ -0,0 +1,107 @@ +use Test::More; +use IO::String; +use strict; + +require 't/test-lib.pm'; + +my $res; +my $maintests = 14; +SKIP: { + eval 'use GD::SecurityImage;use Image::Magick;'; + if ($@) { + skip 'Image::Magick not found', $maintests; + } + my $client = LLNG::Manager::Test->new( { + ini => { + logLevel => 'error', + useSafeJail => 1, + authentication => 'Choice', + userDB => 'Same', + passwordDB => 'Choice', + captcha_login_enabled => 1, + authChoiceParam => 'test', + authChoiceModules => { + '1_demo' => 'Demo;Demo;Null', + '2_ssl' => 'SSL;Demo;Null', + }, + } + } + ); + + # Try to authenticate with an unknown user + # ------------------- + ok( $res = $client->_get( '/', accept => 'text/html' ), 'Get menu' ); + my ( $host, $url, $query ) = + expectForm( $res, '#', undef, 'user', 'password', 'token' ); + + $query =~ s/.*\btoken=([^&]+).*/token=$1/; + my $token; + ok( $token = $1, ' Token value is defined' ); + ok( $res->[2]->[0] =~ m#_post( + '/', IO::String->new($query), + length => length($query), + accept => 'text/html', + ), + 'Auth query with an unknown user' + ); + ( $host, $url, $query ) = + expectForm( $res, '#', undef, 'user', 'password', 'token' ); + + ok( $res->[2]->[0] =~ /<\/span><\/div>/, + 'dalek rejected with PE_BADCREDENTIALS' ) + or print STDERR Dumper( $res->[2]->[0] ); + + # Try to authenticate + # ------------------- + $query =~ s/.*\btoken=([^&]+).*/token=$1/; + ok( $token = $1, ' Token value is defined' ); + ok( $res->[2]->[0] =~ m#_post( + '/', IO::String->new($query), + length => length($query), + accept => 'text/html', + ), + 'Auth query' + ); + my $id = expectCookie($res); + $client->logout($id); +} +count($maintests); +clean_sessions(); +done_testing( count() ); diff --git a/lemonldap-ng-portal/t/28-AuthChoice-with-rules.t b/lemonldap-ng-portal/t/28-AuthChoice-with-rules.t index c92ab9883..2628a8a54 100644 --- a/lemonldap-ng-portal/t/28-AuthChoice-with-rules.t +++ b/lemonldap-ng-portal/t/28-AuthChoice-with-rules.t @@ -120,7 +120,7 @@ m%