File Coverage

blib/lib/LibCAS/Client.pm
Criterion Covered Total %
statement 16 18 88.8
branch n/a
condition n/a
subroutine 6 6 100.0
pod n/a
total 22 24 91.6


line stmt bran cond sub pod time code
1             package LibCAS::Client;
2              
3             require 5.008_008;
4              
5 1     1   23851 use strict;
  1         3  
  1         45  
6 1     1   6 use warnings;
  1         2  
  1         42  
7              
8 1     1   854 use HTTP::Cookies;
  1         19084  
  1         34  
9 1     1   960 use LWP::UserAgent;
  1         49401  
  1         36  
10 1     1   13 use URI;
  1         3  
  1         20  
11 1     1   388 use XML::LibXML;
  0            
  0            
12              
13             use LibCAS::Client::Response::Error;
14             use LibCAS::Client::Response::Failure;
15             use LibCAS::Client::Response::AuthenSuccess;
16             use LibCAS::Client::Response::ProxySuccess;
17              
18             =head1 NAME
19              
20             LibCAS::Client - A perl module for authenticating and validating against Jasig's CAS server
21              
22             =head1 VERSION
23              
24             Version 0.01
25              
26             =cut
27              
28             our $VERSION = '0.01';
29              
30             =head1 SYNOPSIS
31              
32             LibCAS::Client provides an OO interface for generating URLs and validating tickets for
33             Jasig's Central Authentication Service (CAS).
34              
35             Using the module should hopefully be straight forward, something similar to:
36              
37             my $cas = LibCAS::Client->new(cas_url => 'https://my-cas-server/cas');
38             my $login_url = $cas->login_url(service => 'my_service_name');
39            
40             # Do a HTTP redirect to $login_url to have CAS prompt for credentials
41             # or to have the CAS server issue a service ticket.
42            
43             my $r = $cas->service_validate(service => 'my_service_name', ticket => 'ticket_from_login');
44            
45             if ($r->is_success()) {
46             # Do things for successful authentication
47             } elsif ($r->is_failure()) {
48             # Do things for failed authentication
49             } else {
50             # Anything that makes it here is an error
51             }
52              
53             =cut
54              
55             my $cas_url = "https://localhost/cas";
56             my $cas_login_path = "/login";
57             my $cas_logout_path = "/logout";
58             my $cas_validate_path = "/validate"; # CAS 1.0
59             my $cas_proxy_path = "/proxy"; # CAS 2.0
60             my $cas_serviceValidate_path = "/serviceValidate"; # CAS 2.0
61             my $cas_proxyValidate_path = "/proxyValidate"; # CAS 2.0
62              
63             =head1 METHODS
64              
65             =head2 new
66              
67             Create a new instance of the LibCAS::Client object. Valid parameters are:
68              
69             =over
70              
71             =item I - The base URL to the CAS server, defaults to C<< https://localhost/cas >>
72              
73             =item I - The path to the CAS login service, defaults to C<< /login >>
74              
75             =item I - The path to the CAS logout service, defaults to C<< /logout >>
76              
77             =item I - The path to the CAS v1.0 validation service, defaults to C<< /validate >>
78              
79             =item I - The path to the CAS proxy service, defaults to C<< /proxy >>
80              
81             =item I - The path to the CAS v2.0 service validation service, defaults to C<< /serviceValidate >>
82              
83             =item I - The path to the CAS v2.0 proxy validation service, defaults to C<< /proxyValidate >>
84              
85             =back
86              
87             =cut
88              
89             sub new {
90             my $this = shift;
91             my %args = @_;
92              
93             my $self = {
94             cas_url => $cas_url,
95             cas_login_path => $cas_login_path,
96             cas_logout_path => $cas_logout_path,
97             cas_validate_path => $cas_validate_path,
98             cas_proxy_path => $cas_proxy_path,
99             cas_serviceValidate_path => $cas_serviceValidate_path,
100             cas_proxyValidate_path => $cas_proxyValidate_path,
101             debug => 0
102             };
103            
104             map { $self->{$_} = $args{$_} } keys %args;
105              
106             my $ssl_opts = {
107             verify_hostname => 0,
108             SSL_ca_path => undef,
109             SSL_ca_file => undef,
110             SSL_use_cert => 0,
111             SSL_verify_mode => 0
112             };
113              
114             $self->{_ua} = LWP::UserAgent->new(
115             agent => "Authen-CAS/$VERSION",
116             ssl_opts => $ssl_opts,
117             cookie_jar => HTTP::Cookies->new()
118             );
119            
120             my $class = ref($this) || $this;
121            
122             bless($self,$class);
123             return $self;
124             }
125              
126             =head2 login_url
127              
128             Generate the login url needed for the CAS server, depending on the C<< cas_url >> and C<< cas_login_path >>
129             parameters passed during object construction.
130              
131             Valid parameters to the C<< login_url >> method are:
132              
133             =over
134              
135             =item I [optional] - The name of the service to authenticate for.
136              
137             =item I [optional] - Bypass any existing single sign-on session, and require the client to represent their credentials.
138              
139             =item I [optional] - Do not require the client to present credentials if a single sign-on has not been established.
140              
141             =back
142              
143             =cut
144              
145             sub login_url {
146             my $self = shift;
147             my %args = @_;
148            
149             my %query_string = ();
150            
151             my $cas_uri = URI->new($self->{cas_url}.$self->{cas_login_path});
152            
153             if ($args{'service'}) {
154             $query_string{'service'} = $args{'service'};
155             }
156            
157             if ($args{'renew'} && _is_true($args{'renew'})) {
158             $query_string{'renew'} = 'true';
159             }
160            
161             if ($args{'gateway'} && _is_true($args{'gateway'})) {
162             $query_string{'gateway'} = 'true';
163             }
164            
165             return _build_url($cas_uri, \%query_string);
166             }
167              
168             =head2 logout_url
169              
170             Generate the logout url needed for the CAS server, depending on the C<< cas_url >> and C<< cas_logout_path >>
171             parameters passed during object construction.
172              
173             B Calling this method will destroy the single sign-on session, which may affect the client's ability
174             to access other applications protected by this CAS server.
175              
176             Valid parameters to the C<< logout_url >> method are:
177              
178             =over
179              
180             =item I [optional] - A URL to be displayed on the logout page.
181              
182             =back
183              
184             =cut
185              
186             sub logout_url {
187             my $self = shift;
188             my %args = @_;
189            
190             my %query_string = ();
191            
192             my $cas_uri = URI->new($self->{cas_url}.$self->{cas_logout_path});
193            
194             if ($args{'url'}) {
195             $query_string{'url'} = $args{'url'};
196             }
197            
198             return _build_url($cas_uri, \%query_string);
199             }
200              
201             =head2 validate_url
202              
203             Generate the URL that performs CAS protocol version 1.0 service ticket validation.
204              
205             Valid parameters to the C<< validate_url >> method are:
206              
207             =over
208              
209             =item I [required] - The name of the service which the ticket was issued for.
210              
211             =item I [required] - The service ticket issued by the CAS server.
212              
213             =item I [optional] - If set, this option will only allow validation to pass if the ticket was
214             issued immediatly after the client presents their credentials. It will fail if the service ticket
215             that is presented was issued from a single sign-on session.
216              
217             =back
218              
219             =cut
220              
221             sub validate_url {
222             my $self = shift;
223            
224             my $cas_uri = URI->new($self->{cas_url}.$self->{cas_validate_path});
225              
226             my $query_string = _parse_validate_args(@_) || return;
227            
228             return _build_url($cas_uri, $query_string);
229             }
230              
231             =head2 service_validate_url
232              
233             Generate the URL that performs CAS protocol version 2.0 service ticket validation, and generate proxy-
234             granting tickets, if requested.
235              
236             Valid parameters to the C<< service_validate_url >> method are:
237              
238             =over
239              
240             =item I [required] - The name of the service which the ticket was issued for.
241              
242             =item I [required] - The service ticket issued by the CAS server.
243              
244             =item I [optional] - If set, this option will only allow validation to pass if the ticket was
245             issued immediatly after the client presents their credentials. It will fail if the service ticket
246             that is presented was issued from a single sign-on session.
247              
248             =item I [optional] - The URL of the proxy callback.
249              
250             =back
251              
252             =cut
253              
254             sub service_validate_url {
255             my $self = shift;
256            
257             my $cas_uri = URI->new($self->{cas_url}.$self->{cas_serviceValidate_path});
258            
259             my $query_string = _parse_validate20_args(@_) || return;
260            
261             return _build_url($cas_uri, $query_string);
262             }
263              
264             =head2 proxy_url
265              
266             Generate the URL to the CAS server for generating proxy tickets.
267              
268             Valid parameters to the C<< proxy_url >> method are:
269              
270             =over
271              
272             =item I [required] - The proxy granting ticket.
273              
274             =item I [required] - The service identifier for the back-end service.
275              
276             =back
277              
278             =cut
279              
280             sub proxy_url {
281             my $self = shift;
282             my %args = @_;
283            
284             my %query_string = ();
285            
286             my $cas_uri = URI->new($self->{cas_url}.$self->{cas_proxy_path});
287            
288             if (! $args{'pgt'} || ! $args{'targetService'}) {
289             $@ = "pgt and targetService parameters must be supplied";
290             return;
291             } else {
292             $query_string{'pgt'} = $args{'pgt'};
293             $query_string{'targetService'} = $args{'targetService'};
294             }
295            
296             return _build_url($cas_uri, \%query_string);
297             }
298              
299             =head2 proxy_validate_url
300              
301             This method performs the same functions as the C<< service_validate_url >> method, with the added
302             benefit of being able to validate proxy tickets as well.
303              
304             Valid parameters for C<< proxy_validate_url >> are the same as they are for C<< service_validate_url >>
305              
306             =cut
307              
308             sub proxy_validate_url {
309             my $self = shift;
310            
311             my $cas_uri = URI->new($self->{cas_url}.$self->{cas_proxyValidate_path});
312            
313             my $query_string = _parse_validate20_args(@_) || return;
314            
315             return _build_url($cas_uri, $query_string);
316             }
317              
318             sub authenticate {
319             my $self = shift;
320             my %args = @_;
321            
322             my $r;
323            
324             if (! $args{username} || ! $args{password}) {
325             $r = LibCAS::Client::Response::Error->new(error => "username and password parameters must be supplied");
326             } else {
327             my $query_string = $self->_get_hidden_form_params();
328            
329             $query_string->{username} = $args{username};
330             $query_string->{password} = $args{password};
331              
332             if ($args{service}) {
333             $query_string->{service} = $args{service};
334             }
335            
336             if ($args{'warn'} && _is_true($args{'warn'})) {
337             $query_string->{'warn'} = $args{'warn'};
338             }
339            
340             if (! $query_string->{'lt'}) {
341             $r = LibCAS::Client::Response::Error->new(error => $@);
342             } else {
343             my $response = $self->{_ua}->post($self->login_url(), Content => $query_string);
344            
345             if ($response->is_success()) {
346             $r = LibCAS::Client::Response::AuthenSuccess->new(user => $args{username});
347             } else {
348             $r = LibCAS::Client::Response::Error->new(error=>_create_http_error_message($response));
349             }
350             }
351             }
352            
353             return $r;
354             }
355              
356             =head2 validate
357              
358             Validate a service ticket using CAS protocol version 1.0. Supported arguments for this method are the
359             same as they are for the C<< validate_url >> method.
360              
361             Returns an LibCAS::Client::Response object to denote whether or not the validation was successful. Success,
362             failure, or error conditions can be checked by calling the C<< is_success() >>, C<< is_failure() >>, or
363             C<< is_error() >> methods on the returned object.
364              
365             =cut
366              
367             sub validate {
368             my $self = shift;
369             my $r;
370            
371             my $url = $self->validate_url(@_);
372            
373             if (! $url) {
374             $r = LibCAS::Client::Response::Error->new(error => "URL generation failed: ".$@);
375             }
376              
377             my $response = $self->do_http_request($url);
378            
379             if (! $response) {
380             $r = LibCAS::Client::Response::Error->new(error => $@);
381             }
382            
383             if ($response =~ /^no\n\n$/) {
384             $r = LibCAS::Client::Response::Failure->new(code => 'V1_VALIDATE_FAILURE', response => $response);
385             } elsif ($response =~ /^yes\n([^\n]+)\n$/){
386             $r = LibCAS::Client::Response::AuthenSuccess->(user => $1, response => $response);
387             } else {
388             $r = LibCAS::Client::Response::Error->new(error => "Invalid response from CAS", response => $response);
389             }
390            
391             return $r;
392             }
393              
394             =head2 service_validate
395              
396             Validate a service ticket using CAS protocol version 2.0. Supported arguments for this method are the
397             same as they are for the C<< service_validate_url >> method.
398              
399             Returns an LibCAS::Client::Response object to denote whether or not the validation was successful. Success,
400             failure, or error conditions can be checked by calling the C<< is_success() >>, C<< is_failure() >>, or
401             C<< is_error() >> methods on the returned object.
402              
403             =cut
404              
405             sub service_validate {
406             my $self = shift;
407             my $r;
408            
409             my $url = $self->service_validate_url(@_);
410            
411             if (! $url) {
412             $r = LibCAS::Client::Response::Error->new(error => "URL generation failed: ".$@);
413             } else {
414             $r = $self->_do_v2_validation_request($url);
415             }
416            
417             return $r;
418             }
419              
420             =head2 proxy
421              
422             Obtain a proxy ticket to services that have a proxy granting ticket, and will be using proxy
423             authentication to a back-end service. Supported arguments for this method are the
424             same as they are for the C<< service_validate_url >> method.
425              
426             Returns an LibCAS::Client::Response object to denote whether or not the validation was successful. Success,
427             failure, or error conditions can be checked by calling the C<< is_success() >>, C<< is_failure() >>, or
428             C<< is_error() >> methods on the returned object.
429              
430             =cut
431              
432             sub proxy {
433             my $self = shift;
434             my $r;
435            
436             my $url = $self->proxy_url(@_);
437            
438             if (! $url) {
439             $r = LibCAS::Client::Response::Error->new(error => "URL generation failed: ".$@);
440             } else {
441             my $response = $self->do_http_request($url);
442            
443             if (! $response) {
444             $r = LibCAS::Client::Response::Error->new(error => $@);
445             } else {
446             $r = _parse_v2_proxy_xml_response($response);
447             }
448             }
449            
450             return $r;
451             }
452              
453             =head2 proxy_validate
454              
455             Validate a service ticket, or a proxy ticket, using CAS protocol version 2.0. Supported arguments for this method are the
456             same as they are for the C<< proxy_validate_url >> method.
457              
458             Returns an LibCAS::Client::Response object to denote whether or not the validation was successful. Success,
459             failure, or error conditions can be checked by calling the C<< is_success() >>, C<< is_failure() >>, or
460             C<< is_error() >> methods on the returned object.
461              
462             =cut
463              
464             sub proxy_validate {
465             my $self = shift;
466             my $r;
467            
468             my $url = $self->proxy_validate_url(@_);
469            
470             if (! $url) {
471             $r = LibCAS::Client::Response::Error->new(error => "URL generation failed: ".$@);
472             } else {
473             $r = $self->_do_v2_validation_request($url);
474             }
475            
476             return $r;
477             }
478              
479             sub do_http_request {
480             my $self = shift;
481             my $url = shift;
482              
483             my $response = $self->{_ua}->get($url);
484              
485             if (! $response->is_success) {
486             $@ = _create_http_error_message($response);
487             return;
488             } else {
489             return $response->content;
490             }
491             }
492              
493             sub _get_hidden_form_params {
494             # There are a number of hidden form fields that are needed to successfully log in
495             # programatically.
496             my $self = shift;
497              
498             my $response = $self->{_ua}->get($self->login_url());
499             my $parser = XML::LibXML->new();
500            
501             my %params;
502            
503             eval {
504             if ($response->is_success()) {
505             my $doc = $parser->parse_html_string($response->content(), {recover => 1});
506             my @nodes = $doc->findnodes('//input[@type="hidden"]');
507            
508             %params = map { $_->getAttribute('name') => $_->getAttribute('value') } @nodes;
509            
510             if (! $params{'lt'}) {
511             die "Could not find login ticket";
512             }
513             } else {
514             die _create_http_error_message($response);
515             }
516             };
517            
518             if ($@) {
519             return;
520             } else {
521             return \%params;
522             }
523             }
524              
525             sub _parse_v2_proxy_xml_response {
526             my $xml = shift;
527             my ($r, $doc, $node);
528              
529             my $parser = XML::LibXML->new();
530              
531             eval {
532             my $doc = $parser->parse_string($xml);
533            
534             if ($node = $doc->find('/cas:serviceResponse/cas:proxySuccess')->get_node(1)) {
535             my $tkt = $node->find('./cas:proxyTicket')->get_node(1)->textContent;
536            
537             if ($tkt) {
538             $r = LibCAS::Client::Response::ProxySuccess->new(proxy_ticket => $tkt, response => $doc);
539             } else {
540             die "Invalid CAS Response, could not find proxyTicket information";
541             }
542             } elsif ($node = $doc->find('/cas:serviceResponse/cas:proxyFailure')->get_node(1)) {
543             if ($node->hasAttribute('code')) {
544             my $code = $node->getAttribute('code');
545             my $msg = $node->textContent;
546            
547             $msg =~ s/^\s+//;
548             $msg =~ s/\s+$//;
549            
550             $r = LibCAS::Client::Response::Failure->new(code => $code, message => $msg, response => $doc);
551             } else {
552             die "Invalid CAS Response, could not find proxy failure code attribute";
553             }
554             } else {
555             die "Invalid CAS Response"
556             }
557             };
558            
559             if ($@) {
560             $r = LibCAS::Client::Response::Error->new(error => $@, response => $doc);
561             }
562            
563             return $r;
564             }
565              
566             sub _parse_v2_validate_xml_response {
567             my $xml = shift;
568             my ($r, $doc, $node);
569              
570             my $parser = XML::LibXML->new();
571              
572             eval {
573             my $doc = $parser->parse_string($xml);
574            
575             if ($node = $doc->find('/cas:serviceResponse/cas:authenticationSuccess')->get_node(1)) {
576             my $user = $node->find('./cas:user')->get_node(1)->textContent;
577            
578             if ($user) {
579             my $pgt = $node->find('./cas:proxyGrantingTicket')->get_node(1);
580             $pgt = $pgt->textContent if $pgt;
581            
582             my $proxies = $node->findnodes('./cas:proxies/cas:proxy');
583             $proxies = [ map $_->textContent, @$proxies ] if $proxies;
584            
585             $r = LibCAS::Client::Response::AuthenSuccess->new(
586             user => $user,
587             pgt => $pgt,
588             proxies => $proxies,
589             response => $doc
590             );
591             } else {
592             die "Invalid CAS Response, could not find user information";
593             }
594             } elsif ($node = $doc->find('/cas:serviceResponse/cas:authenticationFailure')->get_node(1)) {
595             if ($node->hasAttribute('code')) {
596             my $code = $node->getAttribute('code');
597             my $msg = $node->textContent;
598            
599             $msg =~ s/^\s+//;
600             $msg =~ s/\s+$//;
601            
602             $r = LibCAS::Client::Response::Failure->new(code => $code, message => $msg, response => $doc);
603             } else {
604             die "Invalid CAS Response, could not find validation failure code attribute";
605             }
606             } else {
607             die "Invalid CAS Response"
608             }
609             };
610            
611             if ($@) {
612             $r = LibCAS::Client::Response::Error(error => $@, response => $doc);
613             }
614            
615             return $r;
616             }
617              
618             sub _do_v2_validation_request {
619             my $self = shift;
620             my $url = shift;
621             my $r;
622            
623             my $response = $self->do_http_request($url);
624            
625             if (! $response) {
626             $r = LibCAS::Client::Response::Error->new(error => $@);
627             } else {
628             $r = _parse_v2_validate_xml_response($response);
629             }
630            
631             return $r;
632             }
633              
634             sub _build_url {
635             my $uri = shift;
636             my $query_string = shift;
637            
638             if ($query_string) {
639             $uri->query_form($query_string);
640             }
641            
642             return $uri->canonical;
643             }
644              
645             sub _parse_validate20_args {
646             my %args = @_;
647              
648             my $query_string = _parse_validate_args(%args) || return;
649            
650             if ($args{'pgtUrl'}) {
651             $query_string->{'pgtUrl'} = $args{'pgtUrl'};
652             }
653            
654             return $query_string;
655             }
656              
657             sub _parse_validate_args {
658             my %args = @_;
659             my %query_string = ();
660              
661             if (! $args{'service'} || ! $args{'ticket'}) {
662             $@ = "service and ticket parameters must be specified";
663             return;
664             } else {
665             $query_string{'service'} = $args{'service'};
666             $query_string{'ticket'} = $args{'ticket'};
667             }
668            
669             if ($args{'renew'} && _is_true($args{'renew'})) {
670             $query_string{'renew'} = 'true';
671             }
672            
673             return \%query_string;
674             }
675              
676             sub _is_true {
677             my $arg = shift;
678             my $is_true = 0;
679            
680             if (defined $arg) {
681             if ($arg =~ /^\d+$/ && $arg > 0) {
682             $is_true = 1;
683             } else {
684             if ($arg =~ /^true$/i || $arg =~ /^t$/i ||
685             $arg =~ /^yes$/i || $arg =~ /^y$/i) {
686             $is_true = 1;
687             }
688             }
689             }
690            
691             return $is_true;
692             }
693              
694             sub _create_http_error_message {
695             my $response = shift;
696            
697             return "HTTP request failed: ".$response->code.": ".$response->message." -> ".$response->content;
698             }
699              
700             =head1 AUTHOR
701              
702             "Mike Morris", C<< <"michael.m.morris at gmail.com"> >>
703              
704             =head1 BUGS
705              
706             =head1 SUPPORT
707              
708             You can find documentation for this module with the perldoc command.
709              
710             perldoc LibCAS::Client
711              
712             You can also look for information at:
713              
714             =over 4
715              
716             =item * RT: CPAN's request tracker (report bugs here)
717              
718             L
719              
720             =item * AnnoCPAN: Annotated CPAN documentation
721              
722             L
723              
724             =item * CPAN Ratings
725              
726             L
727              
728             =item * Search CPAN
729              
730             L
731              
732             =back
733              
734             =head1 ACKNOWLEDGEMENTS
735              
736             This code is derived from L
737             and L, with the added ability to customize the paths for
738             the services on the CAS server, and use URI and XML parsing libs.
739              
740             Documentation for the CAS protocol can be found at L
741              
742             =head1 LICENSE AND COPYRIGHT
743              
744             Copyright 2012 "Michael Morris".
745              
746             This program is free software; you can redistribute it and/or modify it
747             under the terms of either: the GNU General Public License as published
748             by the Free Software Foundation; or the Artistic License.
749              
750             See http://dev.perl.org/licenses/ for more information.
751              
752             =cut
753              
754             1;