package Plack::Auth::SSO::CAS; use strict; use utf8; use feature qw(:5.10); use Data::Util qw(:check); use Authen::CAS::Client; use Moo; use Plack::Request; use Plack::Session; use Plack::Auth::SSO::ResponseParser::CAS; use XML::LibXML::XPathContext; use XML::LibXML; our $VERSION = "0.0133"; with "Plack::Auth::SSO"; has cas_url => ( is => "ro", isa => sub { is_string($_[0]) or die("cas_url should be string"); }, required => 1 ); sub parse_failure { my ( $self, $obj ) = @_; my $xpath; if ( is_instance( $obj, "XML::LibXML" ) ) { $xpath = XML::LibXML::XPathContext->new( $obj ); } else { $xpath = XML::LibXML::XPathContext->new( XML::LibXML->load_xml( string => $obj ) ); } $xpath->registerNs( "cas", "http://www.yale.edu/tp/cas" ); my @nodes = $xpath->find( "/cas:serviceResponse/cas:authenticationFailure" )->get_nodelist(); if ( @nodes ) { return +{ type => $nodes[0]->findvalue( '@code' ), content => $nodes[0]->textContent(), package => __PACKAGE__, package_id => $self->id() }; } else { return undef; } } sub to_app { my $self = $_[0]; sub { state $response_parser = Plack::Auth::SSO::ResponseParser::CAS->new(); state $cas = Authen::CAS::Client->new($self->cas_url()); my $env = $_[0]; my $request = Plack::Request->new($env); my $session = Plack::Session->new($env); my $params = $request->query_parameters(); my $auth_sso = $self->get_auth_sso($session); #already got here before if (is_hash_ref($auth_sso)) { return $self->redirect_to_authorization(); } #ticket? my $ticket = $params->get("ticket"); my $request_uri = $request->request_uri(); my $idx = index( $request_uri, "?" ); if ( $idx >= 0 ) { $request_uri = substr( $request_uri, 0, $idx ); } my $service = $self->uri_for($request_uri); if (is_string($ticket)) { my $r = $cas->service_validate($service, $ticket); if ($r->is_success) { my $doc = $r->doc(); $self->set_auth_sso( $session, { %{ $response_parser->parse( $doc ) }, package => __PACKAGE__, package_id => $self->id, response => { content => $doc->toString(), content_type => "text/xml" } } ); return $self->redirect_to_authorization(); } #e.g. "Can't connect to localhost:8443 (certificate verify failed)" elsif( $r->is_error() ) { $self->set_auth_sso_error( $session, { package => __PACKAGE__, package_id => $self->id, type => "unknown", content => $r->doc }); return $self->redirect_to_error(); } #$r->is_failure() -> authenticationFailure: return to authentication url else { my $failure = $self->parse_failure( $r->doc ); if ( $failure->{type} ne "INVALID_TICKET" ) { $self->set_auth_sso_error( $session, $failure ); return $self->redirect_to_error(); } } } #no ticket or ticket validation failed my $login_url = $cas->login_url($service)->as_string; [302, [Location => $login_url], []]; }; } 1; =pod =head1 NAME Plack::Auth::SSO::CAS - implementation of Plack::Auth::SSO for CAS =head1 SYNOPSIS #in your app.psgi builder { mount "/auth/cas" => Plack::Auth::SSO::CAS->new( session_key => "auth_sso", uri_base => "http://localhost:5000", authorization_path => "/auth/cas/callback", error_path => "/auth/error" )->to_app; mount "/auth/cas/callback" => sub { my $env = shift; my $session = Plack::Session->new($env); my $auth_sso = $session->get("auth_sso"); #not authenticated yet unless($auth_sso){ return [403,["Content-Type" => "text/html"],["forbidden"]]; } #process auth_sso (white list, roles ..) [200,["Content-Type" => "text/html"],["logged in!"]]; }; mount "/auth/error" => sub { my $env = shift; my $session = Plack::Session->new($env); my $auth_sso_error = $session->get("auth_sso_error"); unless ( $auth_sso_error ) { return [ 302, [ Location => $self->uri_for( "/" ) ], [] ]; } [ 200, [ "Content-Type" => "text/plain" ], [ "Something went wrong. User could not be authenticated against CAS\n", "Please report this error:\n", $auth_sso_error->{content} ]]; }; }; =head1 DESCRIPTION This is an implementation of L<Plack::Auth::SSO> to authenticate against a CAS server. It inherits all configuration options from its parent. =head1 CONFIG =over 4 =item cas_url base url of the CAS service =back =head1 ERRORS Cf. L<https://apereo.github.io/cas/4.2.x/protocol/CAS-Protocol-Specification.html#253-error-codes> When a ticket arrives, it is checked against the CAS Server. This can lead to the following situations: * an error occurs. This means that the CAS server is down, or returned an unexpected response. The error type is "unknown": { package => "Plack::Auth::SSO::CAS", package_id => "Plack::Auth::SSO::CAS", type => "unknown", content => "server could not complete request" } * the ticket is rejected by the CAS server. When the authentication code is "TICKET_INVALID" the user is redirected back to the CAS server. In other cases the type equals the authentication code, and content equals the error description. { package => "Plack::Auth::SSO::CAS", package_id => "Plack::Auth::SSO::CAS", type => "INVALID_SERVICE", content => "invalid service" } =head1 TODO * add an option to ignore validation of the SSL certificate of the CAS Service? For now you should set the environment like this: export SSL_VERIFY_NONE=1 export PERL_LWP_SSL_VERIFY_HOSTNAME=0 =head1 AUTHOR Nicolas Franck, C<< <nicolas.franck at ugent.be> >> =head1 SEE ALSO L<Plack::Auth::SSO> =cut