lemonldap-ng/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Issuer/CAS.pm
2017-11-11 13:51:48 +01:00

663 lines
20 KiB
Perl

package Lemonldap::NG::Portal::Issuer::CAS;
use strict;
use Mouse;
use URI;
use Lemonldap::NG::Portal::Main::Constants qw(
PE_CAS_SERVICE_NOT_ALLOWED
PE_CONFIRM
PE_ERROR
PE_LOGOUT_OK
PE_OK
);
our $VERSION = '2.0.0';
extends 'Lemonldap::NG::Portal::Main::Issuer',
'Lemonldap::NG::Portal::Lib::CAS';
# INTERFACE
sub beforeAuth { 'exportRequestParameters' }
# INITIALIZATION
sub init {
my ($self) = @_;
# Launch parents initialization subroutines, then launch IdP en SP lists
my $res = $self->Lemonldap::NG::Portal::Main::Issuer::init();
return 0 unless ( $self->loadApp );
$self->addUnauthRoute(
( $self->path ) => {
serviceValidate => 'serviceValidate',
validate => 'validate',
proxyValidate => 'proxyValidate',
proxy => 'proxy'
},
['GET']
);
return $res;
}
# RUNNING METHODS
# Main method (launched only for authenticated users, see Main/Issuer)
sub run {
my ( $self, $req, $target ) = @_;
# CAS URL
my $cas_login = 'login';
my $cas_logout = 'logout';
my $cas_validate = 'validate';
my $cas_serviceValidate = 'serviceValidate';
my $cas_proxyValidate = 'proxyValidate';
my $cas_proxy = 'proxy';
# Called URL
my $url = $req->uri();
# Session ID
my $session_id = $req->{sessionInfo}->{_session_id} || $req->{id};
# Session creation timestamp
my $time = $req->{sessionInfo}->{_utime} || time();
# 1. LOGIN
if ( $target eq $cas_login ) {
$self->logger->debug("URL $url detected as an CAS LOGIN URL");
# GET parameters
my $service = $self->p->getHiddenFormValue( $req, 'service' )
|| $req->param('service');
my $renew =
$self->p->getHiddenFormValue( $req, 'renew' ) || $req->param('renew');
my $gateway = $self->p->getHiddenFormValue( $req, 'gateway' )
|| $req->param('gateway');
my $casServiceTicket;
# Renew
if ( $renew and $renew eq 'true' ) {
# Authentication must be replayed
$self->logger->debug("Authentication renew requested");
$self->{updateSession} = 1;
$req->steps(
[
@{ $self->p->beforeAuth },
$self->p->authProcess,
@{ $self->p->betweenAuthAndDatas },
$self->p->sessionDatas,
@{ $self->p->afterDatas },
]
);
return PE_OK;
}
# If no service defined, exit
unless ( defined $service ) {
$self->logger->debug("No service defined in CAS URL");
return PE_OK;
}
unless ( $service =~ m#^(https?://[^/]+)(/.*)?$# ) {
$self->logger->error("Bad service $service");
return PE_ERROR;
}
my ( $host, $uri ) = ( $1, $2 );
my $app = $self->casAppList->{$host};
# Check access on the service
my $casAccessControlPolicy = $self->conf->{casAccessControlPolicy};
if ( $casAccessControlPolicy =~ /^(error|faketicket)$/i ) {
$self->logger->debug(
"CAS access control requested on service $service");
unless ($app) {
$self->userLogger->error('CAS service not configured');
return PE_CAS_SERVICE_NOT_ALLOWED;
}
if ( my $rule = $self->spRules->{$app} ) {
if ( $rule->( $req, $req->sessionInfo ) ) {
$self->logger->debug("CAS service $service access allowed");
}
else {
$self->userLogger->error(
"CAS service $service access not allowed");
if ( $casAccessControlPolicy =~ /^(error)$/i ) {
$self->logger->debug(
"Return error instead of redirecting user on CAS service"
);
return PE_CAS_SERVICE_NOT_ALLOWED;
}
else {
$self->logger->debug(
"Redirect user on CAS service with a fake ticket");
$casServiceTicket = "ST-F4K3T1CK3T";
}
}
}
}
unless ($casServiceTicket) {
# Check last authentication time to decide if
# the authentication is recent or not
my $casRenewFlag = 0;
my $last_authn_utime = $req->{sessionInfo}->{_lastAuthnUTime} || 0;
if (
time() - $last_authn_utime <
$self->conf->{portalForceAuthnInterval} )
{
$self->logger->debug(
"Authentication is recent, will set CAS renew flag to true"
);
$casRenewFlag = 1;
}
# Create a service ticket
$self->logger->debug(
"Create a CAS service ticket for service $service");
my $Sinfos;
$Sinfos->{type} = 'casService';
$Sinfos->{service} = $service;
$Sinfos->{renew} = $casRenewFlag;
$Sinfos->{_cas_id} = $session_id;
$Sinfos->{_utime} = $time;
$Sinfos->{_casApp} = $app;
my $casServiceSession = $self->getCasSession( undef, $Sinfos );
unless ($casServiceSession) {
$self->logger->error("Unable to create CAS session");
return PE_ERROR;
}
my $casServiceSessionID = $casServiceSession->id;
$casServiceTicket = "ST-" . $casServiceSessionID;
$self->logger->debug(
"CAS service session $casServiceSessionID created");
}
# Redirect to service
my $service_url = $service;
$service_url .= (
$service =~ /\?/
? '&ticket=' . $casServiceTicket
: '?ticket=' . $casServiceTicket
);
$self->logger->debug("Redirect user to $service_url");
$req->{urldc} = $service_url;
$req->steps( [] );
return PE_OK;
}
# 2. LOGOUT
if ( $target eq $cas_logout ) {
$self->logger->debug("URL $url detected as an CAS LOGOUT URL");
# Disable Content-Security-Policy header since logout can be embedded
# in a frame
$req->frame(1);
# GET parameters
my $logout_url = $req->param('url'); # CAS 2.0
my $logout_service = $req->param('service'); # CAS 3.0
# Delete linked CAS sessions
$self->deleteCasSecondarySessions($session_id);
# Delete local session
if ( my $session = $self->p->getApacheSession($session_id) ) {
unless ( $self->p->_deleteSession( $req, $session ) ) {
$self->logger->error("Fail to delete session $session_id ");
}
if ($logout_url) {
# Display a link to the provided URL
$self->logger->debug(
"Logout URL $logout_url will be displayed");
$req->info(
$self->loadTemplate(
'casBack2Url', params => { url => $logout_url }
)
);
$req->datas->{activeTimer} = 0;
return PE_CONFIRM;
}
if ($logout_service) {
$self->logger->debug(
"User will be redirected to $logout_service");
$req->{urldc} = $logout_service;
$req->steps( [] );
return PE_OK;
}
}
else {
$self->logger->info("Unknown session $session_id");
}
return PE_LOGOUT_OK;
}
# 3. VALIDATE [CAS 1.0]
if ( $target eq $cas_validate ) {
$self->logger->debug("URL $url detected as an CAS VALIDATE URL");
# This URL must not be called by authenticated users
$self->userLogger->info(
"CAS VALIDATE URL called by authenticated user, ignore it");
return PE_OK;
}
# 4. SERVICE VALIDATE [CAS 2.0]
if ( $target eq $cas_serviceValidate ) {
$self->logger->debug(
"URL $url detected as an CAS SERVICE VALIDATE URL");
# This URL must not be called by authenticated users
$self->userLogger->info(
"CAS SERVICE VALIDATE URL called by authenticated user, ignore it");
return PE_OK;
}
# 5. PROXY VALIDATE [CAS 2.0]
if ( $target eq $cas_proxyValidate ) {
$self->logger->debug("URL $url detected as an CAS PROXY VALIDATE URL");
# This URL must not be called by authenticated users
$self->userLogger->info(
"CAS PROXY VALIDATE URL called by authenticated user, ignore it");
return PE_OK;
}
# 6. PROXY [CAS 2.0]
if ( $target eq $cas_proxy ) {
$self->logger->debug("URL $url detected as an CAS PROXY URL");
# This URL must not be called by authenticated users
$self->userLogger->info(
"CAS PROXY URL called by authenticated user, ignore it");
return PE_OK;
}
return PE_OK;
}
sub logout {
my ( $self, $req ) = @_;
# Session ID
my $session_id = $req->{sessionInfo}->{_session_id} || $req->{id};
# Delete linked CAS sessions
$self->deleteCasSecondarySessions($session_id);
return PE_OK;
}
# Direct request from SP to IdP
sub validate {
my ( $self, $req ) = @_;
$self->logger->debug(
'URL ' . $req->uri . ' detected as an CAS VALIDATE URL' );
# GET parameters
my $service = $req->param('service');
my $ticket = $req->param('ticket');
my $renew = $req->param('renew');
# Required parameters: service and ticket
unless ( $service and $ticket ) {
$self->logger->error("Service and Ticket parameters required");
return $self->returnCasValidateError();
}
$self->logger->debug(
"Get validate request with ticket $ticket for service $service");
unless ( $ticket =~ s/^ST-// ) {
$self->logger->error("Provided ticket is not a service ticket (ST)");
return $self->returnCasValidateError();
}
my $casServiceSession = $self->getCasSession($ticket);
unless ($casServiceSession) {
$self->logger->error("Service ticket session $ticket not found");
return $self->returnCasValidateError();
}
$self->logger->debug("Service ticket session $ticket found");
my $service1_uri = URI->new($service);
my $service2_uri = URI->new( $casServiceSession->data->{service} );
# Check service
unless ( $service1_uri->eq($service2_uri) ) {
# Tolerate that relative URI are the same
if ( $service1_uri->rel($service2_uri) eq "./"
or $service2_uri->rel($service1_uri) eq "./" )
{
$self->logger->notice(
"Submitted service $service1_uri does not exactly match initial service "
. $service2_uri
. ' but difference is tolerated.' );
}
else {
$self->logger->error(
"Submitted service $service does not match initial service "
. $casServiceSession->data->{service} );
$self->deleteCasSession($casServiceSession);
return $self->returnCasValidateError();
}
}
else {
$self->logger->debug("Submitted service $service math initial servce");
}
# Check renew
if ( $renew and $renew eq 'true' ) {
# We should check the ST was delivered with primary credentials
$self->logger->debug("Renew flag detected ");
unless ( $casServiceSession->data->{renew} ) {
$self->logger->error(
"Authentication renew requested, but not done in former authentication process"
);
$self->deleteCasSession($casServiceSession);
return $self->returnCasValidateError();
}
}
# Open local session
my $localSession =
$self->p->getApacheSession( $casServiceSession->data->{_cas_id} );
unless ($localSession) {
$self->logger->warn( "Local session "
. $casServiceSession->data->{_cas_id}
. " notfound" );
$self->deleteCasSession($casServiceSession);
return $self->returnCasValidateError();
}
# Get username
my $username =
$localSession->data->{ $self->conf->{casAttr}
|| $self->conf->{whatToTrace} };
$self->logger->debug("Get username $username");
# Return success message
$self->deleteCasSession($casServiceSession);
return $self->returnCasValidateSuccess($username);
}
sub proxy {
my ( $self, $req ) = @_;
}
sub serviceValidate {
my ( $self, $req ) = @_;
return $self->_validate2( 'SERVICE', $req );
}
sub proxyValidate {
my ( $self, $req ) = @_;
return $self->_validate2( 'PROXY', $req );
}
# INTERNAL METHODS
sub _validate2 {
my ( $self, $urlType, $req ) = @_;
$self->logger->debug(
'URL ' . $req->uri . " detected as an CAS $urlType VALIDATE URL" );
# GET parameters
my $service = $req->param('service');
my $ticket = $req->param('ticket');
my $pgtUrl = $req->param('pgtUrl');
my $renew = $req->param('renew') // 'false';
# PGTIOU
my $casProxyGrantingTicketIOU;
# Required parameters: service and ticket
unless ( $service and $ticket ) {
$self->logger->error("Service and Ticket parameters required");
return $self->returnCasServiceValidateError( 'INVALID_REQUEST',
'Missing mandatory parameters (service, ticket)' );
}
$self->logger->debug( "Get "
. lc($urlType)
. " validate request with ticket $ticket for service $service" );
# Get CAS session corresponding to ticket
if ( $urlType eq 'SERVICE' and !( $ticket =~ s/^ST-// ) ) {
$self->logger->error("Provided ticket is not a service ticket (ST)");
return $self->returnCasServiceValidateError( 'INVALID_TICKET',
'Provided ticket is not a service ticket' );
}
elsif ( $urlType eq 'PROXY' and !( $ticket =~ s/^(P|S)T-// ) ) {
$self->userLogger->error(
"Provided ticket is not a service or proxy ticket ($1T)");
return $self->returnCasServiceValidateError( 'INVALID_TICKET',
'Provided ticket is not a service or proxy ticket' );
}
my $casServiceSession = $self->getCasSession($ticket);
unless ($casServiceSession) {
$self->logger->error("$urlType ticket session $ticket not found");
return $self->returnCasServiceValidateError( 'INVALID_TICKET',
'Ticket not found' );
}
my $app = $casServiceSession->data->{_casApp};
$self->logger->debug("$urlType ticket session $ticket found");
my $service1_uri = URI->new($service);
my $service2_uri = URI->new( $casServiceSession->data->{service} );
# Check service
unless ( $service1_uri->eq($service2_uri) ) {
# Tolerate that relative URI are the same
if ( $service1_uri->rel($service2_uri) eq "./"
or $service2_uri->rel($service1_uri) eq "./" )
{
$self->logger->notice(
"Submitted service $service1_uri does not exactly match initial service "
. $service2_uri
. ' but difference is tolerated.' );
}
else {
$self->userLogger->error(
"Submitted service $service does not match initial service "
. $casServiceSession->data->{service} );
$self->deleteCasSession($casServiceSession);
return $self->returnCasServiceValidateError( 'INVALID_SERVICE',
'Submitted service does not match initial service' );
}
}
else {
$self->logger->debug(
"Submitted service $service match initial service");
}
# Check renew
if ( $renew and $renew eq 'true' ) {
# We should check the ST was delivered with primary credentials
$self->logger->debug("Renew flag detected ");
unless ( $casServiceSession->data->{renew} ) {
$self->logger->error(
"Authentication renew requested, but not done in former authentication process"
);
$self->deleteCasSession($casServiceSession);
return $self->returnCasValidateError();
}
}
# Proxies (for PROXY VALIDATE only)
my $proxies = $casServiceSession->data->{proxies};
# Proxy granting ticket
if ($pgtUrl) {
# Create a proxy granting ticket
$self->logger->debug(
"Create a CAS proxy granting ticket for service $service");
my $PGinfos;
# PGT session
$PGinfos->{type} = 'casProxyGranting';
$PGinfos->{service} = $service;
$PGinfos->{_cas_id} = $casServiceSession->data->{_cas_id};
$PGinfos->{_utime} = $casServiceSession->data->{_utime};
$PGinfos->{_casApp} = $app;
# Trace proxies
$PGinfos->{proxies} = (
$proxies
? $proxies . $self->{multiValuesSeparator} . $pgtUrl
: $pgtUrl
);
my $casProxyGrantingSession = $self->getCasSession( undef, $PGinfos );
if ($casProxyGrantingSession) {
my $casProxyGrantingSessionID = $casProxyGrantingSession->id;
my $casProxyGrantingTicket = "PGT-" . $casProxyGrantingSessionID;
$self->logger->debug(
"CAS proxy granting session $casProxyGrantingSessionID created"
);
# Generate the proxy granting ticket IOU
my $tmpCasSession = $self->getCasSession();
if ($tmpCasSession) {
$casProxyGrantingTicketIOU = "PGTIOU-" . $tmpCasSession->id;
$self->deleteCasSession($tmpCasSession);
$self->logger->debug(
"Generate proxy granting ticket IOU $casProxyGrantingTicketIOU"
);
# Request pgtUrl
if (
$self->callPgtUrl(
$pgtUrl, $casProxyGrantingTicketIOU,
$casProxyGrantingTicket
)
)
{
$self->logger->debug(
"Proxy granting URL $pgtUrl called with success");
}
else {
$self->logger->error(
"Error calling proxy granting URL $pgtUrl");
$casProxyGrantingTicketIOU = undef;
}
}
}
else {
$self->logger->warn(
"Error in proxy granting ticket management, bypass it");
}
}
# Open local session
my $localSession =
$self->p->getApacheSession( $casServiceSession->data->{_cas_id} );
unless ($localSession) {
$self->userLogger->error( "Local session "
. $casServiceSession->data->{_cas_id}
. " notfound" );
$self->deleteCasSession($casServiceSession);
return $self->returnCasServiceValidateError( 'INTERNAL_ERROR',
'No session associated to ticket' );
}
# Get username
my $username =
$localSession->data->{ $self->conf->{casAttr}
|| $self->conf->{whatToTrace} };
$self->logger->debug("Get username $username");
# Get attributes [CAS 3.0]
my $attributes = {};
my $ev =
( $app and $self->conf->{casAppMetaDataExportedVars}->{$app} )
? $self->conf->{casAppMetaDataExportedVars}->{$app}
: {};
unless (%$ev) {
$ev = $self->conf->{casAttributes} || {};
}
foreach my $casAttribute ( keys %$ev ) {
my $localSessionValue = $localSession->data->{ $ev->{$casAttribute} };
$attributes->{$casAttribute} = $localSessionValue
if defined $localSessionValue;
}
# Return success message
$self->deleteCasSession($casServiceSession);
return $self->returnCasServiceValidateSuccess( $req, $username,
$casProxyGrantingTicketIOU, $proxies, $attributes );
}
# Store request parameters in %ENV
sub exportRequestParameters {
my ( $self, $req ) = @_;
foreach my $param (qw/service renew gateway/) {
if ( $req->param($param) ) {
$req->env->{ "llng_cas_" . $param } = $req->param($param);
}
}
return PE_OK;
}
1;