comparison extra/soundsoftware/SoundSoftware.pm @ 247:73ff0e6a11b1 cannam

* Merge from branch cannam-pre-20110113-merge
author Chris Cannam
date Thu, 03 Mar 2011 12:11:53 +0000
parents 5da98461a9f6
children 034e9b00b341
comparison
equal deleted inserted replaced
246:eeebe205a056 247:73ff0e6a11b1
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 =head1 INSTALLATION
29
30 Debian/ubuntu:
31
32 apt-get install libapache-dbi-perl libapache2-mod-perl2 \
33 libdbd-mysql-perl libauthen-simple-ldap-perl libio-socket-ssl-perl
34
35 Note that LDAP support is hardcoded "on" in this script (it is
36 optional in the original Redmine.pm).
37
38 =head1 CONFIGURATION
39
40 ## This module has to be in your perl path
41 ## eg: /usr/local/lib/site_perl/Apache/Authn/SoundSoftware.pm
42 PerlLoadModule Apache::Authn::SoundSoftware
43
44 # Example when using hgwebdir
45 ScriptAlias / "/var/hg/hgwebdir.cgi/"
46
47 <Location />
48 AuthName "Mercurial"
49 AuthType Basic
50 Require valid-user
51 PerlAccessHandler Apache::Authn::SoundSoftware::access_handler
52 PerlAuthenHandler Apache::Authn::SoundSoftware::authen_handler
53 SoundSoftwareDSN "DBI:mysql:database=redmine;host=localhost"
54 SoundSoftwareDbUser "redmine"
55 SoundSoftwareDbPass "password"
56 Options +ExecCGI
57 AddHandler cgi-script .cgi
58 ## Optional where clause (fulltext search would be slow and
59 ## database dependant).
60 # SoundSoftwareDbWhereClause "and members.role_id IN (1,2)"
61 ## Optional prefix for local repository URLs
62 # SoundSoftwareRepoPrefix "/var/hg/"
63 </Location>
64
65 See the original Redmine.pm for further configuration notes.
66
67 =cut
68
69 use strict;
70 use warnings FATAL => 'all', NONFATAL => 'redefine';
71
72 use DBI;
73 use Digest::SHA1;
74 use Authen::Simple::LDAP;
75 use Apache2::Module;
76 use Apache2::Access;
77 use Apache2::ServerRec qw();
78 use Apache2::RequestRec qw();
79 use Apache2::RequestUtil qw();
80 use Apache2::Const qw(:common :override :cmd_how);
81 use APR::Pool ();
82 use APR::Table ();
83
84 my @directives = (
85 {
86 name => 'SoundSoftwareDSN',
87 req_override => OR_AUTHCFG,
88 args_how => TAKE1,
89 errmsg => 'Dsn in format used by Perl DBI. eg: "DBI:Pg:dbname=databasename;host=my.db.server"',
90 },
91 {
92 name => 'SoundSoftwareDbUser',
93 req_override => OR_AUTHCFG,
94 args_how => TAKE1,
95 },
96 {
97 name => 'SoundSoftwareDbPass',
98 req_override => OR_AUTHCFG,
99 args_how => TAKE1,
100 },
101 {
102 name => 'SoundSoftwareDbWhereClause',
103 req_override => OR_AUTHCFG,
104 args_how => TAKE1,
105 },
106 {
107 name => 'SoundSoftwareRepoPrefix',
108 req_override => OR_AUTHCFG,
109 args_how => TAKE1,
110 },
111 );
112
113 sub SoundSoftwareDSN {
114 my ($self, $parms, $arg) = @_;
115 $self->{SoundSoftwareDSN} = $arg;
116 my $query = "SELECT
117 hashed_password, auth_source_id, permissions
118 FROM members, projects, users, roles, member_roles
119 WHERE
120 projects.id=members.project_id
121 AND member_roles.member_id=members.id
122 AND users.id=members.user_id
123 AND roles.id=member_roles.role_id
124 AND users.status=1
125 AND login=?
126 AND identifier=? ";
127 $self->{SoundSoftwareQuery} = trim($query);
128 }
129
130 sub SoundSoftwareDbUser { set_val('SoundSoftwareDbUser', @_); }
131 sub SoundSoftwareDbPass { set_val('SoundSoftwareDbPass', @_); }
132 sub SoundSoftwareDbWhereClause {
133 my ($self, $parms, $arg) = @_;
134 $self->{SoundSoftwareQuery} = trim($self->{SoundSoftwareQuery}.($arg ? $arg : "")." ");
135 }
136
137 sub SoundSoftwareRepoPrefix {
138 my ($self, $parms, $arg) = @_;
139 if ($arg) {
140 $self->{SoundSoftwareRepoPrefix} = $arg;
141 }
142 }
143
144 sub trim {
145 my $string = shift;
146 $string =~ s/\s{2,}/ /g;
147 return $string;
148 }
149
150 sub set_val {
151 my ($key, $self, $parms, $arg) = @_;
152 $self->{$key} = $arg;
153 }
154
155 Apache2::Module::add(__PACKAGE__, \@directives);
156
157
158 my %read_only_methods = map { $_ => 1 } qw/GET PROPFIND REPORT OPTIONS/;
159
160 sub access_handler {
161 my $r = shift;
162
163 print STDERR "SoundSoftware.pm: In access handler at " . scalar localtime() . "\n";
164
165 unless ($r->some_auth_required) {
166 $r->log_reason("No authentication has been configured");
167 return FORBIDDEN;
168 }
169
170 my $method = $r->method;
171
172 print STDERR "SoundSoftware.pm: Method: $method, uri " . $r->uri . ", location " . $r->location . "\n";
173 print STDERR "SoundSoftware.pm: Accept: " . $r->headers_in->{Accept} . "\n";
174
175 if (!defined $read_only_methods{$method}) {
176 print STDERR "SoundSoftware.pm: Method is not read-only, authentication handler required\n";
177 return OK;
178 }
179
180 my $dbh = connect_database($r);
181 unless ($dbh) {
182 print STDERR "SoundSoftware.pm: Database connection failed!: " . $DBI::errstr . "\n";
183 return FORBIDDEN;
184 }
185
186
187 print STDERR "Connected to db, dbh is " . $dbh . "\n";
188
189 my $project_id = get_project_identifier($dbh, $r);
190 my $status = get_project_status($dbh, $project_id, $r);
191
192 $dbh->disconnect();
193 undef $dbh;
194
195 if ($status == 0) { # nonexistent
196 print STDERR "SoundSoftware.pm: Project does not exist, refusing access\n";
197 return FORBIDDEN;
198 } elsif ($status == 1) { # public
199 print STDERR "SoundSoftware.pm: Project is public, no restriction here\n";
200 $r->set_handlers(PerlAuthenHandler => [\&OK])
201 } else { # private
202 print STDERR "SoundSoftware.pm: Project is private, authentication handler required\n";
203 }
204
205 return OK
206 }
207
208 sub authen_handler {
209 my $r = shift;
210
211 print STDERR "SoundSoftware.pm: In authentication handler at " . scalar localtime() . "\n";
212
213 my $dbh = connect_database($r);
214 unless ($dbh) {
215 print STDERR "SoundSoftware.pm: Database connection failed!: " . $DBI::errstr . "\n";
216 return AUTH_REQUIRED;
217 }
218
219 my $project_id = get_project_identifier($dbh, $r);
220 my $realm = get_realm($dbh, $project_id, $r);
221 $r->auth_name($realm);
222
223 my ($res, $redmine_pass) = $r->get_basic_auth_pw();
224 unless ($res == OK) {
225 $dbh->disconnect();
226 undef $dbh;
227 return $res;
228 }
229
230 print STDERR "SoundSoftware.pm: User is " . $r->user . ", got password\n";
231
232 my $permitted = is_permitted($dbh, $project_id, $r->user, $redmine_pass, $r);
233
234 $dbh->disconnect();
235 undef $dbh;
236
237 if ($permitted) {
238 return OK;
239 } else {
240 print STDERR "SoundSoftware.pm: Not permitted\n";
241 $r->note_auth_failure();
242 return AUTH_REQUIRED;
243 }
244 }
245
246 sub get_project_status {
247 my $dbh = shift;
248 my $project_id = shift;
249 my $r = shift;
250
251 if (!defined $project_id or $project_id eq '') {
252 return 0; # nonexistent
253 }
254
255 my $sth = $dbh->prepare(
256 "SELECT is_public FROM projects WHERE projects.identifier = ?;"
257 );
258
259 $sth->execute($project_id);
260 my $ret = 0; # nonexistent
261 if (my @row = $sth->fetchrow_array) {
262 if ($row[0] eq "1" || $row[0] eq "t") {
263 $ret = 1; # public
264 } else {
265 $ret = 2; # private
266 }
267 }
268 $sth->finish();
269 undef $sth;
270
271 $ret;
272 }
273
274 sub is_permitted {
275 my $dbh = shift;
276 my $project_id = shift;
277 my $redmine_user = shift;
278 my $redmine_pass = shift;
279 my $r = shift;
280
281 my $pass_digest = Digest::SHA1::sha1_hex($redmine_pass);
282
283 my $cfg = Apache2::Module::get_config
284 (__PACKAGE__, $r->server, $r->per_dir_config);
285
286 my $query = $cfg->{SoundSoftwareQuery};
287 my $sth = $dbh->prepare($query);
288 $sth->execute($redmine_user, $project_id);
289
290 my $ret;
291 while (my ($hashed_password, $auth_source_id, $permissions) = $sth->fetchrow_array) {
292
293 # Test permissions for this user before we verify credentials
294 # -- if the user is not permitted this action anyway, there's
295 # not much point in e.g. contacting the LDAP
296
297 my $method = $r->method;
298
299 if ((defined $read_only_methods{$method} && $permissions =~ /:browse_repository/)
300 || $permissions =~ /:commit_access/) {
301
302 # User would be permitted this action, if their
303 # credentials checked out -- test those now
304
305 print STDERR "SoundSoftware.pm: User $redmine_user has required role, checking credentials\n";
306
307 unless ($auth_source_id) {
308 if ($hashed_password eq $pass_digest) {
309 print STDERR "SoundSoftware.pm: User $redmine_user authenticated via password\n";
310 $ret = 1;
311 last;
312 }
313 } else {
314 my $sthldap = $dbh->prepare(
315 "SELECT host,port,tls,account,account_password,base_dn,attr_login FROM auth_sources WHERE id = ?;"
316 );
317 $sthldap->execute($auth_source_id);
318 while (my @rowldap = $sthldap->fetchrow_array) {
319 my $ldap = Authen::Simple::LDAP->new(
320 host => ($rowldap[2] eq "1" || $rowldap[2] eq "t") ? "ldaps://$rowldap[0]" : $rowldap[0],
321 port => $rowldap[1],
322 basedn => $rowldap[5],
323 binddn => $rowldap[3] ? $rowldap[3] : "",
324 bindpw => $rowldap[4] ? $rowldap[4] : "",
325 filter => "(".$rowldap[6]."=%s)"
326 );
327 if ($ldap->authenticate($redmine_user, $redmine_pass)) {
328 print STDERR "SoundSoftware.pm: User $redmine_user authenticated via LDAP\n";
329 $ret = 1;
330 }
331 }
332 $sthldap->finish();
333 undef $sthldap;
334 }
335 } else {
336 print STDERR "SoundSoftware.pm: User $redmine_user lacks required role for this project\n";
337 }
338 }
339
340 $sth->finish();
341 undef $sth;
342
343 $ret;
344 }
345
346 sub get_project_identifier {
347 my $dbh = shift;
348 my $r = shift;
349
350 my $location = $r->location;
351 my ($repo) = $r->uri =~ m{$location/*([^/]+)};
352
353 return $repo if (!$repo);
354
355 $repo =~ s/[^a-zA-Z0-9\._-]//g;
356
357 # The original Redmine.pm returns the string just calculated as
358 # the project identifier. That won't do for us -- we may have
359 # (and in fact already do have, in our test instance) projects
360 # whose repository names differ from the project identifiers.
361
362 # This is a rather fundamental change because it means that almost
363 # every request needs more than one database query -- which
364 # prompts us to start passing around $dbh instead of connecting
365 # locally within each function as is done in Redmine.pm.
366
367 my $sth = $dbh->prepare(
368 "SELECT projects.identifier FROM projects, repositories WHERE repositories.project_id = projects.id AND repositories.url LIKE ?;"
369 );
370
371 my $cfg = Apache2::Module::get_config
372 (__PACKAGE__, $r->server, $r->per_dir_config);
373
374 my $prefix = $cfg->{SoundSoftwareRepoPrefix};
375 if (!defined $prefix) { $prefix = '%/'; }
376
377 my $identifier = '';
378
379 $sth->execute($prefix . $repo);
380 my $ret = 0;
381 if (my @row = $sth->fetchrow_array) {
382 $identifier = $row[0];
383 }
384 $sth->finish();
385 undef $sth;
386
387 print STDERR "SoundSoftware.pm: Repository '$repo' belongs to project '$identifier'\n";
388
389 $identifier;
390 }
391
392 sub get_realm {
393 my $dbh = shift;
394 my $project_id = shift;
395 my $r = shift;
396
397 my $sth = $dbh->prepare(
398 "SELECT projects.name FROM projects WHERE projects.identifier = ?;"
399 );
400
401 my $name = $project_id;
402
403 $sth->execute($project_id);
404 my $ret = 0;
405 if (my @row = $sth->fetchrow_array) {
406 $name = $row[0];
407 }
408 $sth->finish();
409 undef $sth;
410
411 # be timid about characters not permitted in auth realm and revert
412 # to project identifier if any are found
413 if ($name =~ m/[^\w\d\s\._-]/) {
414 $name = $project_id;
415 }
416
417 my $realm = '"Mercurial repository for ' . "'$name'" . '"';
418
419 $realm;
420 }
421
422 sub connect_database {
423 my $r = shift;
424
425 my $cfg = Apache2::Module::get_config
426 (__PACKAGE__, $r->server, $r->per_dir_config);
427
428 return DBI->connect($cfg->{SoundSoftwareDSN},
429 $cfg->{SoundSoftwareDbUser},
430 $cfg->{SoundSoftwareDbPass});
431 }
432
433 1;