File Coverage

blib/lib/App/Dochazka/REST/Auth.pm
Criterion Covered Total %
statement 45 166 27.1
branch 0 52 0.0
condition 0 18 0.0
subroutine 15 23 65.2
pod 2 2 100.0
total 62 261 23.7


line stmt bran cond sub pod time code
1             # *************************************************************************
2             # Copyright (c) 2014-2015, SUSE LLC
3             #
4             # All rights reserved.
5             #
6             # Redistribution and use in source and binary forms, with or without
7             # modification, are permitted provided that the following conditions are met:
8             #
9             # 1. Redistributions of source code must retain the above copyright notice,
10             # this list of conditions and the following disclaimer.
11             #
12             # 2. Redistributions in binary form must reproduce the above copyright
13             # notice, this list of conditions and the following disclaimer in the
14             # documentation and/or other materials provided with the distribution.
15             #
16             # 3. Neither the name of SUSE LLC nor the names of its contributors may be
17             # used to endorse or promote products derived from this software without
18             # specific prior written permission.
19             #
20             # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21             # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22             # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
23             # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
24             # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
25             # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
26             # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
27             # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
28             # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
29             # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
30             # POSSIBILITY OF SUCH DAMAGE.
31             # *************************************************************************
32              
33              
34             use strict;
35 41     41   18679 use warnings;
  41         106  
  41         1071  
36 41     41   213  
  41         94  
  41         1116  
37             use App::CELL qw( $CELL $log $meta $site );
38 41     41   206 use App::Dochazka::REST;
  41         84  
  41         4042  
39 41     41   276 use App::Dochazka::REST::ConnBank qw( $dbix_conn conn_status );
  41         94  
  41         1407  
40 41     41   244 use App::Dochazka::REST::ACL qw( check_acl );
  41         80  
  41         3131  
41 41     41   290 use App::Dochazka::REST::LDAP qw( ldap_exists ldap_search ldap_auth );
  41         97  
  41         1972  
42 41     41   268 use App::Dochazka::REST::Model::Employee qw( autocreate_employee nick_exists );
  41         128  
  41         2335  
43 41     41   311 use Authen::Passphrase::SaltedDigest;
  41         110  
  41         1871  
44 41     41   270 use Data::Dumper;
  41         108  
  41         1049  
45 41     41   222 use Params::Validate qw(:all);
  41         90  
  41         1660  
46 41     41   236 use Try::Tiny;
  41         114  
  41         5268  
47 41     41   314 use Web::Machine::Util qw( create_header );
  41         80  
  41         2046  
48 41     41   272 use Web::MREST::InitRouter qw( $resources );
  41         117  
  41         438  
49 41     41   12469  
  41         107  
  41         3209  
50             # methods/attributes not defined in this module will be inherited from:
51             use parent 'Web::MREST::Entity';
52 41     41   283  
  41         93  
  41         304  
53              
54              
55              
56             =head1 NAME
57              
58             App::Dochazka::REST::Auth - HTTP request authentication and authorization
59              
60              
61              
62              
63             =head1 DESCRIPTION
64              
65             This package, which is between L<Web::MREST::Entity> and
66             L<Web::Dochazka::REST::Dispatch> in the chain of inheritance, provides the
67             'is_authorized' and 'forbidden' methods called by L<Web::Machine> on each
68             incoming HTTP request.
69              
70              
71              
72              
73             =head1 METHODS
74              
75              
76             =head2 is_authorized
77              
78             This overrides the L<Web::Machine> method of the same name.
79              
80             Authenticate the originator of the request, using HTTP Basic Authentication.
81             Upon successful authentication, check that the user (employee) exists in
82             the database (create if necessary) and retrieve her EID. Push the EID and
83             current privilege level onto the context. Get the user's L<DBIx::Connector>
84             object and push that onto the context, too.
85              
86             =cut
87              
88             my ( $self, $auth_header ) = @_;
89             $log->debug( "Entering " . __PACKAGE__ . "::is_authorized" );
90 0     0 1  
91 0           # get database connection for this HTTP request
92             App::Dochazka::REST::ConnBank::init_singleton();
93              
94 0           if ( ! $meta->META_DOCHAZKA_UNIT_TESTING ) {
95             return 1 if $self->_validate_session;
96 0 0         }
97 0 0         if ( $auth_header ) {
98             $log->debug("is_authorized: auth header is $auth_header" );
99 0 0         my $username = $auth_header->username;
100 0           my $password = $auth_header->password;
101 0           my $auth_status = $self->_authenticate( $username, $password );
102 0           if ( $auth_status->ok ) {
103 0           my $emp = $auth_status->payload;
104 0 0         $self->push_onto_context( {
105 0           current => $emp->TO_JSON,
106 0           current_obj => $emp,
107             current_priv => $emp->priv( $dbix_conn ),
108             dbix_conn => $dbix_conn,
109             } );
110             $self->_init_session( $emp ) unless $meta->META_DOCHAZKA_UNIT_TESTING;
111             return 1;
112 0 0         } else {
113 0           $log->error(
114             "_authenticate returned non-OK status. The entire status object is " .
115 0           Dumper( $auth_status )
116             );
117             }
118             }
119             return create_header(
120             'WWWAuthenticate' => [
121 0           'Basic' => (
122             realm => $site->DOCHAZKA_BASIC_AUTH_REALM
123             )
124             ]
125             );
126             }
127              
128              
129             =head3 _init_session
130              
131             Initialize the session. Takes an employee object.
132              
133             =cut
134              
135             my $self = shift;
136             $log->debug( "Entering " . __PACKAGE__ . "::_init_session" );
137              
138 0     0     my ( $emp ) = validate_pos( @_, { type => HASHREF, can => 'eid' } );
139 0            
140             my $r = $self->request;
141 0           my $ip_addr = $r->{'env'}->{'REMOTE_ADDR'};
142             my $session = $r->{'env'}->{'psgix.session'};
143 0           my $eid = $emp->eid;
144 0            
145 0           $session->{'eid'} = $eid;
146 0           $session->{'ip_addr'} = $ip_addr;
147             $session->{'last_seen'} = time;
148 0            
149 0           $log->info( "Initialized new session, EID $eid" );
150 0            
151             return;
152 0           }
153              
154 0            
155             =head3 _validate_session
156              
157             Validate the session
158              
159             =cut
160              
161             my ( $self ) = @_;
162             $log->debug( "Entering " . __PACKAGE__ . "::_validate_session" );
163              
164             my $r = $self->request;
165 0     0      
166 0           my $remote_addr = $r->{'env'}->{'REMOTE_ADDR'};
167              
168 0           my $session = $r->{'env'}->{'psgix.session'};
169             $log->debug( "Session is " . Dumper( $session ) );
170 0            
171             return 0 unless %$session;
172 0           return 0 unless _is_fresh( $session->{'last_seen'} );
173 0           return 0 unless $session->{'ip_addr'} eq $remote_addr;
174             return 0 unless exists( $session->{'eid'} ) and $session->{'eid'};
175 0 0          
176 0 0         $log->info( "Detected valid existing session" .
177 0 0         ", EID " . $session->{'eid'} .
178 0 0 0       ", last seen " . $session->{'last_seen'}
179             );
180              
181             $session->{'last_seen'} = time;
182 0            
183             my $emp = App::Dochazka::REST::Model::Employee->load_by_eid( $dbix_conn, $session->{'eid'} )->payload;
184             die "missing employee object in session management"
185 0           unless $emp->isa( "App::Dochazka::REST::Model::Employee" );
186             $self->push_onto_context( {
187 0           current => $emp->TO_JSON,
188 0 0         current_obj => $emp,
189             current_priv => $emp->priv( $dbix_conn ),
190 0           dbix_conn => $dbix_conn,
191             } );
192              
193             return 1;
194             }
195              
196              
197 0           =head3 _is_fresh
198              
199             Takes a single argument, which is assumed to be number of seconds since
200             epoch when the session was last seen. This is compared to "now" and if the
201             difference is greater than the DOCHAZKA_REST_SESSION_EXPIRATION_TIME site
202             parameter, the return value is false, otherwise true.
203              
204             =cut
205              
206             $log->debug( "Entering " . __PACKAGE__ . "::_is_fresh" );
207             my ( $last_seen ) = validate_pos( @_, { type => SCALAR } );
208             if ( time - $last_seen > $site->DOCHAZKA_REST_SESSION_EXPIRATION_TIME ) {
209             $log->error( "Session expired!" );
210             return 0;
211 0     0     }
212 0           return 1;
213 0 0         }
214 0            
215 0            
216             =head3 _authenticate
217 0            
218             Authenticate the nick associated with an incoming REST request. Takes a nick
219             and a password (i.e., a set of credentials). Returns a status object, which
220             will have level 'OK' on success (with employee object in the payload), 'NOT_OK'
221             on failure. In the latter case, there will be a declared status.
222              
223             =cut
224              
225             my ( $self, $nick, $password ) = @_;
226             my ( $status, $emp );
227             $log->debug( "Entering " . __PACKAGE__ . "::_authenticate" );
228              
229             # empty credentials: fall back to demo/demo
230             if ( $nick ) {
231 0     0     $log->notice( "Login attempt from $nick" );
232 0           } else {
233 0           $log->notice( "Login attempt from (anonymous) -- defaulting to demo/demo" );
234             $nick = 'demo';
235             $password = 'demo';
236 0 0         }
237 0            
238             $log->debug( "\$site->DOCHAZKA_LDAP is " . $site->DOCHAZKA_LDAP );
239 0            
240 0           # check if LDAP is enabled and if the employee exists in LDAP
241 0           if ( ! $meta->META_DOCHAZKA_UNIT_TESTING and
242             $site->DOCHAZKA_LDAP and
243             ldap_exists( $nick )
244 0           ) {
245              
246             $log->info( "Detected authentication attempt from $nick, a known LDAP user" );
247 0 0 0       #$log->debug( "Password provided: $password" );
      0        
248              
249             # - authenticate by LDAP bind
250             if ( ldap_auth( $nick, $password ) ) {
251             # successful LDAP auth: if the employee doesn't already exist in
252 0           # the database, possibly autocreate
253             $status = autocreate_employee( $dbix_conn, $nick );
254             return $status unless $status->ok;
255             } else {
256 0 0         return $CELL->status_not_ok( 'DOCHAZKA_EMPLOYEE_AUTH' );
257             }
258              
259 0           # load the employee object
260 0 0         my $emp = App::Dochazka::REST::Model::Employee->load_by_nick( $dbix_conn, $nick )->payload;
261             die "missing employee object in _authenticate" unless ref($emp) eq "App::Dochazka::REST::Model::Employee";
262 0           return $CELL->status_ok( 'DOCHAZKA_EMPLOYEE_AUTH', payload => $emp );
263             }
264              
265             # if not, authenticate against the password stored in the employee object.
266 0           else {
267 0 0          
268 0           $log->notice( "Employee $nick not found in LDAP; reverting to internal auth" );
269              
270             # - check if this employee exists in database
271             my $emp = nick_exists( $dbix_conn, $nick );
272              
273             if ( ! defined( $emp ) or ! $emp->isa( 'App::Dochazka::REST::Model::Employee' ) ) {
274 0           $log->notice( "Rejecting login attempt from unknown user $nick" );
275             $self->mrest_declare_status( explanation => "Authentication failed for user $nick", permanent => 1 );
276             return $CELL->status_not_ok;
277 0           }
278              
279 0 0 0       # - the password might be empty
280 0           $password = '' unless defined( $password );
281 0           my $passhash = $emp->passhash;
282 0           $passhash = '' unless defined( $passhash );
283              
284             # - check password against passhash
285             my ( $ppr, $status );
286 0 0         try {
287 0           $ppr = Authen::Passphrase::SaltedDigest->new(
288 0 0         algorithm => "SHA-512",
289             salt_hex => $emp->salt,
290             hash_hex => $emp->passhash,
291 0           );
292             } catch {
293 0     0     $status = $CELL->status_err( 'DOCHAZKA_PASSPHRASE_EXCEPTION', args => [ $_ ] );
294             };
295              
296             if ( ref( $ppr ) ne 'Authen::Passphrase::SaltedDigest' ) {
297             $log->crit( "employee $nick has invalid passhash and/or salt" );
298             return $CELL->status_not_ok( 'DOCHAZKA_EMPLOYEE_AUTH' );
299 0     0     }
300 0           if ( $ppr->match( $password ) ) {
301             $log->notice( "Internal auth successful for employee $nick" );
302 0 0         return $CELL->status_ok( 'DOCHAZKA_EMPLOYEE_AUTH', payload => $emp );
303 0           } else {
304 0           $self->mrest_declare_status( explanation =>
305             "Internal auth failed for known employee $nick (mistyped password?)"
306 0 0         );
307 0           return $CELL->status_not_ok;
308 0           }
309             }
310 0           }
311              
312              
313 0           =head2 forbidden
314              
315             This overrides the L<Web::Machine> method of the same name.
316              
317             Authorization (ACL check) method.
318              
319             First, parse the path and look at the method to determine which controller
320             action the user is asking us to perform. Each controller action has an ACL
321             associated with it, from which we can determine whether employees of each of
322             the four different privilege levels are authorized to perform that action.
323              
324             Requests for non-existent resources will always pass the ACL check.
325              
326             =cut
327              
328             my ( $self ) = @_;
329             $log->debug( "Entering " . __PACKAGE__ . "::forbidden" );
330              
331             my $method = $self->context->{'method'};
332             my $resource_name = $self->context->{'resource_name'};
333              
334             # if there is no handler on the context, the URL is invalid so we
335 0     0 1   # just pass on the request
336 0           if ( not exists $self->context->{'handler'} ) {
337             $log->debug("forbidden: no handler on context, passing on this request");
338 0           return 0;
339 0           }
340              
341             my $resource_def = $resources->{$resource_name}->{$method};
342              
343 0 0         # now we get the ACL profile. There are three possibilities:
344 0           # 1. acl_profile property does not exist => fail
345 0           # 2. single ACL profile for the entire resource
346             # 3. separate ACL profiles for each HTTP method
347             my ( $acl_profile_prop, $acl_profile );
348 0           SKIP: {
349              
350             # check acl_profile property
351             if ( exists( $resource_def->{'acl_profile'} ) ) {
352             $acl_profile_prop = $resource_def->{'acl_profile'};
353             } else {
354 0           $log->notice( "Resource $resource_name has no acl_profile property; ACL check will fail" );
355             last SKIP;
356             }
357              
358 0 0         # got the property, process it
  0            
359 0           if ( ! ref( $acl_profile_prop ) ) {
360             $acl_profile = $acl_profile_prop;
361 0           $log->debug( "ACL profile for all methods is " . ( $acl_profile || "undefined" ) );
362 0           } elsif ( ref( $acl_profile_prop ) eq 'HASH' ) {
363             $acl_profile = $acl_profile_prop->{$method};
364             $log->debug( "ACL profile for $method requests is " . ( $acl_profile || "undefined" ) );
365             } else {
366 0 0         $self->mrest_declare_status( code => 500, explanation =>
    0          
367 0           "Cannot determine ACL profile of resource!!! Path is " . $self->context->{'path'},
368 0   0       permanent => 1 );
369             return 1;
370 0           }
371 0   0       }
372             # push ACL profile onto context
373             $self->push_onto_context( { 'acl_profile' => $acl_profile } );
374 0            
375             # determine privlevel of our user
376 0           my $acl_priv = $self->context->{'current_priv'};
377             $log->debug( "My ACL level is $acl_priv and the ACL profile of this resource is "
378             . ( $acl_profile || "undefined" ) );
379              
380 0           # compare the two
381             my $acl_check_passed = check_acl( profile => $acl_profile, privlevel => $acl_priv );
382             if ( $acl_check_passed ) {
383 0           $log->debug( "ACL check passed" );
384 0   0       $self->push_onto_context( { 'acl_priv' => $acl_priv } );
385             return 0;
386             }
387             $self->mrest_declare_status( explanation => 'DISPATCH_ACL_CHECK_FAILED',
388 0           args => [ $resource_name ] );
389 0 0         return 1;
390 0           }
391 0            
392 0           1;