File Coverage

blib/lib/Authen/CAS/UserAgent.pm
Criterion Covered Total %
statement 43 45 95.5
branch n/a
condition n/a
subroutine 15 15 100.0
pod n/a
total 58 60 96.6


line stmt bran cond sub pod time code
1             package Authen::CAS::UserAgent;
2              
3             =head1 NAME
4              
5             Authen::CAS::UserAgent - CAS-aware LWP::UserAgent
6              
7             =head1 SYNOPSIS
8              
9             use Authen::CAS::UserAgent;
10              
11             my $ua = Authen::CAS::UserAgent->new(
12             'cas_opts' => {
13             'server' => 'https://cas.example.com/cas/',
14             'username' => 'user',
15             'password' => 'password',
16             'restful' => 1,
17             },
18             );
19             $ua->get('https://www.example.com/casProtectedResource');
20              
21             =head1 DESCRIPTION
22              
23             This module attempts to add transparent CAS authentication support to
24             LWP::UserAgent. It currently supports using proxy granting tickets, the RESTful
25             API, screen scraping the login screen, or a custom login callback when CAS
26             authentication is required.
27              
28             =cut
29              
30 2     2   77054 use strict;
  2         5  
  2         81  
31 2     2   2332 use utf8;
  2         22  
  2         11  
32 2     2   90 use base qw{LWP::UserAgent Exporter};
  2         4  
  2         2716  
33              
34             our $VERSION = '0.91';
35              
36 2     2   181049 use constant CASHANDLERNAME => __PACKAGE__ . '.Handler';
  2         5  
  2         709  
37 2     2   23 use constant XMLNS_CAS => 'http://www.yale.edu/tp/cas';
  2         4  
  2         89  
38              
39 2     2   10 use constant ERROR_PROXY_INVALIDRESPONSE => 1;
  2         4  
  2         78  
40 2     2   9 use constant ERROR_PROXY_INVALIDTICKET => 2;
  2         5  
  2         76  
41 2     2   11 use constant ERROR_PROXY_UNKNOWN => 3;
  2         4  
  2         185  
42              
43             our @EXPORT_OK = qw{
44             ERROR_PROXY_INVALIDRESPONSE
45             ERROR_PROXY_INVALIDTICKET
46             ERROR_PROXY_UNKNOWN
47             };
48             our %EXPORT_TAGS = (
49             ERRORS => [qw{
50             ERROR_PROXY_INVALIDRESPONSE
51             ERROR_PROXY_INVALIDTICKET
52             ERROR_PROXY_UNKNOWN
53             }],
54             );
55              
56 2     2   10 use HTTP::Request;
  2         4  
  2         51  
57 2     2   6697 use HTTP::Request::Common ();
  2         5757  
  2         53  
58 2     2   14 use HTTP::Status ();
  2         4  
  2         36  
59 2     2   14 use URI;
  2         4  
  2         69  
60 2     2   13 use URI::Escape qw{uri_escape};
  2         5  
  2         162  
61 2     2   2573 use URI::QueryParam;
  2         2080  
  2         97  
62 2     2   2835 use XML::LibXML;
  0            
  0            
63             use XML::LibXML::XPathContext;
64              
65             ##LWP handlers
66              
67             #cas login handler, detects a redirect to the cas login page, logs the user in and updates the initial redirect
68             my $casLoginHandler = sub {
69             my ($response, $ua, $h) = @_;
70              
71             #prevent potential recursion caused by attempting to log the user in
72             return if($h->{'running'} > 0);
73              
74             #check to see if this is a redirection to the login page
75             my $uri = URI->new_abs($response->header('Location'), $response->request->uri)->canonical;
76             my $loginUri = URI->new_abs('login', $h->{'casServer'})->canonical;
77             if(
78             $uri->scheme eq $loginUri->scheme &&
79             $uri->authority eq $loginUri->authority &&
80             $uri->path eq $loginUri->path
81             ) {
82             #short-circuit if a service isn't specified
83             my $service = URI->new(scalar $uri->query_param('service'));
84             return if($service eq '');
85              
86             #short-circuit if in strict mode and the service is different than the original uri
87             return if($h->{'strict'} && $response->request->uri ne $service);
88              
89             #get a ticket for the specified service
90             my $ticket = $ua->get_cas_ticket($service, $h);
91              
92             #short-circuit if a ticket wasn't found
93             return if(!defined $ticket);
94              
95             #update the Location header
96             $response->header('Location', $service . ($service =~ /\?/ ? '&' : '?') . 'ticket=' . uri_escape($ticket));
97              
98             #attach a local response_redirect handler that will issue the redirect if necessary
99             push(@{$response->{'handlers'}->{'response_redirect'}},
100             {
101             %$h,
102             'callback' => sub {
103             my ($response, $ua, $h) = @_;
104              
105             #delete this response_redirect handler from the response object
106             delete $response->{'handlers'}->{'response_redirect'};
107             delete $response->{'handlers'} unless(%{$response->{'handlers'}});
108              
109             #determine the new uri
110             my $uri = $response->request->uri;
111             my $newUri = URI->new_abs(scalar $response->header('Location'), $uri);
112              
113             #check to see if the target uri is the same as the original uri (ignoring the ticket)
114             my $targetUri = $newUri->clone;
115             if($targetUri =~ s/[\&\?]ticket=[^\&\?]*$//sog) {
116             if($targetUri eq $uri) {
117             #clone the original request, update the request uri, and return the new request
118             my $request = $response->request->clone;
119             $request->uri($newUri);
120             return $request
121             }
122             }
123              
124             return;
125             },
126             },
127             );
128             }
129              
130             return;
131             };
132              
133             # default heuristic for finding login parameters
134             my $defaultLoginParamsHeuristic = sub {
135             my ($service, $response, $ua, $h, @params) = @_;
136              
137             # find all input controls on the submit form
138             my $content = $response->decoded_content;
139             while($content =~ /(\)/igs) {
140             my $input = $1;
141             my $name = $input =~ /name=\"(.*?)\"/si ? $1 : undef;
142             my $value = $input =~ /value=\"(.*?)\"/si ? $1 : undef;
143              
144             # we only care about the lt, execution, and _eventId parameters
145             if($name eq 'lt' || $name eq 'execution' || $name eq '_eventId') {
146             push @params, $name, $value;
147             }
148             }
149              
150             # return the updated params
151             return @params;
152             };
153              
154             #default heuristic for detecting the service and ticket in the login response
155             my $defaultTicketHeuristic = sub {
156             my ($response, $service) = @_;
157              
158             #attempt using the Location header on a redirect response
159             if($response->is_redirect) {
160             my $uri = $response->header('Location');
161             if($uri =~ /[?&]ticket=([^&]*)$/) {
162             return $1;
163             }
164             }
165              
166             #check for a javascript window.location.href redirect
167             if($response->decoded_content =~ /window\.location\.href="[^"]*ticket=([^&"]*?)"/sg) {
168             return $1;
169             }
170              
171             return;
172             };
173              
174             #default callback to log the user into CAS and return a ticket for the specified service
175             my $defaultLoginCallback = sub {
176             my ($service, $ua, $h) = @_;
177              
178             # generate the params for this login request
179             my $loginUri = URI->new_abs('login', $h->{'casServer'});
180             my @params = (
181             'service' => $service,
182             'username' => $h->{'username'},
183             'password' => $h->{'password'},
184             );
185              
186             # find any additional required login params (i.e. lt, execution, and _eventId)
187             if(@{$h->{'config'}->{'param_heuristics'}}) {
188             # retrieve the login form that will be parsed by configured param_heuristics
189             my $formUri = $loginUri->clone();
190             $formUri->query_param('service', $service);
191             my $response = $ua->simple_request(HTTP::Request::Common::GET($formUri));
192              
193             # process all configured param heuristics
194             foreach (@{$h->{'config'}->{'param_heuristics'}}) {
195             # skip invalid heuristics
196             next if(ref($_) ne 'CODE');
197              
198             # process this heuristic
199             @params = $_->($service, $response, $ua, $h, @params);
200             }
201             }
202              
203             # issue the login request
204             my $response = $ua->simple_request(HTTP::Request::Common::POST($loginUri, \@params));
205              
206             #short-circuit if there is no response from CAS for some reason
207             return if(!$response);
208              
209             #process all the ticket heuristics until a ticket is found
210             foreach (@{$h->{'config'}->{'ticket_heuristics'}}) {
211             #skip invalid heuristics
212             next if(ref($_) ne 'CODE');
213              
214             #process the current heuristic
215             my $ticket = eval {$_->($response, $service)};
216              
217             #quit processing if a ticket is found
218             return $ticket if(defined $ticket);
219             }
220              
221             #return undefined if no ticket was found
222             return;
223             };
224              
225             # Login callback when the specified server is in proxy mode
226             my $proxyLoginCallback = sub {
227             my ($service, $ua, $h) = @_;
228              
229             #clear any previous error
230             delete $h->{'error'};
231              
232             #create the request uri
233             my $ptUri = URI->new_abs('proxy', $h->{'casServer'});
234             $ptUri->query_form(
235             'pgt' => $h->{'pgt'},
236             'targetService' => $service,
237             );
238              
239             # fetch proxy ticket and parse response xml
240             my $response = $ua->simple_request(HTTP::Request::Common::GET($ptUri));
241             my $doc = eval {XML::LibXML->new()->parse_string($response->decoded_content('charset' => 'none'))};
242             if($@ || !$doc) {
243             $h->{'error'} = ERROR_PROXY_INVALIDRESPONSE;
244             push @{$h->{'errors'}}, $h->{'error'};
245             return;
246             }
247              
248             # process the response to extract the proxy ticket or any errors
249             my $xpc = XML::LibXML::XPathContext->new();
250             $xpc->registerNs('cas', XMLNS_CAS);
251             if($xpc->exists('/cas:serviceResponse/cas:proxyFailure', $doc)) {
252             my $code = $xpc->findvalue('/cas:serviceResponse/cas:proxyFailure[position()=1]/@code', $doc);
253             if($code eq 'INVALID_TICKET') {
254             $h->{'error'} = ERROR_PROXY_INVALIDTICKET;
255             push @{$h->{'errors'}}, $h->{'error'};
256             }
257             else {
258             $h->{'error'} = ERROR_PROXY_UNKNOWN;
259             push @{$h->{'errors'}}, $h->{'error'};
260             }
261             }
262             elsif($xpc->exists('/cas:serviceResponse/cas:proxySuccess', $doc)) {
263             return $xpc->findvalue('/cas:serviceResponse/cas:proxySuccess[position()=1]/cas:proxyTicket[position()=1]', $doc);
264             }
265             else {
266             $h->{'error'} = ERROR_PROXY_INVALIDRESPONSE;
267             push @{$h->{'errors'}}, $h->{'error'};
268             }
269              
270             # default to no ticket being returned
271             return;
272             };
273              
274             #Login callback for CAS servers that implement the RESTful API
275             #TODO: cache the TGT
276             my $restLoginCallback = sub {
277             my ($service, $ua, $h) = @_;
278              
279             #retrieve the tgt
280             my $loginUri = URI->new_abs('v1/tickets', $h->{'casServer'});
281             my $tgtResponse = $ua->simple_request(HTTP::Request::Common::POST($loginUri, [
282             'username' => $h->{'username'},
283             'password' => $h->{'password'},
284             ]));
285             return if($tgtResponse->code != 201);
286             my $tgtUri = $tgtResponse->header('Location');
287              
288             #retrieve a ticket for the requested service
289             my $ticketResponse = $ua->simple_request(HTTP::Request::Common::POST($tgtUri, [
290             'service' => $service,
291             ]));
292             return if($ticketResponse->code != 200);
293             return $ticketResponse->decoded_content;
294             };
295              
296             ##Static Methods
297              
298             #return the default user agent for this class
299             sub _agent($) {
300             return
301             $_[0]->SUPER::_agent . ' ' .
302             'CAS-UserAgent/' . $VERSION;
303             }
304              
305             #Constructor
306             sub new($%) {
307             my $self = shift;
308             my (%opt) = @_;
309              
310             # remove any cas options before creating base object
311             my $cas_opts = delete $opt{'cas_opts'};
312              
313             #setup the base object
314             $self = $self->SUPER::new(%opt);
315              
316             #attach a cas login handler if options were specified
317             $self->attach_cas_handler(%$cas_opts) if(ref($cas_opts) eq 'HASH');
318              
319             #return this object
320             return $self;
321             }
322              
323             =head1 METHODS
324              
325             The following methods are available:
326              
327             =over 4
328              
329             =item $ua->attach_cas_handler( %options )
330              
331             This method attaches a CAS handler to the current C
332             object.
333              
334             The following options are supported:
335              
336             =over
337              
338             =item C => $url
339              
340             This option defines the base CAS URL to use for this handler. The base url is
341             used to detect redirects to CAS for authentication and to issue any requests to
342             CAS when authenticating.
343              
344             This option is required.
345              
346             =item C => $string
347              
348             This option defines the username to use for authenticating with the CAS server.
349              
350             This option is required unless using proxy mode.
351              
352             =item C => $string
353              
354             This option defines the password to use for authenticating with the CAS server.
355              
356             This option is required unless using proxy mode.
357              
358             =item C => $bool
359              
360             When this option is TRUE, C will use the RESTful API to
361             authenticate with the CAS server.
362              
363             This option defaults to FALSE.
364              
365             =item C => $bool
366              
367             When this option is TRUE, C using a proxy granting
368             ticket to authenticate with the CAS server.
369              
370             This option defaults to FALSE.
371              
372             =item C => $string
373              
374             This option specifies the proxy granting ticket to use when proxy mode is active.
375              
376             This option is required when using proxy mode.
377              
378             =item C => $bool
379              
380             When this option is TRUE, C will only allow
381             authentication for the URL of the request requiring authentication.
382              
383             This option defaults to FALSE.
384              
385             =item C => $cb
386              
387             This option can be used to specify a custom callback to use when authenticating
388             with CAS. The callback is called as follows: $cb->($service, $ua, $handler) and
389             is expected to return a $ticket for the specified service on successful
390             authentication.
391              
392             =back
393              
394             =back
395              
396             =cut
397              
398             #method that will attach the cas server login handler
399             # server => the base CAS server uri to add a login handler for
400             # username => the username to use to login to the specified CAS server
401             # password => the password to use to login to the specified CAS server
402             # pgt => the pgt for a proxy login handler
403             # proxy => a boolean indicating this handler is a proxy login handler
404             # restful => a boolean indicating if the CAS server supports the RESTful API
405             # callback => a login callback to use for logging into CAS, it should return a ticket for the specified service
406             # ticket_heuristics => an array of heuristic callbacks that are performed when searching for the service and ticket in a CAS response
407             # strict => only allow CAS login when the service is the same as the original url
408             sub attach_cas_handler($%) {
409             my $self = shift;
410             my (%opt) = @_;
411              
412             #short-circuit if required options aren't specified
413             return if(!exists $opt{'server'});
414             return if(!$opt{'proxy'} && !(exists $opt{'username'} && exists $opt{'password'}));
415             return if($opt{'proxy'} && !$opt{'pgt'});
416              
417             #sanitize options
418             $opt{'server'} = URI->new($opt{'server'} . ($opt{'server'} =~ /\/$/o ? '' : '/'))->canonical;
419             my $callback =
420             ref($opt{'callback'}) eq 'CODE' ? $opt{'callback'} :
421             $opt{'proxy'} ? $proxyLoginCallback :
422             $opt{'restful'} ? $restLoginCallback :
423             $defaultLoginCallback;
424              
425             # process any default config values for bundled callbacks/heuristics, we do this here
426             # instead of in the callbacks to make default values available to custom
427             # callbacks
428             $opt{'ticket_heuristics'} = [$opt{'ticket_heuristics'}] if(ref($opt{'ticket_heuristics'}) ne 'ARRAY');
429             push @{$opt{'ticket_heuristics'}}, $defaultTicketHeuristic;
430             @{$opt{'ticket_heuristics'}} = grep {ref($_) eq 'CODE'} @{$opt{'ticket_heuristics'}};
431              
432             $opt{'param_heuristics'} = [$opt{'param_heuristics'}] if(ref($opt{'param_heuristics'}) ne 'ARRAY');
433             push @{$opt{'param_heuristics'}}, $defaultLoginParamsHeuristic;
434             @{$opt{'param_heuristics'}} = grep {ref($_) eq 'CODE'} @{$opt{'param_heuristics'}};
435              
436             #remove any pre-existing login handler for the current CAS server
437             $self->remove_cas_handlers($opt{'server'});
438              
439             #attach a new CAS login handler
440             $self->set_my_handler('response_done', $casLoginHandler,
441             'owner' => CASHANDLERNAME,
442             'casServer' => $opt{'server'},
443             'strict' => $opt{'strict'},
444             'loginCb' => $callback,
445             'username' => $opt{'username'},
446             'password' => $opt{'password'},
447             'pgt' => $opt{'pgt'},
448             'config' => \%opt,
449             'errors' => [],
450             'running' => 0,
451             'm_code' => [
452             HTTP::Status::HTTP_MOVED_PERMANENTLY,
453             HTTP::Status::HTTP_FOUND,
454             HTTP::Status::HTTP_SEE_OTHER,
455             HTTP::Status::HTTP_TEMPORARY_REDIRECT,
456             ],
457             );
458              
459             return 1;
460             }
461              
462             sub get_cas_handlers($;$) {
463             my $self = shift;
464             my ($server) = @_;
465              
466             $server = URI->new($server . ($server =~ /\/$/o ? '' : '/'))->canonical if(defined $server);
467             return $self->get_my_handler('response_done',
468             'owner' => CASHANDLERNAME,
469             (defined $server ? ('casServer' => $server) : ()),
470             );
471             }
472              
473             # method that will retrieve a ticket for the specified service
474             sub get_cas_ticket($$;$) {
475             my $self = shift;
476             my ($service, $server) = @_;
477              
478             # resolve which handler to use
479             my $h;
480             if(ref($server) eq 'HASH' && defined $server->{'casServer'}) {
481             $h = $server;
482             }
483             else {
484             my @handlers = $self->get_cas_handlers($server);
485             die 'too many CAS servers found, try specifying a specific CAS server' if(@handlers > 1);
486             $h = $handlers[0];
487             }
488             die 'cannot find a CAS server to fetch the ST from' if(!$h);
489              
490             # get a ticket from the handler
491             $h->{'running'}++;
492             my $ticket = eval {$h->{'loginCb'}->($service, LWP::UserAgent->new('cookie_jar' => {}), $h)};
493             $h->{'running'}--;
494              
495             # return the found ticket
496             return $ticket;
497             }
498              
499             #method that will remove the cas login handlers for the specified cas servers or all if a specified server is not provided
500             sub remove_cas_handlers($@) {
501             my $self = shift;
502              
503             #remove cas login handlers for any specified cas servers
504             $self->remove_handler('response_done',
505             'owner' => CASHANDLERNAME,
506             'casServer' => $_,
507             ) foreach(map {URI->new($_ . ($_ =~ /\/$/o ? '' : '/'))->canonical} @_);
508              
509             #remove all cas login handlers if no servers were specified
510             $self->remove_handler('response_done',
511             'owner' => CASHANDLERNAME,
512             ) if(!@_);
513              
514             return;
515             }
516              
517             1;
518              
519             __END__