Merge branch 'feature-cas-service-url-2321' into 'v2.0'

Feature cas service url 2321

See merge request lemonldap-ng/lemonldap-ng!175
This commit is contained in:
Maxime Besson 2021-01-05 18:49:24 +01:00
commit 127aa91a8f
5 changed files with 194 additions and 26 deletions

View File

@ -106,7 +106,7 @@ Options
^^^^^^^
- **Service URL** : the service (user-facing) URL of the CAS-enabled
application.
application. See :ref:`idpcas-url-matching`
- **User attribute** : session field that will be used as main
identifier.
- **Authentication Level** : required authentication level to access this
@ -125,3 +125,21 @@ Macros
You can define here macros that will be only evaluated for this service,
and not registered in the session of the user.
.. _idpcas-url-matching:
URL Matching
^^^^^^^^^^^^
.. versionchanged:: 2.0.10
Before version 2.0.10, only the hostname was taken into account, which made it impossible to have two different CAS services behind the same hostname.
Since version 2.0.10, the entire service URL is compared to the Service URL defined in LemonLDAP::NG. The longest prefix wins.
For example, if you declared two applications in LemonLDAP::NG with the following service URLs:
* https://cas.example.com/applications/zone1
* https://cas.example.com/applications/
An application located at https://cas.example.com/applications/zone1/myapp will match the first CAS service definition

View File

@ -861,14 +861,14 @@ sub tests {
return ( $res, join( ', ', @msg ) );
},
# CAS APP URL must be unique
# CAS APP URL must be defined and unique
casAppHostnameUniqueness => sub {
return 1
unless ( $conf->{casAppMetaDataOptions}
and %{ $conf->{casAppMetaDataOptions} } );
my @msg;
my $res = 1;
my %casHosts;
my %casUrl;
foreach my $casConfKey ( keys %{ $conf->{casAppMetaDataOptions} } )
{
my $appUrl =
@ -883,13 +883,13 @@ sub tests {
next;
}
if ( defined $casHosts{$appHost} ) {
if ( defined $casUrl{$appUrl} ) {
push @msg,
"$casConfKey and $casHosts{$appHost} have the same Service hostname";
"$casConfKey and $casUrl{$appUrl} have the same Service URL";
$res = 0;
next;
}
$casHosts{$appHost} = $casConfKey;
$casUrl{$appUrl} = $casConfKey;
}
return ( $res, join( ', ', @msg ) );
},

View File

@ -59,10 +59,9 @@ sub init {
);
# Add CAS Services, so we can check service= parameter on logout
foreach my $casSrv ( keys %{ $self->conf->{casAppMetaDataOptions} } ) {
foreach my $casSrv ( keys %{ $self->casAppList } ) {
if ( my $serviceUrl =
$self->conf->{casAppMetaDataOptions}->{$casSrv}
->{casAppMetaDataOptionsService} )
$self->casAppList->{$casSrv}->{casAppMetaDataOptionsService} )
{
push @{ $self->p->{additionalTrustedDomains} }, $serviceUrl;
$self->logger->debug(
@ -96,14 +95,14 @@ sub storeEnvAndCheckGateway {
if ( $service and $service =~ m#^(https?://[^/]+)(/.*)?$# ) {
my ( $host, $uri ) = ( $1, $2 );
my $app = $self->casAppList->{$host};
my $app = $self->getCasApp($service);
if ($app) {
$req->env->{llng_cas_app} = $app;
# Store target authentication level in pdata
my $targetAuthnLevel = $self->conf->{casAppMetaDataOptions}->{$app}
->{casAppMetaDataOptionsAuthnLevel};
my $targetAuthnLevel =
$self->casAppList->{$app}->{casAppMetaDataOptionsAuthnLevel};
$req->pdata->{targetAuthnLevel} = $targetAuthnLevel
if $targetAuthnLevel;
@ -168,12 +167,12 @@ sub run {
return PE_ERROR;
}
my ( $host, $uri ) = ( $1, $2 );
my $app = $self->casAppList->{$host};
my $app = $self->getCasApp($service);
my $spAuthnLevel = 0;
if ($app) {
$spAuthnLevel = $self->conf->{casAppMetaDataOptions}->{$app}
->{casAppMetaDataOptionsAuthnLevel} || 0;
$spAuthnLevel =
$self->casAppList->{$app}->{casAppMetaDataOptionsAuthnLevel} || 0;
}
# Renew
@ -851,10 +850,8 @@ sub getUsernameForApp {
my $username_attribute =
( $app
and $self->conf->{casAppMetaDataOptions}->{$app}
->{casAppMetaDataOptionsUserAttribute} )
? $self->conf->{casAppMetaDataOptions}->{$app}
->{casAppMetaDataOptionsUserAttribute}
and $self->casAppList->{$app}->{casAppMetaDataOptionsUserAttribute} )
? $self->casAppList->{$app}->{casAppMetaDataOptionsUserAttribute}
: ( $self->conf->{casAttr}
|| $self->conf->{whatToTrace} );

View File

@ -5,6 +5,7 @@ use Mouse;
use Lemonldap::NG::Common::FormEncode;
use XML::Simple;
use Lemonldap::NG::Common::UserAgent;
use URI;
our $VERSION = '2.0.8';
@ -43,20 +44,21 @@ sub loadSrv {
return 1;
}
# Load CAS application list, key is the service URL
# Load CAS application list
sub loadApp {
my ($self) = @_;
unless ( $self->conf->{casAppMetaDataOptions}
if ( $self->conf->{casAppMetaDataOptions}
and %{ $self->conf->{casAppMetaDataOptions} } )
{
$self->casAppList( $self->conf->{casAppMetaDataOptions} );
}
else {
$self->logger->info("No CAS apps found in configuration");
}
foreach ( keys %{ $self->conf->{casAppMetaDataOptions} } ) {
my $tmp =
$self->conf->{casAppMetaDataOptions}->{$_}
->{casAppMetaDataOptionsService};
$tmp =~ s#^(https?://[^/]+).*$#$1#;
$self->casAppList->{$tmp} = $_;
# Load access rule
my $rule = $self->conf->{casAppMetaDataOptions}->{$_}
->{casAppMetaDataOptionsRule};
if ( length $rule ) {
@ -497,6 +499,59 @@ sub retrievePT {
return $pt;
}
# Get CAS App from service URL
sub getCasApp {
my ( $self, $uri_param ) = @_;
my $uri = URI->new($uri_param);
my $hostname = $uri->authority;
my $uriCanon = $uri->canonical;
return undef unless $hostname;
my $prefixConfKey;
my $longestCandidate = "";
my $hostnameConfKey;
for my $app ( keys %{ $self->casAppList } ) {
my $candidateUri =
URI->new( $self->casAppList->{$app}->{casAppMetaDataOptionsService} );
my $candidateHost = $candidateUri->authority;
my $candidateCanon = $candidateUri->canonical;
# Try to match prefix, remembering the longest match found
if ( index( $uriCanon, $candidateCanon ) == 0 ) {
if ( length($longestCandidate) < length($candidateCanon) ) {
$longestCandidate = $candidateCanon;
$prefixConfKey = $app;
}
}
# Try to match host
$hostnameConfKey = $app if ( $hostname eq $candidateHost );
}
# Application found by prefix has priority
return $prefixConfKey if $prefixConfKey;
$self->logger->warn(
"Matched CAS service $hostnameConfKey based on hostname only. "
. "This will be deprecated in a future version" )
if $hostnameConfKey;
return $hostnameConfKey;
}
# This method returns the host part of the given URL
# If the URL has no scheme, return it completely
# http://example.com/uri => example.com
# foo.bar => foo.bar
sub _getHostForService {
my ( $self, $service ) = @_;
return undef unless $service;
my $uri = URI->new($service);
return $uri->scheme ? $uri->host : $uri->as_string;
}
1;
__END__

View File

@ -0,0 +1,98 @@
use lib 'inc';
use Test::More;
use strict;
use IO::String;
use LWP::UserAgent;
use LWP::Protocol::PSGI;
use MIME::Base64;
BEGIN {
require 't/test-lib.pm';
}
my $debug = 'error';
my ( $issuer, $res );
eval { require XML::Simple };
plan skip_all => "Missing dependencies: $@" if ($@);
# Login
ok( $issuer = issuer(), 'Issuer portal' );
count(1);
my $s = "user=dwho&password=dwho";
my $id = expectCookie(
$issuer->_post(
'/',
IO::String->new($s),
accept => 'text/html',
length => length($s),
)
);
# Service 1, will be matched by URI
ok(
$res = $issuer->_get(
'/cas/login',
query => 'service=http://auth.sp.com/srv1/index.php',
accept => 'text/html',
cookie => "lemonldap=$id",
),
'Query CAS server'
);
count(1);
expectRedirection( $res,
qr#^http://auth.sp.com/srv1/index.php\?(ticket=[^&]+)$# );
# Service 2, will be matched by hostname
ok(
$res = $issuer->_get(
'/cas/login',
query => 'service=http://auth.other.com/srv2',
accept => 'text/html',
cookie => "lemonldap=$id",
),
'Query CAS server'
);
count(1);
expectRedirection( $res, qr#^http://auth.other.com/srv2\?(ticket=[^&]+)$# );
clean_sessions();
done_testing( count() );
sub issuer {
return LLNG::Manager::Test->new( {
ini => {
logLevel => $debug,
domain => 'idp.com',
portal => 'http://auth.idp.com',
authentication => 'Demo',
userDB => 'Same',
issuerDBCASActivation => 1,
casAttr => 'uid',
casAppMetaDataOptions => {
sp1 => {
casAppMetaDataOptionsService =>
'https://auth.other.com/xxxyz',
casAppMetaDataOptionsRule => "1",
},
sp2 => {
casAppMetaDataOptionsService => 'http://auth.sp.com/',
casAppMetaDataOptionsRule => "0",
},
sp3 => {
casAppMetaDataOptionsService =>
'http://auth.sp.com/srv1/',
casAppMetaDataOptionsRule => "1",
},
sp4 => {
casAppMetaDataOptionsService =>
'http://auth.sp.com/srv2/',
casAppMetaDataOptionsRule => "0",
},
},
casAccessControlPolicy => 'error',
multiValuesSeparator => ';',
}
}
);
}