Project

General

Profile

Patch #3712 » RedmineAdvanced.pm

Arnaud Martel, 2009-08-05 18:12

 
1
package Apache::Authn::RedmineAdvanced;
2

    
3
=head1 Apache::Authn::RedmineAdvanced
4

    
5
Redmine - a mod_perl module to authenticate webdav subversion users
6
against redmine database
7

    
8
=head1 SYNOPSIS
9

    
10
This module allow anonymous users to browse public project and
11
registred users to browse and commit their project. Authentication is
12
done against the redmine database or the LDAP configured in redmine.
13

    
14
This method is far simpler than the one with pam_* and works with all
15
database without an hassle but you need to have apache/mod_perl on the
16
svn server.
17

    
18
=head1 INSTALLATION
19

    
20
For this to automagically work, you need to have a recent reposman.rb
21
(after r860) and if you already use reposman, read the last section to
22
migrate.
23

    
24
Sorry ruby users but you need some perl modules, at least mod_perl2,
25
DBI and DBD::mysql (or the DBD driver for you database as it should
26
work on allmost all databases).
27

    
28
On debian/ubuntu you must do :
29

    
30
  aptitude install libapache-dbi-perl libapache2-mod-perl2 libdbd-mysql-perl
31

    
32
If your Redmine users use LDAP authentication, you will also need
33
Authen::Simple::LDAP (and IO::Socket::SSL if LDAPS is used):
34

    
35
  aptitude install libauthen-simple-ldap-perl libio-socket-ssl-perl
36

    
37
=head1 CONFIGURATION
38

    
39
   ## This module has to be in your perl path
40
   ## eg:  /usr/lib/perl5/Apache/Authn/RedmineAdvanced.pm
41
   PerlLoadModule Apache::Authn::RedmineAdvanced
42
   <Location /svn>
43
     DAV svn
44
     SVNParentPath "/var/svn"
45

    
46
     AuthType Basic
47
     AuthName redmine
48
     Require valid-user
49

    
50
     PerlAccessHandler Apache::Authn::RedmineAdvanced::access_handler
51
     PerlAuthenHandler Apache::Authn::RedmineAdvanced::authen_handler
52
  
53
     ## for mysql
54
     RedmineDSN "DBI:mysql:database=databasename;host=my.db.server"
55
     ## for postgres
56
     # RedmineDSN "DBI:Pg:dbname=databasename;host=my.db.server"
57

    
58
     RedmineDbUser "redmine"
59
     RedmineDbPass "password"
60
     ## Optional where clause (fulltext search would be slow and
61
     ## database dependant).
62
     # RedmineDbWhereClause "and members.role_id IN (1,2)"
63
     ## Optional credentials cache size
64
     # RedmineCacheCredsMax 50
65
     ## Optional RedmineAuthenticationOnly (value doesn't matter, only presence is checked)
66
     # RedmineAuthenticationOnly on
67
     ## Optional ProjectIdentifier to bind access rights on a specific project
68
     # RedmineProjectId myproject
69
     ## Optional Permissions to allow read
70
     # RedmineReadPermissions :browse_repository
71
     ## Optional Permissions to allow write
72
     # RedmineWritePermissions :commit_access
73
     
74
  </Location>
75

    
76
To be able to browse repository inside redmine, you must add something
77
like that :
78

    
79
   <Location /svn-private>
80
     DAV svn
81
     SVNParentPath "/var/svn"
82
     Order deny,allow
83
     Deny from all
84
     # only allow reading orders
85
     <Limit GET PROPFIND OPTIONS REPORT>
86
       Allow from redmine.server.ip
87
     </Limit>
88
   </Location>
89

    
90
and you will have to use this reposman.rb command line to create repository :
91

    
92
  reposman.rb --redmine my.redmine.server --svn-dir /var/svn --owner www-data -u http://svn.server/svn-private/
93

    
94
=head1 MIGRATION FROM OLDER RELEASES
95

    
96
If you use an older reposman.rb (r860 or before), you need to change
97
rights on repositories to allow the apache user to read and write
98
S<them :>
99

    
100
  sudo chown -R www-data /var/svn/*
101
  sudo chmod -R u+w /var/svn/*
102

    
103
And you need to upgrade at least reposman.rb (after r860).
104

    
105
=cut
106

    
107
use strict;
108
use warnings FATAL => 'all', NONFATAL => 'redefine';
109

    
110
use DBI;
111
use Digest::SHA1;
112

    
113
# optional module for LDAP authentication
114
my $CanUseLDAPAuth = eval("use Authen::Simple::LDAP; 1");
115

    
116
use Apache2::Module;
117
use Apache2::Access;
118
use Apache2::ServerRec qw();
119
use Apache2::RequestRec qw();
120
use Apache2::RequestUtil qw();
121
use Apache2::Const qw(:common :override :cmd_how);
122
use APR::Pool  ();
123
use APR::Table ();
124

    
125
# use Apache2::Directive qw();
126

    
127
my @directives = (
128
   {
129
      name         => 'RedmineDSN',
130
      req_override => OR_AUTHCFG,
131
      args_how     => TAKE1,
132
      errmsg =>
133
'Dsn in format used by Perl DBI. eg: "DBI:Pg:dbname=databasename;host=my.db.server"',
134
   },
135
   {
136
      name         => 'RedmineProjectId',
137
      req_override => OR_AUTHCFG,
138
      args_how     => TAKE1,
139
      errmsg =>
140
        'Project identifiant to bind authorization only on a specific project',
141
   },
142
   {
143
      name         => 'RedmineAuthenticationOnly',
144
      req_override => OR_AUTHCFG,
145
      args_how     => TAKE1,
146
      errmsg       => 'On if no authorization check',
147
   },
148
   {
149
      name         => 'RedmineReadPermissions',
150
      req_override => OR_AUTHCFG,
151
      args_how     => ITERATE,
152
      errmsg       => 'list of permissions to allow read access',
153
   },
154
   {
155
      name         => 'RedmineWritePermissions',
156
      req_override => OR_AUTHCFG,
157
      args_how     => ITERATE,
158
      errmsg       => 'list of permissions to allow other than read access',
159
   },
160
   {
161
      name         => 'RedmineDbUser',
162
      req_override => OR_AUTHCFG,
163
      args_how     => TAKE1,
164
   },
165
   {
166
      name         => 'RedmineDbPass',
167
      req_override => OR_AUTHCFG,
168
      args_how     => TAKE1,
169
   },
170
   {
171
      name         => 'RedmineDbWhereClause',
172
      req_override => OR_AUTHCFG,
173
      args_how     => TAKE1,
174
   },
175
   {
176
      name         => 'RedmineCacheCredsMax',
177
      req_override => OR_AUTHCFG,
178
      args_how     => TAKE1,
179
      errmsg       => 'RedmineCacheCredsMax must be decimal number',
180
   },
181
);
182

    
183
sub RedmineProjectId          { set_val( 'RedmineProjectId',          @_ ); }
184
sub RedmineAuthenticationOnly { set_val( 'RedmineAuthenticationOnly', @_ ); }
185

    
186
sub RedmineReadPermissions {
187
   my ( $self, $parms, $arg ) = @_;
188
   push @{ $self->{RedmineReadPermissions} }, $arg;
189
}
190

    
191
sub RedmineWritePermissions {
192
   my ( $self, $parms, $arg ) = @_;
193
   push @{ $self->{RedmineWritePermissions} }, $arg;
194
}
195

    
196
sub RedmineDSN {
197
   my ( $self, $parms, $arg ) = @_;
198
   $self->{RedmineDSN} = $arg;
199
   my $query = "SELECT 
200
              permissions
201
              FROM members, projects, users, roles, member_roles
202
              WHERE 
203
                projects.id=members.project_id 
204
                AND member_roles.member_id=members.id
205
                AND users.id=members.user_id 
206
                AND roles.id=member_roles.role_id
207
                AND login=? 
208
                AND identifier=? ";
209
   $self->{RedmineQuery} = trim($query);
210
}
211
sub RedmineDbUser { set_val( 'RedmineDbUser', @_ ); }
212
sub RedmineDbPass { set_val( 'RedmineDbPass', @_ ); }
213

    
214
sub RedmineDbWhereClause {
215
   my ( $self, $parms, $arg ) = @_;
216
   $self->{RedmineQuery} =
217
     trim( $self->{RedmineQuery} . ( $arg ? $arg : "" ) . " " );
218
}
219

    
220
sub RedmineCacheCredsMax {
221
   my ( $self, $parms, $arg ) = @_;
222
   if ($arg) {
223
      $self->{RedmineCachePool} = APR::Pool->new;
224
      $self->{RedmineCacheCreds} =
225
        APR::Table::make( $self->{RedmineCachePool}, $arg );
226
      $self->{RedmineCacheCredsCount} = 0;
227
      $self->{RedmineCacheCredsMax}   = $arg;
228
   }
229
}
230

    
231
sub printlog {
232
   my $string = shift;
233
   my $r      = shift;
234
   $r->log->debug($string);
235
}
236

    
237
sub trim {
238
   my $string = shift;
239
   $string =~ s/\s{2,}/ /g;
240
   return $string;
241
}
242

    
243
sub set_val {
244
   my ( $key, $self, $parms, $arg ) = @_;
245
   $self->{$key} = $arg;
246
}
247

    
248
Apache2::Module::add( __PACKAGE__, \@directives );
249

    
250
my %read_only_methods = map { $_ => 1 } qw/GET PROPFIND REPORT OPTIONS/;
251

    
252
sub access_handler {
253
   my $r = shift;
254

    
255
   my $cfg =
256
     Apache2::Module::get_config( __PACKAGE__, $r->server, $r->per_dir_config );
257

    
258
   unless ( $r->some_auth_required ) {
259
      $r->log_reason("No authentication has been configured");
260
      return FORBIDDEN;
261
   }
262

    
263
   return OK
264
     if $cfg->{RedmineAuthenticationOnly};    #anonymous access not allowed
265

    
266
   #check public project AND anonymous access is allowed
267
   if ( !anonymous_denied($r) ) {
268
      my $project_id = get_project_identifier($r);
269
      my $project_pub = is_public_project( $project_id, $r );
270
      if ( $project_pub < 0 ) {
271

    
272
         #Unknown project => only read access is granted
273
         $r->set_handlers( PerlAuthenHandler => [ \&OK ] )
274
           if
275
            defined $read_only_methods{ $r->method };  #anonymous access allowed
276
      }
277
      elsif ( $project_pub > 0 ) {
278

    
279
         #public project, so we check anonymous permissions
280
         my $perm = get_anonymous_permissions($r);
281
         $r->set_handlers( PerlAuthenHandler => [ \&OK ] )
282
           if check_permission( $perm, $cfg, $r );     #anonymous access allowed
283
      }
284
   }
285

    
286
   return OK;
287
}
288

    
289
sub authen_handler {
290
   my $r = shift;
291

    
292
   my ( $res, $redmine_pass ) = $r->get_basic_auth_pw();
293
   return $res unless $res == OK;
294

    
295
   my $method = $r->method;
296

    
297
   my $cfg =
298
     Apache2::Module::get_config( __PACKAGE__, $r->server, $r->per_dir_config );
299
   my $project_id = get_project_identifier($r);
300

    
301
   #1. Check cache if available
302
   my $usrprojpass;
303
   my $pass_digest = Digest::SHA1::sha1_hex($redmine_pass);
304
   if ( $cfg->{RedmineCacheCredsMax} ) {
305
      $usrprojpass =
306
        $cfg->{RedmineCacheCreds}->get( $r->user . ":" . $project_id );
307
      return OK
308
        if ( defined $usrprojpass and ( $usrprojpass eq $pass_digest ) );
309
   }
310

    
311
   #2. Then authenticate user
312
   if ( !authenticate_user( $r->user, $redmine_pass, $r ) ) {
313

    
314
      #wrong credentials
315
      $r->note_auth_failure();
316
      return AUTH_REQUIRED;
317
   }
318

    
319
   #Authentication only, no permissions check
320
   return OK if $cfg->{RedmineAuthenticationOnly};
321

    
322
   my $project_pub = is_public_project( $project_id, $r );
323

    
324
   #if project doesn't exist then administrator is required
325
   if ( $project_pub < 0 ) {
326
      if ( is_admin( $r->user, $r ) ) {
327
         return OK;
328
      }
329
      else {
330
         $r->note_auth_failure();
331
         return AUTH_REQUIRED;
332
      }
333
   }
334

    
335
   #Check permissions if user is a project member
336
   my @perms = get_user_permissions( $r->user, $project_id, $cfg, $r );
337
   if (@perms) {
338
      for my $perm (@perms) {
339
         if ( check_permission( $perm, $cfg, $r ) ) {
340
            update_redmine_cache( $r->user, $project_id, $pass_digest, $cfg );
341
            return OK if check_permission( $perm, $cfg, $r );
342
         }
343
      }
344

    
345
   }
346
   elsif ( $project_pub > 0 ) {
347

    
348
      #public project so we check permissions for non-members
349
      my $perm2 = get_nonmember_permission($r);
350
      return OK if check_permission( $perm2, $cfg, $r );
351
   }
352

    
353
   $r->note_auth_failure();
354
   return AUTH_REQUIRED;
355
}
356

    
357
sub is_public_project {
358
   my $project_id = shift;
359
   my $r          = shift;
360

    
361
   my $dbh = connect_database($r);
362
   my $sth = $dbh->prepare(
363
      "SELECT is_public FROM projects WHERE projects.identifier=?");
364

    
365
   $sth->execute($project_id);
366
   my ($ret) = $sth->fetchrow_array;
367
   $sth->finish();
368
   $dbh->disconnect();
369

    
370
   if ( defined $ret ) {
371
      return ( $ret ? 1 : 0 );
372
   }
373
   else {
374
      return -1;    #project doesn't exist
375
   }
376
}
377

    
378
# perhaps we should use repository right (other read right) to check public access.
379
# it could be faster BUT it doesn't work for the moment.
380
# sub is_public_project_by_file {
381
#     my $project_id = shift;
382
#     my $r = shift;
383

    
384
#     my $tree = Apache2::Directive::conftree();
385
#     my $node = $tree->lookup('Location', $r->location);
386
#     my $hash = $node->as_hash;
387

    
388
#     my $svnparentpath = $hash->{SVNParentPath};
389
#     my $repos_path = $svnparentpath . "/" . $project_id;
390
#     return 1 if (stat($repos_path))[2] & 00007;
391
# }
392

    
393
sub get_project_identifier {
394
   my $r = shift;
395

    
396
   my $identifier;
397
   my $cfg =
398
     Apache2::Module::get_config( __PACKAGE__, $r->server, $r->per_dir_config );
399

    
400
   if ( $cfg->{RedmineProjectId} ) {
401
      $identifier = $cfg->{RedmineProjectId};
402
   }
403
   else {
404
      my $location = $r->location;
405
      ($identifier) = $r->uri =~ m{$location/*([^/]+)};
406
   }
407

    
408
   $identifier;
409
}
410

    
411
sub connect_database {
412
   my $r = shift;
413

    
414
   my $cfg =
415
     Apache2::Module::get_config( __PACKAGE__, $r->server, $r->per_dir_config );
416
   return DBI->connect( $cfg->{RedmineDSN}, $cfg->{RedmineDbUser},
417
      $cfg->{RedmineDbPass} );
418
}
419

    
420
sub get_anonymous_permissions {
421
   my $r = shift;
422

    
423
   my $dbh = connect_database($r);
424
   my $sth = $dbh->prepare("SELECT permissions FROM roles WHERE roles.id=2");
425

    
426
   $sth->execute();
427
   my ($ret) = $sth->fetchrow_array;
428
   $sth->finish();
429
   $dbh->disconnect();
430

    
431
   $ret;
432
}
433

    
434
sub authenticate_user {
435
   my $redmine_user = shift;
436
   my $redmine_pass = shift;
437
   my $r            = shift;
438

    
439
   my $pass_digest = Digest::SHA1::sha1_hex($redmine_pass);
440
   my $ret;
441

    
442
   my $dbh = connect_database($r);
443
   my $sth = $dbh->prepare(
444
"SELECT hashed_password, auth_source_id FROM users WHERE users.status=1 AND login=? "
445
   );
446

    
447
   $sth->execute($redmine_user);
448
   while ( my ( $hashed_password, $auth_source_id, $permissions ) =
449
      $sth->fetchrow_array )
450
   {
451

    
452
      #Check authentication
453
      unless ($auth_source_id) {
454
         if ( $hashed_password eq $pass_digest ) {
455
            $ret = 1;
456
         }
457
      }
458
      elsif ($CanUseLDAPAuth) {
459
         my $sthldap = $dbh->prepare(
460
"SELECT host,port,tls,account,account_password,base_dn,attr_login from auth_sources WHERE id = ?;"
461
         );
462
         $sthldap->execute($auth_source_id);
463
         while ( my @rowldap = $sthldap->fetchrow_array ) {
464
            my $ldap = Authen::Simple::LDAP->new(
465
               host => ( $rowldap[2] == 1 || $rowldap[2] eq "t" )
466
               ? "ldaps://$rowldap[0]"
467
               : $rowldap[0],
468
               port   => $rowldap[1],
469
               basedn => $rowldap[5],
470
               binddn => $rowldap[3] ? $rowldap[3] : "",
471
               bindpw => $rowldap[4] ? $rowldap[4] : "",
472
               filter => "(" . $rowldap[6] . "=%s)"
473
            );
474
            $ret = 1
475
              if ( $ldap->authenticate( $redmine_user, $redmine_pass ) );
476
         }
477
         $sthldap->finish();
478
      }
479
   }
480
   $sth->finish();
481
   $dbh->disconnect();
482

    
483
   $ret;
484
}
485

    
486
sub is_admin {
487
   my $redmine_user = shift;
488
   my $r            = shift;
489

    
490
   my $dbh = connect_database($r);
491
   my $sth = $dbh->prepare("SELECT admin FROM users WHERE login=?");
492

    
493
   $sth->execute($redmine_user);
494
   my ($ret) = $sth->fetchrow_array;
495
   $sth->finish();
496
   $dbh->disconnect();
497

    
498
   $ret;
499
}
500

    
501
sub check_permission() {
502
   my $perm = shift;
503
   my $cfg  = shift;
504
   my $r    = shift;
505

    
506
   my $ret;
507
   my @listperms;
508
   if ( defined $read_only_methods{ $r->method } ) {
509
      @listperms =
510
          $cfg->{RedmineReadPermissions}
511
        ? @{ $cfg->{RedmineReadPermissions} }
512
        : (':browse_repository');
513
   }
514
   else {
515
      @listperms =
516
          $cfg->{RedmineWritePermissions}
517
        ? @{ $cfg->{RedmineWritePermissions} }
518
        : (':commit_access');
519
   }
520
   for my $p (@listperms) {
521
      if ( $perm =~ /$p/ ) {
522
         $ret = 1;
523
      }
524
   }
525
   $ret;
526
}
527

    
528
sub get_user_permissions {
529
   my $redmine_user = shift;
530
   my $project_id   = shift;
531
   my $cfg          = shift;
532
   my $r            = shift;
533

    
534
   #$self->{RedmineQuery}
535
   my $dbh = connect_database($r);
536
   my $sth = $dbh->prepare( $cfg->{RedmineQuery} );
537

    
538
   $sth->execute( $redmine_user, $project_id );
539
   my @ret;
540
   while ( my ($it) = $sth->fetchrow_array ) {
541
      push( @ret, $it );
542
   }
543
   $sth->finish();
544
   $dbh->disconnect();
545

    
546
   @ret;
547
}
548

    
549
sub get_nonmember_permission {
550
   my $r = shift;
551

    
552
   my $dbh = connect_database($r);
553
   my $sth = $dbh->prepare("SELECT permissions FROM roles WHERE roles.id=1");
554

    
555
   $sth->execute();
556
   my ($ret) = $sth->fetchrow_array;
557
   $sth->finish();
558
   $dbh->disconnect();
559

    
560
   $ret;
561
}
562

    
563
sub update_redmine_cache {
564
   my $redmine_user = shift;
565
   my $project_id   = shift;
566
   my $redmine_pass = shift;
567
   my $cfg          = shift;
568

    
569
   my $pass_digest = Digest::SHA1::sha1_hex($redmine_pass);
570
   if ( $cfg->{RedmineCacheCredsMax} ) {
571
      my $usrprojpass =
572
        $cfg->{RedmineCacheCreds}->get( $redmine_user . ":" . $project_id );
573
      if ( defined $usrprojpass ) {
574
         $cfg->{RedmineCacheCreds}
575
           ->set( $redmine_user . ":" . $project_id, $pass_digest );
576
      }
577
      else {
578
         if ( $cfg->{RedmineCacheCredsCount} < $cfg->{RedmineCacheCredsMax} ) {
579
            $cfg->{RedmineCacheCreds}
580
              ->set( $redmine_user . ":" . $project_id, $pass_digest );
581
            $cfg->{RedmineCacheCredsCount}++;
582
         }
583
         else {
584
            $cfg->{RedmineCacheCreds}->clear();
585
            $cfg->{RedmineCacheCredsCount} = 0;
586
         }
587
      }
588
   }
589
}
590

    
591
sub anonymous_denied {
592
   my $r = shift;
593

    
594
   my $dbh = connect_database($r);
595
   my $sth =
596
     $dbh->prepare("SELECT value FROM settings WHERE name='login_required'");
597

    
598
   $sth->execute();
599
   my ($ret) = $sth->fetchrow_array;
600
   $sth->finish();
601
   $dbh->disconnect();
602

    
603
   $ret;
604
}
605

    
606
1;
(1-1/5)