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             package App::Dochazka::REST::Auth;
34              
35 41     41   13897 use strict;
  41         128  
  41         1046  
36 41     41   198 use warnings;
  41         78  
  41         1039  
37              
38 41     41   193 use App::CELL qw( $CELL $log $meta $site );
  41         109  
  41         3709  
39 41     41   271 use App::Dochazka::REST;
  41         77  
  41         1362  
40 41     41   231 use App::Dochazka::REST::ConnBank qw( $dbix_conn conn_status );
  41         97  
  41         3131  
41 41     41   252 use App::Dochazka::REST::ACL qw( check_acl );
  41         84  
  41         1583  
42 41     41   229 use App::Dochazka::REST::LDAP qw( ldap_exists ldap_search ldap_auth );
  41         86  
  41         1783  
43 41     41   220 use App::Dochazka::REST::Model::Employee qw( autocreate_employee nick_exists );
  41         82  
  41         1600  
44 41     41   218 use Authen::Passphrase::SaltedDigest;
  41         86  
  41         828  
45 41     41   187 use Data::Dumper;
  41         86  
  41         1381  
46 41     41   231 use Params::Validate qw(:all);
  41         83  
  41         4976  
47 41     41   260 use Try::Tiny;
  41         81  
  41         1742  
48 41     41   233 use Web::Machine::Util qw( create_header );
  41         77  
  41         397  
49 41     41   12405 use Web::MREST::InitRouter qw( $resources );
  41         86  
  41         2810  
50              
51             # methods/attributes not defined in this module will be inherited from:
52 41     41   243 use parent 'Web::MREST::Entity';
  41         77  
  41         235  
53              
54              
55              
56              
57             =head1 NAME
58              
59             App::Dochazka::REST::Auth - HTTP request authentication and authorization
60              
61              
62              
63              
64             =head1 DESCRIPTION
65              
66             This package, which is between L<Web::MREST::Entity> and
67             L<Web::Dochazka::REST::Dispatch> in the chain of inheritance, provides the
68             'is_authorized' and 'forbidden' methods called by L<Web::Machine> on each
69             incoming HTTP request.
70              
71              
72              
73              
74             =head1 METHODS
75              
76              
77             =head2 is_authorized
78              
79             This overrides the L<Web::Machine> method of the same name.
80              
81             Authenticate the originator of the request, using HTTP Basic Authentication.
82             Upon successful authentication, check that the user (employee) exists in
83             the database (create if necessary) and retrieve her EID. Push the EID and
84             current privilege level onto the context. Get the user's L<DBIx::Connector>
85             object and push that onto the context, too.
86              
87             =cut
88              
89             sub is_authorized {
90 0     0 1   my ( $self, $auth_header ) = @_;
91 0           $log->debug( "Entering " . __PACKAGE__ . "::is_authorized" );
92            
93             # get database connection for this HTTP request
94 0           App::Dochazka::REST::ConnBank::init_singleton();
95              
96 0 0         if ( ! $meta->META_DOCHAZKA_UNIT_TESTING ) {
97 0 0         return 1 if $self->_validate_session;
98             }
99 0 0         if ( $auth_header ) {
100 0           $log->debug("is_authorized: auth header is $auth_header" );
101 0           my $username = $auth_header->username;
102 0           my $password = $auth_header->password;
103 0           my $auth_status = $self->_authenticate( $username, $password );
104 0 0         if ( $auth_status->ok ) {
105 0           my $emp = $auth_status->payload;
106 0           $self->push_onto_context( {
107             current => $emp->TO_JSON,
108             current_obj => $emp,
109             current_priv => $emp->priv( $dbix_conn ),
110             dbix_conn => $dbix_conn,
111             } );
112 0 0         $self->_init_session( $emp ) unless $meta->META_DOCHAZKA_UNIT_TESTING;
113 0           return 1;
114             } else {
115 0           $log->error(
116             "_authenticate returned non-OK status. The entire status object is " .
117             Dumper( $auth_status )
118             );
119             }
120             }
121 0           return create_header(
122             'WWWAuthenticate' => [
123             'Basic' => (
124             realm => $site->DOCHAZKA_BASIC_AUTH_REALM
125             )
126             ]
127             );
128             }
129              
130              
131             =head3 _init_session
132              
133             Initialize the session. Takes an employee object.
134              
135             =cut
136              
137             sub _init_session {
138 0     0     my $self = shift;
139 0           $log->debug( "Entering " . __PACKAGE__ . "::_init_session" );
140              
141 0           my ( $emp ) = validate_pos( @_, { type => HASHREF, can => 'eid' } );
142              
143 0           my $r = $self->request;
144 0           my $ip_addr = $r->{'env'}->{'REMOTE_ADDR'};
145 0           my $session = $r->{'env'}->{'psgix.session'};
146 0           my $eid = $emp->eid;
147              
148 0           $session->{'eid'} = $eid;
149 0           $session->{'ip_addr'} = $ip_addr;
150 0           $session->{'last_seen'} = time;
151              
152 0           $log->info( "Initialized new session, EID $eid" );
153              
154 0           return;
155             }
156              
157              
158             =head3 _validate_session
159              
160             Validate the session
161              
162             =cut
163              
164             sub _validate_session {
165 0     0     my ( $self ) = @_;
166 0           $log->debug( "Entering " . __PACKAGE__ . "::_validate_session" );
167              
168 0           my $r = $self->request;
169              
170 0           my $remote_addr = $r->{'env'}->{'REMOTE_ADDR'};
171              
172 0           my $session = $r->{'env'}->{'psgix.session'};
173 0           $log->debug( "Session is " . Dumper( $session ) );
174              
175 0 0         return 0 unless %$session;
176 0 0         return 0 unless _is_fresh( $session->{'last_seen'} );
177 0 0         return 0 unless $session->{'ip_addr'} eq $remote_addr;
178 0 0 0       return 0 unless exists( $session->{'eid'} ) and $session->{'eid'};
179              
180             $log->info( "Detected valid existing session" .
181             ", EID " . $session->{'eid'} .
182 0           ", last seen " . $session->{'last_seen'}
183             );
184              
185 0           $session->{'last_seen'} = time;
186              
187 0           my $emp = App::Dochazka::REST::Model::Employee->load_by_eid( $dbix_conn, $session->{'eid'} )->payload;
188 0 0         die "missing employee object in session management"
189             unless $emp->isa( "App::Dochazka::REST::Model::Employee" );
190 0           $self->push_onto_context( {
191             current => $emp->TO_JSON,
192             current_obj => $emp,
193             current_priv => $emp->priv( $dbix_conn ),
194             dbix_conn => $dbix_conn,
195             } );
196              
197 0           return 1;
198             }
199              
200              
201             =head3 _is_fresh
202              
203             Takes a single argument, which is assumed to be number of seconds since
204             epoch when the session was last seen. This is compared to "now" and if the
205             difference is greater than the DOCHAZKA_REST_SESSION_EXPIRATION_TIME site
206             parameter, the return value is false, otherwise true.
207              
208             =cut
209              
210             sub _is_fresh {
211 0     0     $log->debug( "Entering " . __PACKAGE__ . "::_is_fresh" );
212 0           my ( $last_seen ) = validate_pos( @_, { type => SCALAR } );
213 0 0         if ( time - $last_seen > $site->DOCHAZKA_REST_SESSION_EXPIRATION_TIME ) {
214 0           $log->error( "Session expired!" );
215 0           return 0;
216             }
217 0           return 1;
218             }
219              
220              
221             =head3 _authenticate
222              
223             Authenticate the nick associated with an incoming REST request. Takes a nick
224             and a password (i.e., a set of credentials). Returns a status object, which
225             will have level 'OK' on success (with employee object in the payload), 'NOT_OK'
226             on failure. In the latter case, there will be a declared status.
227              
228             =cut
229              
230             sub _authenticate {
231 0     0     my ( $self, $nick, $password ) = @_;
232 0           my ( $status, $emp );
233 0           $log->debug( "Entering " . __PACKAGE__ . "::_authenticate" );
234              
235             # empty credentials: fall back to demo/demo
236 0 0         if ( $nick ) {
237 0           $log->notice( "Login attempt from $nick" );
238             } else {
239 0           $log->notice( "Login attempt from (anonymous) -- defaulting to demo/demo" );
240 0           $nick = 'demo';
241 0           $password = 'demo';
242             }
243              
244 0           $log->debug( "\$site->DOCHAZKA_LDAP is " . $site->DOCHAZKA_LDAP );
245              
246             # check if LDAP is enabled and if the employee exists in LDAP
247 0 0 0       if ( ! $meta->META_DOCHAZKA_UNIT_TESTING and
      0        
248             $site->DOCHAZKA_LDAP and
249             ldap_exists( $nick )
250             ) {
251              
252 0           $log->info( "Detected authentication attempt from $nick, a known LDAP user" );
253             #$log->debug( "Password provided: $password" );
254              
255             # - authenticate by LDAP bind
256 0 0         if ( ldap_auth( $nick, $password ) ) {
257             # successful LDAP auth: if the employee doesn't already exist in
258             # the database, possibly autocreate
259 0           $status = autocreate_employee( $dbix_conn, $nick );
260 0 0         return $status unless $status->ok;
261             } else {
262 0           return $CELL->status_not_ok( 'DOCHAZKA_EMPLOYEE_AUTH' );
263             }
264              
265             # load the employee object
266 0           my $emp = App::Dochazka::REST::Model::Employee->load_by_nick( $dbix_conn, $nick )->payload;
267 0 0         die "missing employee object in _authenticate" unless ref($emp) eq "App::Dochazka::REST::Model::Employee";
268 0           return $CELL->status_ok( 'DOCHAZKA_EMPLOYEE_AUTH', payload => $emp );
269             }
270              
271             # if not, authenticate against the password stored in the employee object.
272             else {
273              
274 0           $log->notice( "Employee $nick not found in LDAP; reverting to internal auth" );
275              
276             # - check if this employee exists in database
277 0           my $emp = nick_exists( $dbix_conn, $nick );
278              
279 0 0 0       if ( ! defined( $emp ) or ! $emp->isa( 'App::Dochazka::REST::Model::Employee' ) ) {
280 0           $log->notice( "Rejecting login attempt from unknown user $nick" );
281 0           $self->mrest_declare_status( explanation => "Authentication failed for user $nick", permanent => 1 );
282 0           return $CELL->status_not_ok;
283             }
284              
285             # - the password might be empty
286 0 0         $password = '' unless defined( $password );
287 0           my $passhash = $emp->passhash;
288 0 0         $passhash = '' unless defined( $passhash );
289              
290             # - check password against passhash
291 0           my ( $ppr, $status );
292             try {
293 0     0     $ppr = Authen::Passphrase::SaltedDigest->new(
294             algorithm => "SHA-512",
295             salt_hex => $emp->salt,
296             hash_hex => $emp->passhash,
297             );
298             } catch {
299 0     0     $status = $CELL->status_err( 'DOCHAZKA_PASSPHRASE_EXCEPTION', args => [ $_ ] );
300 0           };
301              
302 0 0         if ( ref( $ppr ) ne 'Authen::Passphrase::SaltedDigest' ) {
303 0           $log->crit( "employee $nick has invalid passhash and/or salt" );
304 0           return $CELL->status_not_ok( 'DOCHAZKA_EMPLOYEE_AUTH' );
305             }
306 0 0         if ( $ppr->match( $password ) ) {
307 0           $log->notice( "Internal auth successful for employee $nick" );
308 0           return $CELL->status_ok( 'DOCHAZKA_EMPLOYEE_AUTH', payload => $emp );
309             } else {
310 0           $self->mrest_declare_status( explanation =>
311             "Internal auth failed for known employee $nick (mistyped password?)"
312             );
313 0           return $CELL->status_not_ok;
314             }
315             }
316             }
317              
318              
319             =head2 forbidden
320              
321             This overrides the L<Web::Machine> method of the same name.
322              
323             Authorization (ACL check) method.
324              
325             First, parse the path and look at the method to determine which controller
326             action the user is asking us to perform. Each controller action has an ACL
327             associated with it, from which we can determine whether employees of each of
328             the four different privilege levels are authorized to perform that action.
329              
330             Requests for non-existent resources will always pass the ACL check.
331              
332             =cut
333              
334             sub forbidden {
335 0     0 1   my ( $self ) = @_;
336 0           $log->debug( "Entering " . __PACKAGE__ . "::forbidden" );
337              
338 0           my $method = $self->context->{'method'};
339 0           my $resource_name = $self->context->{'resource_name'};
340              
341             # if there is no handler on the context, the URL is invalid so we
342             # just pass on the request
343 0 0         if ( not exists $self->context->{'handler'} ) {
344 0           $log->debug("forbidden: no handler on context, passing on this request");
345 0           return 0;
346             }
347              
348 0           my $resource_def = $resources->{$resource_name}->{$method};
349              
350             # now we get the ACL profile. There are three possibilities:
351             # 1. acl_profile property does not exist => fail
352             # 2. single ACL profile for the entire resource
353             # 3. separate ACL profiles for each HTTP method
354 0           my ( $acl_profile_prop, $acl_profile );
355             SKIP: {
356              
357             # check acl_profile property
358 0 0         if ( exists( $resource_def->{'acl_profile'} ) ) {
  0            
359 0           $acl_profile_prop = $resource_def->{'acl_profile'};
360             } else {
361 0           $log->notice( "Resource $resource_name has no acl_profile property; ACL check will fail" );
362 0           last SKIP;
363             }
364              
365             # got the property, process it
366 0 0         if ( ! ref( $acl_profile_prop ) ) {
    0          
367 0           $acl_profile = $acl_profile_prop;
368 0   0       $log->debug( "ACL profile for all methods is " . ( $acl_profile || "undefined" ) );
369             } elsif ( ref( $acl_profile_prop ) eq 'HASH' ) {
370 0           $acl_profile = $acl_profile_prop->{$method};
371 0   0       $log->debug( "ACL profile for $method requests is " . ( $acl_profile || "undefined" ) );
372             } else {
373             $self->mrest_declare_status( code => 500, explanation =>
374 0           "Cannot determine ACL profile of resource!!! Path is " . $self->context->{'path'},
375             permanent => 1 );
376 0           return 1;
377             }
378             }
379             # push ACL profile onto context
380 0           $self->push_onto_context( { 'acl_profile' => $acl_profile } );
381              
382             # determine privlevel of our user
383 0           my $acl_priv = $self->context->{'current_priv'};
384 0   0       $log->debug( "My ACL level is $acl_priv and the ACL profile of this resource is "
385             . ( $acl_profile || "undefined" ) );
386              
387             # compare the two
388 0           my $acl_check_passed = check_acl( profile => $acl_profile, privlevel => $acl_priv );
389 0 0         if ( $acl_check_passed ) {
390 0           $log->debug( "ACL check passed" );
391 0           $self->push_onto_context( { 'acl_priv' => $acl_priv } );
392 0           return 0;
393             }
394 0           $self->mrest_declare_status( explanation => 'DISPATCH_ACL_CHECK_FAILED',
395             args => [ $resource_name ] );
396 0           return 1;
397             }
398              
399             1;