File Coverage

blib/lib/Plack/Auth/SSO/CAS.pm
Criterion Covered Total %
statement 78 101 77.2
branch 9 20 45.0
condition 3 3 100.0
subroutine 14 15 93.3
pod 0 2 0.0
total 104 141 73.7


line stmt bran cond sub pod time code
1             package Plack::Auth::SSO::CAS;
2              
3 1     1   216521 use strict;
  1         9  
  1         24  
4 1     1   473 use utf8;
  1         12  
  1         5  
5 1     1   28 use feature qw(:5.10);
  1         2  
  1         124  
6 1     1   5 use Data::Util qw(:check);
  1         1  
  1         118  
7 1     1   434 use Authen::CAS::Client;
  1         44894  
  1         31  
8 1     1   446 use Moo;
  1         8843  
  1         7  
9 1     1   1692 use Plack::Request;
  1         32511  
  1         60  
10 1     1   8 use Plack::Session;
  1         3  
  1         21  
11 1     1   415 use Plack::Auth::SSO::ResponseParser::CAS;
  1         3  
  1         29  
12 1     1   7 use XML::LibXML::XPathContext;
  1         1  
  1         20  
13 1     1   4 use XML::LibXML;
  1         11  
  1         6  
14 1     1   144 use URI;
  1         2  
  1         861  
15              
16             our $VERSION = "0.0137";
17              
18             with "Plack::Auth::SSO";
19              
20             has cas_url => (
21                 is => "ro",
22                 isa => sub { is_string($_[0]) or die("cas_url should be string"); },
23                 required => 1
24             );
25              
26             sub parse_failure {
27              
28 0     0 0 0     my ( $self, $obj ) = @_;
29              
30 0         0     my $xpath;
31              
32 0 0       0     if ( is_instance( $obj, "XML::LibXML" ) ) {
33              
34 0         0         $xpath = XML::LibXML::XPathContext->new( $obj );
35              
36                 }
37                 else {
38              
39 0         0         $xpath = XML::LibXML::XPathContext->new(
40                         XML::LibXML->load_xml( string => $obj )
41                     );
42              
43                 }
44              
45 0         0     $xpath->registerNs( "cas", "http://www.yale.edu/tp/cas" );
46              
47 0         0     my @nodes = $xpath->find( "/cas:serviceResponse/cas:authenticationFailure" )->get_nodelist();
48              
49 0 0       0     if ( @nodes ) {
50              
51                     return +{
52 0         0             type => $nodes[0]->findvalue( '@code' ),
53                         content => $nodes[0]->textContent(),
54                         package => __PACKAGE__,
55                         package_id => $self->id()
56                     };
57              
58                 }
59                 else {
60              
61 0         0         return undef;
62              
63                 }
64             }
65              
66             sub to_app {
67 1     1 0 3751     my $self = $_[0];
68                 sub {
69              
70 3     3   100396         state $response_parser = Plack::Auth::SSO::ResponseParser::CAS->new();
71 3         131         state $cas = Authen::CAS::Client->new($self->cas_url());
72              
73 3         653         my $env = $_[0];
74 3         81         my $log = $self->log();
75              
76 3         2258         my $request = Plack::Request->new($env);
77 3         60         my $session = Plack::Session->new($env);
78 3         56         my $params = $request->query_parameters();
79              
80 3 50       502         if( $log->is_debug() ){
81              
82 0         0             $log->debugf( "incoming query parameters: %s", [$params->flatten] );
83 0         0             $log->debugf( "session: %s", $session->dump() );
84 0         0             $log->debugf( "session_key for auth_sso: %s" . $self->session_key() );
85              
86                     }
87              
88 3         79         my $auth_sso = $self->get_auth_sso($session);
89              
90             #already got here before
91 3 50       12         if (is_hash_ref($auth_sso)) {
92              
93 0         0             $log->debug( "auth_sso already present" );
94              
95 0         0             return $self->redirect_to_authorization();
96              
97                     }
98              
99             #ticket?
100 3         20         my $ticket = $params->get("ticket");
101 3         20         my $state = $params->get("state");
102 3         25         my $request_uri = $request->request_uri();
103 3         20         my $idx = index( $request_uri, "?" );
104 3 100       9         if ( $idx >= 0 ) {
105              
106 2         8             $request_uri = substr( $request_uri, 0, $idx );
107              
108                     }
109 3         11         my $service = $self->uri_for($request_uri);
110 3         15         my $service_uri = URI->new( $service );
111              
112 3 100 100     228         if ( is_string($ticket) && $self->csrf_token_valid($session,$state) ) {
    100          
113              
114 1         4             $log->debug( "ticket present and csrf token valid: entering callback phase" );
115              
116 1         5             $service_uri->query_form( state => $state );
117 1         103             my $r = $cas->service_validate($service_uri->as_string(), $ticket);
118              
119 1         5997             $self->cleanup( $session );
120              
121 1 50       20             if ($r->is_success) {
    0          
122              
123 1         11                 $log->debug( "ticket validation successfull" );
124              
125 1         8                 my $doc = $r->doc();
126              
127                             $self->set_auth_sso(
128                                 $session,
129                                 {
130                                     %{
131 1         6                             $response_parser->parse( $doc )
  1         19  
132                                     },
133                                     package => __PACKAGE__,
134                                     package_id => $self->id,
135                                     response => {
136                                         content => $doc->toString(),
137                                         content_type => "text/xml"
138                                     }
139                                 }
140                             );
141              
142 1         23                 return $self->redirect_to_authorization();
143              
144                         }
145             #e.g. "Can't connect to localhost:8443 (certificate verify failed)"
146                         elsif( $r->is_error() ) {
147              
148 0         0                 $log->error( "ticket validation failed: unexpected error" );
149              
150 0         0                 $self->set_auth_sso_error( $session, {
151                                 package => __PACKAGE__,
152                                 package_id => $self->id,
153                                 type => "unknown",
154                                 content => $r->doc
155                             });
156 0         0                 return $self->redirect_to_error();
157              
158                         }
159             #$r->is_failure() -> authenticationFailure: return to authentication url
160                         else {
161              
162 0         0                 $log->error( "ticket validation failed: authentication failure" );
163              
164 0         0                 my $failure = $self->parse_failure( $r->doc );
165              
166 0 0       0                 if ( $failure->{type} ne "INVALID_TICKET" ) {
167              
168              
169 0         0                     $self->set_auth_sso_error( $session, $failure );
170 0         0                     return $self->redirect_to_error();
171              
172                             }
173              
174                         }
175              
176                     }
177                     elsif( is_string($ticket) ) {
178              
179 1         6             $self->cleanup( $session );
180              
181 1         52             $log->error( "ticket given, but csrf token not present" );
182              
183 1         25             $self->set_auth_sso_error( $session,{
184                             package => __PACKAGE__,
185                             package_id => $self->id,
186                             type => "CSRF_DETECTED",
187                             content => "CSRF_DETECTED"
188                         });
189 1         22             return $self->redirect_to_error();
190              
191                     }
192              
193 1         6         $self->cleanup( $session );
194              
195 1         26         $state = $self->generate_csrf_token();
196 1         8         $self->set_csrf_token(
197                         $session,
198                         $state
199                     );
200              
201 1         36         $service_uri->query_form( state => $state );
202              
203             #no ticket or ticket validation failed
204 1         142         my $login_url = $cas->login_url( $service_uri->as_string() )->as_string;
205              
206 1         508         $log->info( "redirecting to cas login $login_url" );
207              
208 1         28         [302, [Location => $login_url], []];
209              
210 1         46     };
211             }
212              
213             1;
214              
215             =pod
216            
217             =head1 NAME
218            
219             Plack::Auth::SSO::CAS - implementation of Plack::Auth::SSO for CAS
220            
221             =head1 SYNOPSIS
222            
223             #in your app.psgi
224            
225             builder {
226            
227             mount "/auth/cas" => Plack::Auth::SSO::CAS->new(
228            
229             session_key => "auth_sso",
230             uri_base => "http://localhost:5000",
231             authorization_path => "/auth/cas/callback",
232             error_path => "/auth/error"
233            
234             )->to_app;
235            
236             mount "/auth/cas/callback" => sub {
237            
238             my $env = shift;
239             my $session = Plack::Session->new($env);
240             my $auth_sso = $session->get("auth_sso");
241            
242             #not authenticated yet
243             unless($auth_sso){
244            
245             return [403,["Content-Type" => "text/html"],["forbidden"]];
246            
247             }
248            
249             #process auth_sso (white list, roles ..)
250            
251             [200,["Content-Type" => "text/html"],["logged in!"]];
252            
253             };
254            
255             mount "/auth/error" => sub {
256            
257             my $env = shift;
258             my $session = Plack::Session->new($env);
259             my $auth_sso_error = $session->get("auth_sso_error");
260            
261             unless ( $auth_sso_error ) {
262            
263             return [ 302, [ Location => $self->uri_for( "/" ) ], [] ];
264            
265             }
266            
267             [ 200, [ "Content-Type" => "text/plain" ], [
268             "Something went wrong. User could not be authenticated against CAS\n",
269             "Please report this error:\n",
270             $auth_sso_error->{content}
271             ]];
272            
273             };
274            
275             };
276            
277            
278             =head1 DESCRIPTION
279            
280             This is an implementation of L<Plack::Auth::SSO> to authenticate against a CAS server.
281            
282             It inherits all configuration options from its parent.
283            
284             =head1 CONFIG
285            
286             =over 4
287            
288             =item cas_url
289            
290             base url of the CAS service
291            
292             =back
293            
294             =head1 LOGGING
295            
296             All subclasses of L<Plack::Auth::SSO> use L<Log::Any>
297             to log messages to the category that equals the current
298             package name.
299            
300             =head1 ERRORS
301            
302             Cf. L<https://apereo.github.io/cas/4.2.x/protocol/CAS-Protocol-Specification.html#253-error-codes>
303            
304             When a ticket arrives, it is checked against the CAS Server. This can lead to the following situations:
305            
306             * an error occurs. This means that the CAS server is down, or returned an unexpected response. The error type is "unknown":
307            
308             {
309             package => "Plack::Auth::SSO::CAS",
310             package_id => "Plack::Auth::SSO::CAS",
311             type => "unknown",
312             content => "server could not complete request"
313             }
314            
315            
316             * the ticket is rejected by the CAS server. When the authentication code is "TICKET_INVALID" the user is redirected back
317             to the CAS server. In other cases the type equals the authentication code, and content equals the error description.
318            
319             {
320             package => "Plack::Auth::SSO::CAS",
321             package_id => "Plack::Auth::SSO::CAS",
322             type => "INVALID_SERVICE",
323             content => "invalid service"
324             }
325            
326            
327             =head1 TODO
328            
329             * add an option to ignore validation of the SSL certificate of the CAS Service? For now you should set the environment like this:
330            
331             export SSL_VERIFY_NONE=1
332             export PERL_LWP_SSL_VERIFY_HOSTNAME=0
333            
334             =head1 AUTHOR
335            
336             Nicolas Franck, C<< <nicolas.franck at ugent.be> >>
337            
338             =head1 SEE ALSO
339            
340             L<Plack::Auth::SSO>
341            
342             =cut
343