To check out this repository please hg clone the following URL, or open the URL using EasyMercurial or your preferred Mercurial client.

Statistics Download as Zip
| Branch: | Tag: | Revision:

root / extra / soundsoftware / SoundSoftware.pm @ 984:8b6acaabe2da

History | View | Annotate | Download (16.3 KB)

1
package Apache::Authn::SoundSoftware;
2

    
3
=head1 Apache::Authn::SoundSoftware
4

    
5
SoundSoftware - a mod_perl module for Apache authentication against a
6
Redmine database and optional LDAP implementing the access control
7
rules required for the SoundSoftware.ac.uk repository site.
8

    
9
=head1 SYNOPSIS
10

    
11
This module is closely based on the Redmine.pm authentication module
12
provided with Redmine.  It is intended to be used for authentication
13
in front of a repository service such as hgwebdir.
14

    
15
Requirements:
16

    
17
1. Clone/pull from repo for public project: Any user, no
18
authentication required
19

    
20
2. Clone/pull from repo for private project: Project members only
21

    
22
3. Push to repo for public project: "Permitted" users only (this
23
probably means project members who are also identified in the hgrc web
24
section for the repository and so will be approved by hgwebdir?)
25

    
26
4. Push to repo for private project: "Permitted" users only (as above)
27

    
28
5. Push to any repo that is tracking an external repo: Refused always
29

    
30
=head1 INSTALLATION
31

    
32
Debian/ubuntu:
33

    
34
  apt-get install libapache-dbi-perl libapache2-mod-perl2 \
35
    libdbd-mysql-perl libauthen-simple-ldap-perl libio-socket-ssl-perl
36

    
37
Note that LDAP support is hardcoded "on" in this script (it is
38
optional in the original Redmine.pm).
39

    
40
=head1 CONFIGURATION
41

    
42
   ## This module has to be in your perl path
43
   ## eg:  /usr/local/lib/site_perl/Apache/Authn/SoundSoftware.pm
44
   PerlLoadModule Apache::Authn::SoundSoftware
45

    
46
   # Example when using hgwebdir
47
   ScriptAlias / "/var/hg/hgwebdir.cgi/"
48

    
49
   <Location />
50
       AuthName "Mercurial"
51
       AuthType Basic
52
       Require valid-user
53
       PerlAccessHandler Apache::Authn::SoundSoftware::access_handler
54
       PerlAuthenHandler Apache::Authn::SoundSoftware::authen_handler
55
       SoundSoftwareDSN "DBI:mysql:database=redmine;host=localhost"
56
       SoundSoftwareDbUser "redmine"
57
       SoundSoftwareDbPass "password"
58
       Options +ExecCGI
59
       AddHandler cgi-script .cgi
60
       ## Optional where clause (fulltext search would be slow and
61
       ## database dependant).
62
       # SoundSoftwareDbWhereClause "and members.role_id IN (1,2)"
63
       ## Optional prefix for local repository URLs
64
       # SoundSoftwareRepoPrefix "/var/hg/"
65
  </Location>
66

    
67
See the original Redmine.pm for further configuration notes.
68

    
69
=cut
70

    
71
use strict;
72
use warnings FATAL => 'all', NONFATAL => 'redefine';
73

    
74
use DBI;
75
use Digest::SHA1;
76
use Authen::Simple::LDAP;
77
use Apache2::Module;
78
use Apache2::Access;
79
use Apache2::ServerRec qw();
80
use Apache2::RequestRec qw();
81
use Apache2::RequestUtil qw();
82
use Apache2::Const qw(:common :override :cmd_how);
83
use APR::Pool ();
84
use APR::Table ();
85

    
86
my @directives = (
87
  {
88
    name => 'SoundSoftwareDSN',
89
    req_override => OR_AUTHCFG,
90
    args_how => TAKE1,
91
    errmsg => 'Dsn in format used by Perl DBI. eg: "DBI:Pg:dbname=databasename;host=my.db.server"',
92
  },
93
  {
94
    name => 'SoundSoftwareDbUser',
95
    req_override => OR_AUTHCFG,
96
    args_how => TAKE1,
97
  },
98
  {
99
    name => 'SoundSoftwareDbPass',
100
    req_override => OR_AUTHCFG,
101
    args_how => TAKE1,
102
  },
103
  {
104
    name => 'SoundSoftwareDbWhereClause',
105
    req_override => OR_AUTHCFG,
106
    args_how => TAKE1,
107
  },
108
  {
109
    name => 'SoundSoftwareRepoPrefix',
110
    req_override => OR_AUTHCFG,
111
    args_how => TAKE1,
112
  },
113
  {
114
    name => 'SoundSoftwareSslRequired',
115
    req_override => OR_AUTHCFG,
116
    args_how => TAKE1,
117
  },
118
);
119

    
120
sub SoundSoftwareDSN { 
121
    my ($self, $parms, $arg) = @_;
122
    $self->{SoundSoftwareDSN} = $arg;
123
    my $query = "SELECT 
124
                 hashed_password, salt, auth_source_id, permissions
125
              FROM members, projects, users, roles, member_roles
126
              WHERE 
127
                projects.id=members.project_id
128
                AND member_roles.member_id=members.id
129
                AND users.id=members.user_id 
130
                AND roles.id=member_roles.role_id
131
                AND users.status=1 
132
                AND login=? 
133
                AND identifier=? ";
134
    $self->{SoundSoftwareQuery} = trim($query);
135
}
136

    
137
sub SoundSoftwareDbUser { set_val('SoundSoftwareDbUser', @_); }
138
sub SoundSoftwareDbPass { set_val('SoundSoftwareDbPass', @_); }
139
sub SoundSoftwareDbWhereClause { 
140
    my ($self, $parms, $arg) = @_;
141
    $self->{SoundSoftwareQuery} = trim($self->{SoundSoftwareQuery}.($arg ? $arg : "")." ");
142
}
143

    
144
sub SoundSoftwareRepoPrefix { 
145
    my ($self, $parms, $arg) = @_;
146
    if ($arg) {
147
	$self->{SoundSoftwareRepoPrefix} = $arg;
148
    }
149
}
150

    
151
sub SoundSoftwareSslRequired { set_val('SoundSoftwareSslRequired', @_); }
152

    
153
sub trim {
154
    my $string = shift;
155
    $string =~ s/\s{2,}/ /g;
156
    return $string;
157
}
158

    
159
sub set_val {
160
    my ($key, $self, $parms, $arg) = @_;
161
    $self->{$key} = $arg;
162
}
163

    
164
Apache2::Module::add(__PACKAGE__, \@directives);
165

    
166

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

    
169
sub access_handler {
170
    my $r = shift;
171

    
172
    print STDERR "SoundSoftware.pm:$$: In access handler at " . scalar localtime() . "\n";
173

    
174
    unless ($r->some_auth_required) {
175
	$r->log_reason("No authentication has been configured");
176
	return FORBIDDEN;
177
    }
178

    
179
    my $method = $r->method;
180

    
181
    print STDERR "SoundSoftware.pm:$$: Method: $method, uri " . $r->uri . ", location " . $r->location . "\n";
182
    print STDERR "SoundSoftware.pm:$$: Accept: " . $r->headers_in->{Accept} . "\n";
183

    
184
    my $dbh = connect_database($r);
185
    unless ($dbh) {
186
	print STDERR "SoundSoftware.pm:$$: Database connection failed!: " . $DBI::errstr . "\n";
187
	return FORBIDDEN;
188
    }
189

    
190
    print STDERR "Connected to db, dbh is " . $dbh . "\n";
191

    
192
    my $project_id = get_project_identifier($dbh, $r);
193

    
194
    # We want to delegate most of the work to the authentication
195
    # handler (to ensure that user is asked to login even for 
196
    # nonexistent projects -- so they can't tell whether a private
197
    # project exists or not without authenticating). So 
198
    # 
199
    # * if the project is public
200
    #   - if the method is read-only
201
    #     + set handler to OK, no auth needed
202
    #   - if the method is not read-only
203
    #     + if the repo is read-only, return forbidden
204
    #     + else require auth
205
    # * if the project is not public or does not exist
206
    #     + require auth
207
    #
208
    # If we are requiring auth and are not currently https, and
209
    # https is required, then we must return a redirect to https
210
    # instead of an OK.
211

    
212
    my $status = get_project_status($dbh, $project_id, $r);
213
    my $readonly = project_repo_is_readonly($dbh, $project_id, $r);
214

    
215
    $dbh->disconnect();
216
    undef $dbh;
217

    
218
    my $auth_ssl_reqd = will_require_ssl_auth($r);
219

    
220
    if ($status == 1) { # public
221

    
222
	print STDERR "SoundSoftware.pm:$$: Project is public\n";
223

    
224
	if (!defined $read_only_methods{$method}) {
225

    
226
	    print STDERR "SoundSoftware.pm:$$: Method is not read-only\n";
227

    
228
	    if ($readonly) {
229
		print STDERR "SoundSoftware.pm:$$: Project repo is read-only, refusing access\n";
230
		return FORBIDDEN;
231
	    } else {
232
		print STDERR "SoundSoftware.pm:$$: Project repo is read-write, auth required\n";
233
		# fall through, this is the normal case
234
	    }
235

    
236
        } elsif ($auth_ssl_reqd and $r->unparsed_uri =~ m/cmd=branchmap/) {
237

    
238
            # A hac^H^H^Hspecial case. We want to ensure we switch to
239
            # https (if it will be necessarily for authentication) 
240
            # before the first POST request, and this is what I think
241
            # will give us suitable warning for Mercurial.
242

    
243
            print STDERR "SoundSoftware.pm:$$: Switching to HTTPS in preparation\n";
244
            # fall through, this is the normal case
245

    
246
	} else {
247
	    # Public project, read-only method -- this is the only
248
	    # case we can decide for certain to accept in this function
249
	    print STDERR "SoundSoftware.pm:$$: Method is read-only, no restriction here\n";
250
	    $r->set_handlers(PerlAuthenHandler => [\&OK]);
251
	    return OK;
252
	}
253

    
254
    } else { # status != 1, i.e. nonexistent or private -- equivalent here
255

    
256
	print STDERR "SoundSoftware.pm:$$: Project is private or nonexistent, auth required\n";
257
	# fall through
258
    }
259

    
260
    if ($auth_ssl_reqd) {
261
        my $redir_to = "https://" . $r->hostname() . $r->unparsed_uri();
262
        print STDERR "SoundSoftware.pm:$$: Need to switch to HTTPS, redirecting to $redir_to\n";
263
        $r->headers_out->add('Location' => $redir_to);
264
        return REDIRECT;
265
    } else {
266
        return OK;
267
    }
268
}
269

    
270
sub authen_handler {
271
    my $r = shift;
272
    
273
    print STDERR "SoundSoftware.pm:$$: In authentication handler at " . scalar localtime() . "\n";
274

    
275
    my $dbh = connect_database($r);
276
    unless ($dbh) {
277
        print STDERR "SoundSoftware.pm:$$: Database connection failed!: " . $DBI::errstr . "\n";
278
        return AUTH_REQUIRED;
279
    }
280
    
281
    my $project_id = get_project_identifier($dbh, $r);
282
    my $realm = get_realm($dbh, $project_id, $r);
283
    $r->auth_name($realm);
284

    
285
    my ($res, $redmine_pass) =  $r->get_basic_auth_pw();
286
    unless ($res == OK) {
287
	$dbh->disconnect();
288
	undef $dbh;
289
	return $res;
290
    }
291
    
292
    print STDERR "SoundSoftware.pm:$$: User is " . $r->user . ", got password\n";
293

    
294
    my $status = get_project_status($dbh, $project_id, $r);
295
    if ($status == 0) {
296
	# nonexistent, behave like private project you aren't a member of
297
	print STDERR "SoundSoftware.pm:$$: Project doesn't exist, not permitted\n";
298
	$dbh->disconnect();
299
	undef $dbh;
300
	$r->note_auth_failure();
301
	return AUTH_REQUIRED;
302
    }
303

    
304
    my $permitted = is_permitted($dbh, $project_id, $r->user, $redmine_pass, $r);
305
    
306
    $dbh->disconnect();
307
    undef $dbh;
308

    
309
    if ($permitted) {
310
	return OK;
311
    } else {
312
	print STDERR "SoundSoftware.pm:$$: Not permitted\n";
313
	$r->note_auth_failure();
314
	return AUTH_REQUIRED;
315
    }
316
}
317

    
318
sub get_project_status {
319
    my $dbh = shift;
320
    my $project_id = shift;
321
    my $r = shift;
322

    
323
    if (!defined $project_id or $project_id eq '') {
324
	return 0; # nonexistent
325
    }
326
    
327
    my $sth = $dbh->prepare(
328
        "SELECT is_public FROM projects WHERE projects.identifier = ?;"
329
    );
330

    
331
    $sth->execute($project_id);
332
    my $ret = 0; # nonexistent
333
    if (my @row = $sth->fetchrow_array) {
334
    	if ($row[0] eq "1" || $row[0] eq "t") {
335
	    $ret = 1; # public
336
    	} else {
337
	    $ret = 2; # private
338
	}
339
    }
340
    $sth->finish();
341
    undef $sth;
342

    
343
    $ret;
344
}
345

    
346
sub will_require_ssl_auth {
347
    my $r = shift;
348

    
349
    my $cfg = Apache2::Module::get_config
350
        (__PACKAGE__, $r->server, $r->per_dir_config);
351

    
352
    if ($cfg->{SoundSoftwareSslRequired} eq "on") {
353
        if ($r->dir_config('HTTPS') eq "on") {
354
            # already have ssl
355
            return 0;
356
        } else {
357
            # require ssl for auth, don't have it yet
358
            return 1;
359
        }
360
    } elsif ($cfg->{SoundSoftwareSslRequired} eq "off") {
361
        # don't require ssl for auth
362
        return 0;
363
    } else {
364
        print STDERR "WARNING: SoundSoftware.pm:$$: SoundSoftwareSslRequired should be either 'on' or 'off'\n";
365
        # this is safer
366
        return 1;
367
    }
368
}
369

    
370
sub project_repo_is_readonly {
371
    my $dbh = shift;
372
    my $project_id = shift;
373
    my $r = shift;
374

    
375
    if (!defined $project_id or $project_id eq '') {
376
        return 0; # nonexistent
377
    }
378

    
379
    my $sth = $dbh->prepare(
380
        "SELECT repositories.is_external FROM repositories, projects WHERE projects.identifier = ? AND repositories.project_id = projects.id;"
381
    );
382

    
383
    $sth->execute($project_id);
384
    my $ret = 0; # nonexistent
385
    if (my @row = $sth->fetchrow_array) {
386
        if (defined($row[0]) && ($row[0] eq "1" || $row[0] eq "t")) {
387
            $ret = 1; # read-only (i.e. external)
388
        } else {
389
            $ret = 0; # read-write
390
        }
391
    }
392
    $sth->finish();
393
    undef $sth;
394

    
395
    $ret;
396
}
397

    
398
sub is_permitted {
399
    my $dbh = shift;
400
    my $project_id = shift;
401
    my $redmine_user = shift;
402
    my $redmine_pass = shift;
403
    my $r = shift;
404

    
405
    my $pass_digest = Digest::SHA1::sha1_hex($redmine_pass);
406

    
407
    my $cfg = Apache2::Module::get_config
408
	(__PACKAGE__, $r->server, $r->per_dir_config);
409

    
410
    my $query = $cfg->{SoundSoftwareQuery};
411
    my $sth = $dbh->prepare($query);
412
    $sth->execute($redmine_user, $project_id);
413

    
414
    my $ret;
415
    while (my ($hashed_password, $salt, $auth_source_id, $permissions) = $sth->fetchrow_array) {
416

    
417
	# Test permissions for this user before we verify credentials
418
	# -- if the user is not permitted this action anyway, there's
419
	# not much point in e.g. contacting the LDAP
420

    
421
	my $method = $r->method;
422

    
423
	if ((defined $read_only_methods{$method} && $permissions =~ /:browse_repository/)
424
	    || $permissions =~ /:commit_access/) {
425

    
426
	    # User would be permitted this action, if their
427
	    # credentials checked out -- test those now
428

    
429
	    print STDERR "SoundSoftware.pm: User $redmine_user has required role, checking credentials\n";
430

    
431
	    unless ($auth_source_id) {
432
                my $salted_password = Digest::SHA1::sha1_hex($salt.$pass_digest);
433
		if ($hashed_password eq $salted_password) {
434
		    print STDERR "SoundSoftware.pm: User $redmine_user authenticated via password\n";
435
		    $ret = 1;
436
		    last;
437
		}
438
	    } else {
439
		my $sthldap = $dbh->prepare(
440
		    "SELECT host,port,tls,account,account_password,base_dn,attr_login FROM auth_sources WHERE id = ?;"
441
		    );
442
		$sthldap->execute($auth_source_id);
443
		while (my @rowldap = $sthldap->fetchrow_array) {
444
		    my $ldap = Authen::Simple::LDAP->new(
445
			host    => ($rowldap[2] eq "1" || $rowldap[2] eq "t") ? "ldaps://$rowldap[0]" : $rowldap[0],
446
			port    => $rowldap[1],
447
			basedn  => $rowldap[5],
448
			binddn  => $rowldap[3] ? $rowldap[3] : "",
449
			bindpw  => $rowldap[4] ? $rowldap[4] : "",
450
			filter  => "(".$rowldap[6]."=%s)"
451
			);
452
		    if ($ldap->authenticate($redmine_user, $redmine_pass)) {
453
			print STDERR "SoundSoftware.pm:$$: User $redmine_user authenticated via LDAP\n";
454
			$ret = 1;
455
		    }
456
		}
457
		$sthldap->finish();
458
		undef $sthldap;
459
                last if ($ret);
460
	    }
461
	} else {
462
	    print STDERR "SoundSoftware.pm:$$: User $redmine_user lacks required role for this project\n";
463
	}
464
    }
465

    
466
    $sth->finish();
467
    undef $sth;
468

    
469
    $ret;
470
}
471

    
472
sub get_project_identifier {
473
    my $dbh = shift;
474
    my $r = shift;
475
    my $location = $r->location;
476
    my ($repo) = $r->uri =~ m{$location/*([^/]*)};
477

    
478
    return $repo if (!$repo);
479

    
480
    $repo =~ s/[^a-zA-Z0-9\._-]//g;
481
    
482
    # The original Redmine.pm returns the string just calculated as
483
    # the project identifier.  That won't do for us -- we may have
484
    # (and in fact already do have, in our test instance) projects
485
    # whose repository names differ from the project identifiers.
486

    
487
    # This is a rather fundamental change because it means that almost
488
    # every request needs more than one database query -- which
489
    # prompts us to start passing around $dbh instead of connecting
490
    # locally within each function as is done in Redmine.pm.
491

    
492
    my $sth = $dbh->prepare(
493
        "SELECT projects.identifier FROM projects, repositories WHERE repositories.project_id = projects.id AND repositories.url LIKE ?;"
494
    );
495

    
496
    my $cfg = Apache2::Module::get_config
497
	(__PACKAGE__, $r->server, $r->per_dir_config);
498

    
499
    my $prefix = $cfg->{SoundSoftwareRepoPrefix};
500
    if (!defined $prefix) { $prefix = '%/'; }
501
    my $identifier = '';
502

    
503
    $sth->execute($prefix . $repo);
504
    my $ret = 0;
505
    if (my @row = $sth->fetchrow_array) {
506
	$identifier = $row[0];
507
    }
508
    $sth->finish();
509
    undef $sth;
510

    
511
    print STDERR "SoundSoftware.pm:$$: Repository '$repo' belongs to project '$identifier'\n";
512

    
513
    $identifier;
514
}
515

    
516
sub get_realm {
517
    my $dbh = shift;
518
    my $project_id = shift;
519
    my $r = shift;
520

    
521
    my $sth = $dbh->prepare(
522
        "SELECT projects.name FROM projects WHERE projects.identifier = ?;"
523
    );
524

    
525
    my $name = $project_id;
526

    
527
    $sth->execute($project_id);
528
    my $ret = 0;
529
    if (my @row = $sth->fetchrow_array) {
530
	$name = $row[0];
531
    }
532
    $sth->finish();
533
    undef $sth;
534

    
535
    # be timid about characters not permitted in auth realm and revert
536
    # to project identifier if any are found
537
    if ($name =~ m/[^\w\d\s\._-]/) {
538
	$name = $project_id;
539
    } elsif ($name =~ m/^\s*$/) {
540
	# empty or whitespace
541
	$name = $project_id;
542
    }
543
    
544
    if ($name =~ m/^\s*$/) {
545
        # nothing even in $project_id -- probably a nonexistent project.
546
        # use repo name instead (don't want to admit to user that project
547
        # doesn't exist)
548
        my $location = $r->location;
549
        my ($repo) = $r->uri =~ m{$location/*([^/]*)};
550
        $name = $repo;
551
    }
552

    
553
    my $realm = '"Mercurial repository for ' . "'$name'" . '"';
554

    
555
    $realm;
556
}
557

    
558
sub connect_database {
559
    my $r = shift;
560
    
561
    my $cfg = Apache2::Module::get_config
562
	(__PACKAGE__, $r->server, $r->per_dir_config);
563

    
564
    return DBI->connect($cfg->{SoundSoftwareDSN},
565
	                $cfg->{SoundSoftwareDbUser},
566
		        $cfg->{SoundSoftwareDbPass});
567
}
568

    
569
1;