Project

General

Profile

Patch #3712 » Redmine_alternate.pm

Guillaume Perréal, 2011-11-12 17:13

 
1
package Apache::Authn::Redmine;
2

    
3
=head1 Apache::Authn::Redmine
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/Redmine.pm
41
   PerlLoadModule Apache::Authn::Redmine
42
   <Location /svn>
43
     DAV svn
44
     SVNParentPath "/var/svn"
45

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

    
50
     PerlAuthenHandler Apache::Authn::Redmine::authen_handler
51
     PerlAuthzHandler Apache::Authn::Redmine::authz_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
  </Location>
66

    
67
To be able to browse repository inside redmine, you must add something
68
like that :
69

    
70
   <Location /svn-private>
71
     DAV svn
72
     SVNParentPath "/var/svn"
73
     Order deny,allow
74
     Deny from all
75
     # only allow reading orders
76
     <Limit GET PROPFIND OPTIONS REPORT>
77
       Allow from redmine.server.ip
78
     </Limit>
79
   </Location>
80

    
81
and you will have to use this reposman.rb command line to create repository :
82

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

    
85
=head1 MIGRATION FROM OLDER RELEASES
86

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

    
91
  sudo chown -R www-data /var/svn/*
92
  sudo chmod -R u+w /var/svn/*
93

    
94
And you need to upgrade at least reposman.rb (after r860).
95

    
96
=cut
97

    
98
use strict;
99
use warnings FATAL => 'all', NONFATAL => 'redefine';
100

    
101
use DBI;
102
use Digest::SHA1;
103
# optional module for LDAP authentication
104
my $CanUseLDAPAuth = eval("use Authen::Simple::LDAP; 1");
105

    
106
# Reload ourself (disable in production)
107
use Apache2::Reload;
108

    
109
use Apache2::Module;
110
use Apache2::Access;
111
use Apache2::ServerRec qw();
112
use Apache2::RequestRec qw();
113
use Apache2::RequestUtil qw();
114
use Apache2::Const qw(:common :override :cmd_how);
115
use Apache2::Log;
116
use APR::Pool ();
117
use APR::Table ();
118

    
119
# use Apache2::Directive qw();
120

    
121
my @directives = (
122
  {
123
    name => 'RedmineDSN',
124
    req_override => OR_AUTHCFG,
125
    args_how => TAKE1,
126
    errmsg => 'Dsn in format used by Perl DBI. eg: "DBI:Pg:dbname=databasename;host=my.db.server"',
127
  },
128
  {
129
    name => 'RedmineDbUser',
130
    req_override => OR_AUTHCFG,
131
    args_how => TAKE1,
132
  },
133
  {
134
    name => 'RedmineDbPass',
135
    req_override => OR_AUTHCFG,
136
    args_how => TAKE1,
137
  },
138
  {
139
    name => 'RedmineDbWhereClause',
140
    req_override => OR_AUTHCFG,
141
    args_how => TAKE1,
142
  },
143
  {
144
    name => 'RedmineCacheCredsMax',
145
    req_override => OR_AUTHCFG,
146
    args_how => TAKE1,
147
    errmsg => 'RedmineCacheCredsMax must be decimal number',
148
  },
149
  {
150
    name => 'RedmineCacheCredsMaxAge',
151
    req_override => OR_AUTHCFG,
152
    args_how => TAKE1,
153
    errmsg => 'RedmineCacheCredsMaxAge must be decimal number',
154
  },
155
);
156

    
157
sub RedmineDSN { 
158
  my ($self, $parms, $arg) = @_;
159
  $self->{RedmineDSN} = $arg;
160
  my $query = "SELECT 
161
                 hashed_password, salt, auth_source_id, permissions
162
              FROM members, projects, users, roles, member_roles
163
              WHERE 
164
                projects.id=members.project_id
165
                AND member_roles.member_id=members.id
166
                AND users.id=members.user_id 
167
                AND roles.id=member_roles.role_id
168
                AND users.status=1 
169
                AND login=? 
170
                AND identifier=? ";
171
  $self->{RedmineQuery} = trim($query);
172
}
173

    
174
sub RedmineDbUser { set_val('RedmineDbUser', @_); }
175
sub RedmineDbPass { set_val('RedmineDbPass', @_); }
176
sub RedmineDbWhereClause { 
177
  my ($self, $parms, $arg) = @_;
178
  $self->{RedmineQuery} = trim($self->{RedmineQuery}.($arg ? $arg : "")." ");
179
}
180

    
181
sub RedmineCacheCredsMax { 
182
  my ($self, $parms, $arg) = @_;
183
  if ($arg) {
184
    $self->{RedmineCachePool} = APR::Pool->new;
185
    $self->{RedmineCacheCreds} = APR::Table::make($self->{RedmineCachePool}, $arg);
186
    $self->{RedmineCacheCredsCount} = 0;
187
    $self->{RedmineCacheCredsMax} = $arg;
188
  }
189
}
190

    
191
sub RedmineCacheCredsMaxAge { set_val('RedmineCacheCredsMaxAge', @_); }
192

    
193
sub trim {
194
  my $string = shift;
195
  $string =~ s/\s{2,}/ /g;
196
  return $string;
197
}
198

    
199
sub set_val {
200
  my ($key, $self, $parms, $arg) = @_;
201
  $self->{$key} = $arg;
202
}
203

    
204
Apache2::Module::add(__PACKAGE__, \@directives);
205

    
206
my %read_only_methods = map { $_ => ':browse_repository' } qw/GET PROPFIND REPORT OPTIONS/;
207

    
208
sub authen_handler {
209
  my $r = shift;
210
  
211
  unless ($r->some_auth_required) {
212
      $r->log_reason("No authentication has been configured");
213
      return FORBIDDEN;
214
  }
215

    
216
	my ($res, $password) = $r->get_basic_auth_pw();
217
	my $reason;
218
	
219
	if($res == OK) {
220
		# Got user and password
221

    
222
		#	Used cached credentials if possible
223
		my $cache_key = get_cache_key($r, $password);
224
		if(cache_get($r, $cache_key)) {
225
			$r->log->debug("reusing cached credentials for user '", $r->user, "'");
226
			$r->set_handlers(PerlAuthzHandler => undef);
227
			
228
		} else {
229
			# Else check them
230
			my $dbh = connect_database($r);
231
			($res, $reason) = check_login($r, $dbh, $password);
232
			$dbh->disconnect();
233
			
234
			# Store the cache key for latter use
235
			$r->pnotes("RedmineCacheKey" => $cache_key) if $res == OK;	
236
		}
237
		
238
	} elsif($res == AUTH_REQUIRED) {
239
		my $dbh = connect_database($r);
240
		if(is_authentication_forced($dbh)) {
241
			# We really want an user
242
			$reason = 'anonymous access disabled';
243
		} else {
244
			# Anonymous is allowed
245
			$res = OK;
246
		}
247
		$dbh->disconnect();
248
		
249
	}	
250

    
251
	$r->log_reason($reason) if defined($reason);
252
	$r->note_basic_auth_failure unless $res == OK;	
253

    
254
  return $res;
255
}
256

    
257

    
258
sub check_login {
259
	my ($r, $dbh, $password) = @_;
260
	my $user = $r->user;
261
	
262
	my ($hashed_password, $status, $auth_source_id, $salt) = query_fetch_first($dbh, 'SELECT hashed_password, status, auth_source_id, salt FROM users WHERE login = ?', $user);
263
	
264
	# Not found
265
	return (AUTH_REQUIRED, "unknown user '$user'") unless defined($hashed_password);
266

    
267
	# Check password	
268
	if($auth_source_id) {
269
		# LDAP authentication
270
		
271
		# Ensure Authen::Simple::LDAP is available
272
		return (SERVER_ERROR, "Redmine LDAP authentication requires Authen::Simple::LDAP")
273
			unless $CanUseLDAPAuth;
274

    
275
		# Get LDAP server informations
276
		my($host, $port, $tls, $account, $account_password, $base_dn, $attr_login) = query_fetch_first(
277
			$dbh,
278
			"SELECT host,port,tls,account,account_password,base_dn,attr_login from auth_sources WHERE id = ?",
279
			$auth_source_id
280
		);
281
		
282
		# Check them
283
		return (SERVER_ERROR, "Undefined authentication source for '$user'")
284
			unless defined $host;
285

    
286
		# Connect to the LDAP server			
287
    my $ldap = Authen::Simple::LDAP->new(
288
        host    =>      is_true($tls) ? "ldaps://$host:$port" : $host,
289
        port    =>      $port,
290
        basedn  =>      $base_dn,
291
        binddn  =>      $account || "",
292
        bindpw  =>      $account_password || "",
293
        filter  =>      '('.$attr_login.'=%s)'
294
    );
295
    
296
    # Finally check user login
297
    return (AUTH_REQUIRED, "LDAP authentication failed (user: '$user', server: '$host')")
298
    	unless $ldap->authenticate($user, $password);
299
		
300
	} else {	
301
		# Database authentication
302
		my $pass_digest = Digest::SHA1::sha1_hex($password);
303
		return (AUTH_REQUIRED, "wrong password for '$user'")
304
			unless $hashed_password eq Digest::SHA1::sha1_hex($salt.$pass_digest);
305
	}
306
	
307
	# Password is ok, check if account if locked	
308
	return (FORBIDDEN, "inactive account: '$user'") unless $status eq 1;
309

    
310
	$r->log->debug("successfully authenticated as active redmine user '$user'");
311

    
312
	# Everything's ok	
313
	return OK;
314
}
315

    
316
# check if authentication is forced
317
sub is_authentication_forced {
318
	my $dbh = shift;
319
  return is_true(query_fetch_first($dbh, "SELECT value FROM settings WHERE settings.name = 'login_required'"));
320
}
321

    
322
sub authz_handler {
323
  my $r = shift;
324

    
325
  unless ($r->some_auth_required) {
326
      $r->log_reason("No authentication has been configured");
327
      return FORBIDDEN;
328
  }
329

    
330
  my $dbh = connect_database($r); 
331
  
332
  my ($identifier, $project_id, $is_public, $status) = get_project_data($r, $dbh);
333
	$is_public = is_true($is_public);
334

    
335
	my($res, $reason) = FORBIDDEN;
336
  
337
  unless(defined($project_id)) {
338
  	# Unknown project
339
  	$res = DECLINED;
340
  	$reason = "not a redmine project";
341
  	
342
  } elsif($status ne 1 && !defined($read_only_methods{$r->method})) {
343
  	# Write operation on archived project is forbidden
344
  	$reason = "write operations on inactive project '$identifier' are forbidden";
345

    
346
	} elsif(!$r->user) {
347
  	# Anonymous access
348
		$res = AUTH_REQUIRED;
349
		$reason = "anonymous access to '$identifier' denied";
350
		
351
		if($is_public) {
352
			# Check anonymous permissions
353
	 		my $required = required_permission($r);
354
			my ($id) = query_fetch_first($dbh, "SELECT id FROM roles WHERE builtin = 2 AND permissions LIKE ?", '%'.$required.'%');
355
			$res = OK if defined $id;
356
		}
357
  	
358
  	# Force login if failed
359
		$r->note_auth_failure unless $res == OK;
360
		
361
  } else {
362
  	# Logged in user
363
 		my $required = required_permission($r);
364
 		my $user = $r->user;
365
 		
366
 		# Look for membership with required role
367
 		my($id) = query_fetch_first($dbh, q{
368
			SELECT roles.id FROM users, members, member_roles, roles
369
			WHERE users.login = ?
370
			  AND users.id = members.user_id
371
			  AND	members.project_id = ?
372
			  AND members.id = member_roles.member_id
373
			  AND member_roles.role_id = roles.id
374
			  AND roles.permissions LIKE ?
375
		}, $user, $project_id, '%'.$required.'%');
376

    
377
		if(!defined($id) && $is_public) {
378
			# Fallback to non-member role for public projects
379
			$id = query_fetch_first($dbh, "SELECT id FROM roles WHERE builtin = 1 AND permissions LIKE ?", '%'.$required.'%');
380
		}
381
		
382
 		if(defined($id)) {
383
			$res = OK;
384
			
385
			my $cache_key = $r->pnotes("RedmineCacheKey");
386
			cache_set($r, $cache_key) if defined $cache_key;
387

    
388
		} else {
389
			$reason = "insufficient permissions (user: '$user', project: '$identifier', required: '$required')";
390
		}
391
  }
392

    
393
	$r->log->debug("access granted: user '", ($r->user || 'anonymous'), "', project '$identifier', method: '", $r->method, "'") if $res == OK;  
394

    
395
  $r->log_reason($reason) if $res != OK && defined $reason;
396
  
397
  return $res;
398
}
399

    
400
# get project identifier
401
sub get_project_identifier {
402
	my $r = shift;
403
	my $dbh = shift;
404
	
405
	my $location = $r->location;
406
  my ($identifier) = $r->uri =~ m{^\Q$location\E/*([^/]+)};
407
  return $identifier;
408
}
409

    
410
# get information about the project
411
sub get_project_data {
412
	my $r = shift;
413
	my $dbh = shift;
414
	
415
  my $identifier = get_project_identifier($r);
416
	return $identifier, query_fetch_first($dbh, "SELECT id, is_public, status FROM projects WHERE identifier = ?", $identifier);
417
}
418

    
419
# get redmine permission based on HTTP method
420
sub required_permission {
421
	my $r = shift;
422
	$read_only_methods{$r->method} || ':commit_access';
423
}
424

    
425
# return module configuration for current directory
426
sub get_config {
427
	my $r = shift;
428
	Apache2::Module::get_config(__PACKAGE__, $r->server, $r->per_dir_config);
429
}
430

    
431
# get a connection to the redmine database
432
sub connect_database {
433
	my $r = shift;    
434

    
435
	my $cfg = get_config($r);
436
	return DBI->connect($cfg->{RedmineDSN}, $cfg->{RedmineDbUser}, $cfg->{RedmineDbPass});
437
}
438

    
439
# execute a query and return the first row
440
sub query_fetch_first {
441
	my $dbh = shift;
442
	my $query = shift;
443

    
444
  my $sth = $dbh->prepare($query);
445
  $sth->execute(@_);
446
	my @row = $sth->fetchrow_array();
447
  $sth->finish();
448
  undef $sth;
449

    
450
	@row;	
451
}
452

    
453
# tell if a value returned from SQL is "true"
454
sub is_true {
455
	my $value = shift;
456
  return defined($value) && ($value eq "1" || $value eq 1 || $value eq "t");
457
}
458

    
459
# build credential cache key
460
sub get_cache_key {
461
	my ($r, $password) = @_;
462
	return Digest::SHA1::sha1_hex(join(':', get_project_identifier($r), $r->user, $password, required_permission($r)));
463
}
464

    
465
# check if credentials exist in cache
466
sub cache_get {
467
	my($r, $key) = @_;
468
	my $cfg = get_config($r);
469
	my $cache = $cfg->{RedmineCacheCreds};
470
	return unless $cache;
471
	my $time = $cache->get($key) or return 0;
472
	if($cfg->{RedmineCacheCredsMaxAge} && ($r->request_time - $time) > $cfg->{RedmineCacheCredsMaxAge}) {
473
		$cache->unset($key);
474
		$cfg->{RedmineCacheCredsCount}--;
475
		return 0;
476
	}
477
	1;
478
}
479

    
480
# put credentials in cache
481
sub cache_set {
482
	my($r, $key) = @_;
483
	my $cfg = get_config($r);
484
	my $cache = $cfg->{RedmineCacheCreds};
485
	return unless $cache;
486
	if($cfg->{RedmineCacheCredsCount} >= $cfg->{RedmineCacheCredsMax}) {
487
		$cache->clear;
488
		$cfg->{RedmineCacheCredsCount} = 0;
489
	}
490
	$cache->set($key, $r->request_time);
491
	$cfg->{RedmineCacheCredsCount}++;
492
}
493

    
494
1;
495

    
(5-5/5)