File Coverage

blib/lib/Plack/Auth/SSO.pm
Criterion Covered Total %
statement 66 71 92.9
branch 8 16 50.0
condition 2 3 66.6
subroutine 22 23 95.6
pod 10 12 83.3
total 108 125 86.4


line stmt bran cond sub pod time code
1             package Plack::Auth::SSO;
2              
3 3     3   165342 use strict;
  3         7  
  3         77  
4 3     3   536 use utf8;
  3         42  
  3         17  
5 3     3   77 use Data::Util qw(:check);
  3         5  
  3         406  
6 3     3   434 use Moo::Role;
  3         14324  
  3         15  
7 3     3   2316 use Data::UUID;
  3         1609  
  3         147  
8 3     3   1235 use Log::Any qw();
  3         21291  
  3         3252  
9              
10             our $VERSION = "0.0137";
11              
12             has session_key => (
13                 is => "ro",
14                 isa => sub { is_string($_[0]) or die("session_key should be string"); },
15                 lazy => 1,
16                 default => sub { "auth_sso" },
17                 required => 1
18             );
19             has authorization_path => (
20                 is => "ro",
21                 isa => sub { is_string($_[0]) or die("authorization_path should be string"); },
22                 lazy => 1,
23                 default => sub { "/"; },
24                 required => 1
25             );
26             has error_path => (
27                 is => "lazy"
28             );
29             has id => (
30                 is => "ro",
31                 lazy => 1,
32                 builder => "_build_id"
33             );
34             has uri_base => (
35                 is => "ro",
36                 isa => sub { is_string($_[0]) or die("uri_base should be string"); },
37                 required => 1,
38                 default => sub { "http://localhost:5000"; }
39             );
40             has uuid => (
41                 is => "lazy",
42                 init_arg => undef
43             );
44             has log => (
45                 is => "lazy",
46                 init_arg => undef
47             );
48              
49             requires "to_app";
50              
51             sub _build_log {
52              
53 2     2   79     Log::Any->get_logger(
54                     category => ref($_[0])
55                 );
56              
57             }
58              
59             sub _build_uuid {
60              
61 1     1   408     Data::UUID->new();
62              
63             }
64              
65             sub _build_error_path {
66              
67 1     1   356     $_[0]->authorization_path;
68              
69             }
70              
71             sub redirect_to_authorization {
72              
73 1     1 0 2     my $self = $_[0];
74              
75 1         18     my $url = $self->uri_for( $self->authorization_path );
76              
77 1         14     $self->log()->info( "redirecting to authorization url $url" );
78              
79 1         44     [ 302, [ Location => $url ], [] ];
80              
81             }
82              
83             sub redirect_to_error {
84              
85 2     2 0 36     my $self = $_[0];
86              
87 2         32     my $url = $self->uri_for( $self->error_path );
88              
89 2         35     $self->log()->error( "redirecting to error url $url" );
90              
91 2         49     [ 302, [ Location => $url ], [] ];
92             }
93              
94             sub uri_for {
95 7     7 1 71     my ($self, $path) = @_;
96 7         53     $self->uri_base() . $path;
97             }
98              
99             sub _build_id {
100 1     1   28     ref($_[0]);
101             }
102              
103             #check if $env->{psgix.session} is stored Plack::Session->session
104             sub _check_plack_session {
105 13 50   13   34     defined($_[0]->session) or die("Plack::Auth::SSO requires a Plack::Session");
106             }
107              
108             sub get_auth_sso {
109 6     6 1 25886     my ($self, $session) = @_;
110 6         19     _check_plack_session($session);
111              
112 6         202     my $value = $session->get($self->session_key);
113              
114 6 50       161     $self->log()->debugf( "extracted auth_sso from session['" . $self->session_key() . "']: %s", $value )
115                     if $self->log()->is_debug();
116              
117 6         1960     $value;
118             }
119              
120             sub set_auth_sso {
121 2     2 1 345     my ($self, $session, $value) = @_;
122 2         8     _check_plack_session($session);
123              
124 2 50       37     $self->log()->debugf( "session['".$self->session_key."'] set to: %s", $value )
125                     if $self->log()->is_debug();
126              
127 2         55     $session->set($self->session_key, $value);
128             }
129              
130             sub get_auth_sso_error {
131 0     0 1 0     my ($self, $session) = @_;
132 0         0     _check_plack_session($session);
133              
134 0         0     my $value = $session->get($self->session_key()."_error");
135              
136 0 0       0     $self->log()->debugf( "extracted auth_sso_error from session['" . $self->session_key() . "_error']: %s", $value )
137                     if $self->log()->is_debug();
138              
139 0         0     $value;
140             }
141              
142             sub set_auth_sso_error {
143 2     2 1 41     my ($self, $session, $value) = @_;
144 2         7     _check_plack_session($session);
145              
146 2 50       51     $self->log()->errorf( "session['".$self->session_key."_error'] set to: %s", $value )
147                     if $self->log()->is_error();
148              
149 2         85     $session->set($self->session_key . "_error", $value);
150             }
151              
152             sub generate_csrf_token {
153 1     1 1 26     $_[0]->uuid()->create_b64();
154             }
155              
156             sub set_csrf_token {
157 1     1 1 3     my ($self, $session, $value) = @_;
158 1         4     _check_plack_session($session);
159              
160 1 50       25     $self->log()->debugf( "session['".$self->session_key."_csrf'] set to: %s", $value )
161                     if $self->log()->is_debug();
162              
163 1         27     $session->set($self->session_key . "_csrf" , $value);
164             }
165              
166             sub get_csrf_token {
167 2     2 1 38     my ($self, $session) = @_;
168 2         6     _check_plack_session($session);
169              
170 2         213     my $value = $session->get($self->session_key . "_csrf");
171              
172 2 50       100     $self->log()->debugf( "extracted csrf token from session['" . $self->session_key() . "_csrf']: %s", $value )
173                     if $self->log()->is_debug();
174              
175 2         29     $value;
176             }
177              
178             sub csrf_token_valid {
179 2     2 1 6     my ($self, $session,$value) = @_;
180 2         8     my $stored_token = $self->get_csrf_token($session);
181 2   66     22     my $valid = defined($value) && defined($stored_token) && $value eq $stored_token;
182              
183 2 100       31     $self->log()->debug( "csrf validation " . ($valid ? "ok" : "failed") );
184              
185 2         36     $valid;
186             }
187              
188             sub cleanup {
189              
190 3     3 1 8     my ( $self, $session ) = @_;
191              
192 3         55     $self->log()->debug( "removed session['" . $self->session_key() . "_error']" );
193 3         114     $self->log()->debug( "removed session['" . $self->session_key() . "_csrf']" );
194              
195 3         102     $session->remove( $self->session_key() . "_error" );
196 3         90     $session->remove( $self->session_key() . "_csrf" );
197              
198             }
199              
200             1;
201              
202             =pod
203            
204             =head1 NAME
205            
206             Plack::Auth::SSO - role for Single Sign On (SSO) authentication
207            
208             =begin markdown
209            
210             # STATUS
211            
212             [![Build Status](https://travis-ci.org/LibreCat/Plack-Auth-SSO.svg?branch=master)](https://travis-ci.org/LibreCat/Plack-Auth-SSO)
213             [![Coverage](https://coveralls.io/repos/LibreCat/Plack-Auth-SSO/badge.png?branch=master)](https://coveralls.io/r/LibreCat/Plack-Auth-SSO)
214             [![CPANTS kwalitee](http://cpants.cpanauthors.org/dist/Plack-Auth-SSO.png)](http://cpants.cpanauthors.org/dist/Plack-Auth-SSO)
215            
216             =end markdown
217            
218             =head1 IMPLEMENTATIONS
219            
220             * SSO for Central Authentication System (CAS): L<Plack::Auth::SSO::CAS>
221            
222             * SSO for ORCID: L<Plack::Auth::SSO::ORCID>
223            
224             * SSO for Shibboleth: L<Plack::Auth::SSO::Shibboleth>
225            
226             =head1 SYNOPSIS
227            
228             package MySSOAuth;
229            
230             use Moo;
231             use Data::Util qw(:check);
232            
233             with "Plack::Auth::SSO";
234            
235             sub to_app {
236            
237             my $self = shift;
238            
239             sub {
240            
241             my $env = shift;
242             my $request = Plack::Request->new($env);
243             my $session = Plack::Session->new($env);
244            
245             #did this app already authenticate you?
246             #implementation of Plack::Auth::SSO should write hash to session key,
247             #configured by "session_key"
248             my $auth_sso = $self->get_auth_sso($session);
249            
250             #already authenticated: what are you doing here?
251             if( is_hash_ref($auth_sso) ){
252            
253             return [ 302, [ Location => $self->uri_for($self->authorization_path) ], [] ];
254            
255             }
256            
257             #not authenticated: do your internal work
258             #..
259            
260             #authentication done in external application code, but here something went wrong..
261             unless ( $ok ) {
262            
263             #error is set in auth_sso_error..
264             $self->set_auth_sso_error(
265             $session,
266             {
267             package => __PACKAGE__,
268             package_id => $self->id,
269             type => "connection_failed",
270             content => ""
271             }
272             );
273            
274             #user is redirected to error_path
275             return [ 302, [ Location => $self->uri_for($self->error_path) ], [] ];
276            
277             }
278            
279             #everything ok: set auth_sso
280             $self->set_auth_sso(
281             $session,
282             {
283             package => __PACKAGE__,
284             package_id => $self->id,
285             response => {
286             content => "Long response from external SSO application",
287             content_type => "text/xml"
288             },
289             uid => "<uid>",
290             info => {
291             attr1 => "attr1",
292             attr2 => "attr2"
293             },
294             extra => {
295             field1 => "field1"
296             }
297             }
298             );
299            
300             #redirect to other application for authorization:
301             return [ 302, [ Location => $self->uri_for($self->authorization_path) ], [] ];
302            
303             };
304             }
305            
306             1;
307            
308            
309             #in your app.psgi
310            
311             builder {
312            
313             mount "/auth/myssoauth" => MySSOAuth->new(
314            
315             session_key => "auth_sso",
316             authorization_path => "/auth/myssoauth/callback",
317             uri_base => "http://localhost:5001",
318             error_path => "/auth/error"
319            
320             )->to_app;
321            
322             mount "/auth/myssoauth/callback" => sub {
323            
324             my $env = shift;
325             my $session = Plack::Session->new($env);
326             my $auth_sso = $session->get("auth_sso");
327            
328             #not authenticated yet
329             unless($auth_sso){
330            
331             return [ 403, ["Content-Type" => "text/html"], ["forbidden"] ];
332            
333             }
334            
335             #process auth_sso (white list, roles ..)
336            
337             [ 200, ["Content-Type" => "text/html"], ["logged in!"] ];
338            
339             };
340            
341             mount "/auth/error" => sub {
342            
343             my $env = shift;
344             my $session = Plack::Session->new($env);
345             my $auth_sso_error = $session->get("auth_sso_error");
346            
347             unless ( $auth_sso_error ) {
348            
349             return [ 302, [ Location => $self->uri_for( "/" ) ], [] ];
350            
351             }
352            
353             [ 200, [ "Content-Type" => "text/plain" ], [
354             $auth_sso_error->{content}
355             ]];
356            
357             };
358            
359             };
360            
361             =head1 DESCRIPTION
362            
363             This is a Moo::Role for all Single Sign On Authentication packages. It requires
364             C<to_app> method, that returns a valid Plack application
365            
366             An implementation is expected is to do all communication with the external
367             SSO application (e.g. CAS). When it succeeds, it should save the response
368             from the external service in the session, and redirect to the authorization
369             url (see below).
370            
371             The authorization route must pick up the response from the session,
372             and log the user in.
373            
374             This package requires you to use Plack Sessions.
375            
376             =head1 CONFIG
377            
378             =over 4
379            
380             =item session_key
381            
382             When authentication succeeds, the implementation saves the response
383             from the SSO application in this session key, together with extra information.
384            
385             The response should look like this:
386            
387             {
388             package => "<package-name>",
389             package_id => "<package-id>",
390             response => {
391             content => "Long response from external SSO application like CAS",
392             content_type => "<mime-type>"
393             },
394             uid => "<uid-in-external-app>",
395             info => {
396             attr1 => "attr1",
397             attr2 => "attr2"
398             },
399             extra => {
400             field1 => "field1"
401             }
402             }
403            
404             This is usefull for several reasons:
405            
406             * the authorization application can distinguish between authenticated and not authenticated users
407            
408             * it can pick up the saved response from the session
409            
410             * it can lookup a user in an internal database, matching on the provided "uid" from the external service.
411            
412             * the key "package" tells which package authenticated the user; so the application can do an appropriate lookup based on this information.
413            
414             * the key "package_id" defaults to the package name, but is configurable. This is usefull when you have several external services of the same type,
415             and your application wants to distinguish between them.
416            
417             * the original response is stored as text, along with the content type.
418            
419             * other attributes stored in the hash reference "info". It is up to the implementing package whether it should only used attributes as pushed during
420             the authentication step (like in CAS), or do an extra lookup.
421            
422             * "extra" should be used to store request information.
423             e.g. "ORCID" gives a "token".
424             e.g. "Shibboleth" supplies the "Shib-Identity-Provider".
425            
426             =item authorization_path
427            
428             (internal) path of the authorization route. This path will be prepended by "uri_base" to
429             create the full url.
430            
431             When authentication succeeds, this application should redirect you here
432            
433             =item error_path
434            
435             (internal) path of the error route. This path will be prepended by "uri_base" to
436             create the full url.
437            
438             When authentication fails, this application should redirect you here
439            
440             If not set, it has the same value as the authorizaton_path. In that case make sure that you also
441            
442             check for auth_sso_error in your authorization route.
443            
444             The implementor should expect this in the session key "auth_sso_error" ( "_error" is appended to the configured session_key ):
445            
446             {
447             package => "Plack::Auth::SSO::TYPE",
448             package_id => "Plack::Auth::SSO::TYPE",
449             type => "my-error-type",
450             content => "Something went terribly wrong!"
451             }
452            
453             Error types should be documented by the implementor.
454            
455             =item uri_for( path )
456            
457             method that prepends your path with "uri_base".
458            
459             =item id
460            
461             identifier of the authentication module. Defaults to the package name.
462             This is handy when using multiple SSO instances, and you need to known
463             exactly which package authenticated the user.
464            
465             This is stored in "auth_sso" as "package_id".
466            
467             =item uri_base
468            
469             base url of the Plack application
470            
471             =back
472            
473             =head1 METHODS
474            
475             =head2 log
476            
477             logger instance. Object instance of class L<Log::Any::Proxy> that logs messages
478             to a category that equals your current class name.
479            
480             E.g. configure your logging in log4perl.conf:
481            
482             log4perl.category.Plack::Auth::SSO::CAS=INFO,STDERR
483             log4perl.appender.STDERR=Log::Log4perl::Appender::Screen
484             log4perl.appender.STDERR.stderr=1
485             log4perl.appender.STDERR.utf8=1
486             log4perl.appender.STDERR.layout=PatternLayout
487             log4perl.appender.STDERR.layout.ConversionPattern=%d %p [%P] - %c[%L] : %m%n
488            
489             See L<Log::Any> for more information
490            
491             =head2 to_app
492            
493             returns a Plack application
494            
495             This must be implemented by subclasses
496            
497             =head2 get_auth_sso($plack_session)
498            
499             get saved SSO response from your session
500            
501             =head2 set_auth_sso($plack_session,$hash)
502            
503             save SSO response to your session
504            
505             $hash should be a hash ref, and look like this:
506            
507             {
508             package => __PACKAGE__,
509             package_id => __PACKAGE__ ,
510             response => {
511             content => "Long response from external SSO application like CAS",
512             content_type => "<mime-type>",
513             },
514             uid => "<uid>",
515             info => {},
516             extra => {}
517             }
518            
519             =head2 get_auth_sso_error($plack_session)
520            
521             get saved SSO error response from your session
522            
523             =head2 set_auth_sso_error($plack_session,$hash)
524            
525             save SSO error response to your session
526            
527             $hash should be a hash ref, and look like this:
528            
529             {
530             package => __PACKAGE__,
531             package_id => __PACKAGE__ ,
532             type => "my-type",
533             content => "my-content"
534             }
535            
536             =head2 generate_csrf_token()
537            
538             Generate unique CSRF token. Store this token in your session, and supply it as parameter
539             to the redirect uri.
540            
541             =head2 set_csrf_token($session,$token)
542            
543             Save csrf token to the session
544            
545             The token is saved in key session_key + "_csrf"
546            
547             =head2 get_csrf_token($session)
548            
549             Retrieve csrf token from the session
550            
551             =head2 csrf_token_valid($session,$token)
552            
553             Compare supplied token with stored token
554            
555             =head2 cleanup($session)
556            
557             removes additional session keys like auth_sso_error and auth_sso_csrf
558             before redirecting to the authorization path.
559            
560             =head1 EXAMPLES
561            
562             See examples/app1:
563            
564             #copy example config to required location
565             $ cp examples/catmandu.yml.example examples/catmandu.yml
566            
567             #edit config
568             $ vim examples/catmandu.yml
569            
570             #start plack application
571             plackup examples/app1.pl
572            
573             =head1 AUTHOR
574            
575             Nicolas Franck, C<< <nicolas.franck at ugent.be> >>
576            
577             =head1 LICENSE AND COPYRIGHT
578            
579             This program is free software; you can redistribute it and/or modify it
580             under the terms of either: the GNU General Public License as published
581             by the Free Software Foundation; or the Artistic License.
582            
583             See L<http://dev.perl.org/licenses/> for more information.
584            
585             =head1 SEE ALSO
586            
587             L<Plack::Auth::SSO::CAS>,
588             L<Plack::Auth::SSO::ORCID>
589             L<Plack::Auth::SSO::Shibboleth>
590             L<Log::Any>
591            
592             =cut
593