comparison extra/svn/SoundSoftware.pm @ 8:0c83d98252d9 yuya

* Add custom repo prefix and proper auth realm, remove auth cache (seems like an unwise feature), pass DB handle around, various other bits of tidying
author Chris Cannam
date Thu, 12 Aug 2010 15:31:37 +0100
parents 3c16ed8faa07
children 2b5c13a9425f 2c10dc5f122d
comparison
equal deleted inserted replaced
7:3c16ed8faa07 8:0c83d98252d9
56 Options +ExecCGI 56 Options +ExecCGI
57 AddHandler cgi-script .cgi 57 AddHandler cgi-script .cgi
58 ## Optional where clause (fulltext search would be slow and 58 ## Optional where clause (fulltext search would be slow and
59 ## database dependant). 59 ## database dependant).
60 # SoundSoftwareDbWhereClause "and members.role_id IN (1,2)" 60 # SoundSoftwareDbWhereClause "and members.role_id IN (1,2)"
61 ## Optional credentials cache size 61 ## Optional prefix for local repository URLs
62 # SoundSoftwareCacheCredsMax 50 62 # SoundSoftwareRepoPrefix "/var/hg/"
63 </Location> 63 </Location>
64 64
65 See the original Redmine.pm for further configuration notes. 65 See the original Redmine.pm for further configuration notes.
66 66
67 =cut 67 =cut
78 use Apache2::RequestRec qw(); 78 use Apache2::RequestRec qw();
79 use Apache2::RequestUtil qw(); 79 use Apache2::RequestUtil qw();
80 use Apache2::Const qw(:common :override :cmd_how); 80 use Apache2::Const qw(:common :override :cmd_how);
81 use APR::Pool (); 81 use APR::Pool ();
82 use APR::Table (); 82 use APR::Table ();
83
84 # use Apache2::Directive qw();
85 83
86 my @directives = ( 84 my @directives = (
87 { 85 {
88 name => 'SoundSoftwareDSN', 86 name => 'SoundSoftwareDSN',
89 req_override => OR_AUTHCFG, 87 req_override => OR_AUTHCFG,
104 name => 'SoundSoftwareDbWhereClause', 102 name => 'SoundSoftwareDbWhereClause',
105 req_override => OR_AUTHCFG, 103 req_override => OR_AUTHCFG,
106 args_how => TAKE1, 104 args_how => TAKE1,
107 }, 105 },
108 { 106 {
109 name => 'SoundSoftwareCacheCredsMax', 107 name => 'SoundSoftwareRepoPrefix',
110 req_override => OR_AUTHCFG, 108 req_override => OR_AUTHCFG,
111 args_how => TAKE1, 109 args_how => TAKE1,
112 errmsg => 'SoundSoftwareCacheCredsMax must be decimal number',
113 }, 110 },
114 ); 111 );
115 112
116 sub SoundSoftwareDSN { 113 sub SoundSoftwareDSN {
117 my ($self, $parms, $arg) = @_; 114 my ($self, $parms, $arg) = @_;
118 $self->{SoundSoftwareDSN} = $arg; 115 $self->{SoundSoftwareDSN} = $arg;
119 my $query = "SELECT 116 my $query = "SELECT
120 hashed_password, auth_source_id, permissions 117 hashed_password, auth_source_id, permissions
121 FROM members, projects, users, roles, member_roles 118 FROM members, projects, users, roles, member_roles
122 WHERE 119 WHERE
123 projects.id=members.project_id 120 projects.id=members.project_id
124 AND member_roles.member_id=members.id 121 AND member_roles.member_id=members.id
125 AND users.id=members.user_id 122 AND users.id=members.user_id
126 AND roles.id=member_roles.role_id 123 AND roles.id=member_roles.role_id
127 AND users.status=1 124 AND users.status=1
128 AND login=? 125 AND login=?
129 AND identifier=? "; 126 AND identifier=? ";
130 $self->{SoundSoftwareQuery} = trim($query); 127 $self->{SoundSoftwareQuery} = trim($query);
131 } 128 }
132 129
133 sub SoundSoftwareDbUser { set_val('SoundSoftwareDbUser', @_); } 130 sub SoundSoftwareDbUser { set_val('SoundSoftwareDbUser', @_); }
134 sub SoundSoftwareDbPass { set_val('SoundSoftwareDbPass', @_); } 131 sub SoundSoftwareDbPass { set_val('SoundSoftwareDbPass', @_); }
135 sub SoundSoftwareDbWhereClause { 132 sub SoundSoftwareDbWhereClause {
136 my ($self, $parms, $arg) = @_; 133 my ($self, $parms, $arg) = @_;
137 $self->{SoundSoftwareQuery} = trim($self->{SoundSoftwareQuery}.($arg ? $arg : "")." "); 134 $self->{SoundSoftwareQuery} = trim($self->{SoundSoftwareQuery}.($arg ? $arg : "")." ");
138 } 135 }
139 136
140 sub SoundSoftwareCacheCredsMax { 137 sub SoundSoftwareRepoPrefix {
141 my ($self, $parms, $arg) = @_; 138 my ($self, $parms, $arg) = @_;
142 if ($arg) { 139 if ($arg) {
143 $self->{SoundSoftwareCachePool} = APR::Pool->new; 140 $self->{SoundSoftwareRepoPrefix} = $arg;
144 $self->{SoundSoftwareCacheCreds} = APR::Table::make($self->{SoundSoftwareCachePool}, $arg); 141 }
145 $self->{SoundSoftwareCacheCredsCount} = 0;
146 $self->{SoundSoftwareCacheCredsMax} = $arg;
147 }
148 } 142 }
149 143
150 sub trim { 144 sub trim {
151 my $string = shift; 145 my $string = shift;
152 $string =~ s/\s{2,}/ /g; 146 $string =~ s/\s{2,}/ /g;
153 return $string; 147 return $string;
154 } 148 }
155 149
156 sub set_val { 150 sub set_val {
157 my ($key, $self, $parms, $arg) = @_; 151 my ($key, $self, $parms, $arg) = @_;
158 $self->{$key} = $arg; 152 $self->{$key} = $arg;
159 } 153 }
160 154
161 Apache2::Module::add(__PACKAGE__, \@directives); 155 Apache2::Module::add(__PACKAGE__, \@directives);
162 156
163 157
164 my %read_only_methods = map { $_ => 1 } qw/GET PROPFIND REPORT OPTIONS/; 158 my %read_only_methods = map { $_ => 1 } qw/GET PROPFIND REPORT OPTIONS/;
165 159
166 sub access_handler { 160 sub access_handler {
167 my $r = shift; 161 my $r = shift;
168 162
169 print STDERR "SoundSoftware.pm: In access handler\n"; 163 print STDERR "SoundSoftware.pm: In access handler\n";
170 164
171 unless ($r->some_auth_required) { 165 unless ($r->some_auth_required) {
172 $r->log_reason("No authentication has been configured"); 166 $r->log_reason("No authentication has been configured");
173 return FORBIDDEN; 167 return FORBIDDEN;
174 } 168 }
175 169
176 my $method = $r->method; 170 my $method = $r->method;
177 171
178 print STDERR "SoundSoftware.pm: Method: $method, uri " . $r->uri . ", location " . $r->location . "\n"; 172 print STDERR "SoundSoftware.pm: Method: $method, uri " . $r->uri . ", location " . $r->location . "\n";
179 173 print STDERR "SoundSoftware.pm: Accept: " . $r->headers_in->{Accept} . "\n";
180 if (!defined $read_only_methods{$method}) { 174
181 print STDERR "SoundSoftware.pm: Method is not read-only, authentication handler required\n"; 175 if (!defined $read_only_methods{$method}) {
182 return OK; 176 print STDERR "SoundSoftware.pm: Method is not read-only, authentication handler required\n";
183 } 177 return OK;
184 178 }
185 my $project_id = get_project_identifier($r); 179
186 180 my $dbh = connect_database($r);
187 if (defined $project_id) { 181
188 print STDERR "SoundSoftware.pm: Project: $project_id\n"; 182 my $project_id = get_project_identifier($dbh, $r);
189 } else { 183 my $status = get_project_status($dbh, $project_id, $r);
190 print STDERR "SoundSoftware.pm: No project identifier available, refusing access\n"; 184
191 return FORBIDDEN; 185 $dbh->disconnect();
192 } 186 undef $dbh;
193 187
194 my $status = get_project_status($project_id, $r); 188 if ($status == 0) { # nonexistent
195 189 print STDERR "SoundSoftware.pm: Project does not exist, refusing access\n";
196 if ($status == 0) { # nonexistent 190 return FORBIDDEN;
197 print STDERR "SoundSoftware.pm: Project does not exist, refusing access\n"; 191 } elsif ($status == 1) { # public
198 return FORBIDDEN; 192 print STDERR "SoundSoftware.pm: Project is public, no restriction here\n";
199 } elsif ($status == 1) { # public 193 $r->set_handlers(PerlAuthenHandler => [\&OK])
200 print STDERR "SoundSoftware.pm: Project is public, no restriction here\n"; 194 } else { # private
201 $r->set_handlers(PerlAuthenHandler => [\&OK]) 195 print STDERR "SoundSoftware.pm: Project is private, authentication handler required\n";
202 } else { # private 196 }
203 print STDERR "SoundSoftware.pm: Project is not public, authentication handler required\n"; 197
204 } 198 return OK
205
206 return OK
207 } 199 }
208 200
209 sub authen_handler { 201 sub authen_handler {
210 my $r = shift; 202 my $r = shift;
211 203
212 print STDERR "SoundSoftware.pm: In authentication handler\n"; 204 print STDERR "SoundSoftware.pm: In authentication handler\n";
213 205
214 my ($res, $redmine_pass) = $r->get_basic_auth_pw(); 206 my $dbh = connect_database($r);
215 return $res unless $res == OK; 207
216 208 my $project_id = get_project_identifier($dbh, $r);
217 print STDERR "SoundSoftware.pm: User is " . $r->user . ", got password\n"; 209 my $realm = get_realm($dbh, $project_id, $r);
218 210 $r->auth_name($realm);
219 if (is_member($r->user, $redmine_pass, $r)) { 211
220 return OK; 212 my ($res, $redmine_pass) = $r->get_basic_auth_pw();
221 } else { 213 unless ($res == OK) {
222 print STDERR "SoundSoftware.pm: Failed to validate project membership\n"; 214 $dbh->disconnect();
223 $r->note_auth_failure(); 215 undef $dbh;
224 return AUTH_REQUIRED; 216 return $res;
225 } 217 }
218
219 print STDERR "SoundSoftware.pm: User is " . $r->user . ", got password\n";
220
221 my $permitted = is_permitted($dbh, $project_id, $r->user, $redmine_pass, $r);
222
223 $dbh->disconnect();
224 undef $dbh;
225
226 if ($permitted) {
227 return OK;
228 } else {
229 print STDERR "SoundSoftware.pm: Not permitted\n";
230 $r->note_auth_failure();
231 return AUTH_REQUIRED;
232 }
226 } 233 }
227 234
228 sub get_project_status { 235 sub get_project_status {
236 my $dbh = shift;
229 my $project_id = shift; 237 my $project_id = shift;
230 my $r = shift; 238 my $r = shift;
231 239
232 my $dbh = connect_database($r); 240 if (!defined $project_id or $project_id eq '') {
241 return 0; # nonexistent
242 }
243
233 my $sth = $dbh->prepare( 244 my $sth = $dbh->prepare(
234 "SELECT is_public FROM projects WHERE projects.identifier = ?;" 245 "SELECT is_public FROM projects WHERE projects.identifier = ?;"
235 ); 246 );
236 247
237 $sth->execute($project_id); 248 $sth->execute($project_id);
238 my $ret = 0; 249 my $ret = 0; # nonexistent
239 if (my @row = $sth->fetchrow_array) { 250 if (my @row = $sth->fetchrow_array) {
240 if ($row[0] eq "1" || $row[0] eq "t") { 251 if ($row[0] eq "1" || $row[0] eq "t") {
241 $ret = 1; # public 252 $ret = 1; # public
242 } else { 253 } else {
243 $ret = 2; # private (0 means nonexistent) 254 $ret = 2; # private
244 } 255 }
245 } 256 }
246 $sth->finish(); 257 $sth->finish();
247 undef $sth; 258 undef $sth;
248 $dbh->disconnect();
249 undef $dbh;
250 259
251 $ret; 260 $ret;
252 } 261 }
253 262
254 sub is_member { 263 sub is_permitted {
255 my $redmine_user = shift; 264 my $dbh = shift;
256 my $redmine_pass = shift; 265 my $project_id = shift;
257 my $r = shift; 266 my $redmine_user = shift;
258 267 my $redmine_pass = shift;
259 my $dbh = connect_database($r); 268 my $r = shift;
260 my $project_id = get_project_identifier($r); 269
261 270 my $pass_digest = Digest::SHA1::sha1_hex($redmine_pass);
262 my $pass_digest = Digest::SHA1::sha1_hex($redmine_pass); 271
263 272 my $cfg = Apache2::Module::get_config
264 my $cfg = Apache2::Module::get_config(__PACKAGE__, $r->server, $r->per_dir_config); 273 (__PACKAGE__, $r->server, $r->per_dir_config);
265 my $usrprojpass; 274
266 if ($cfg->{SoundSoftwareCacheCredsMax}) { 275 my $query = $cfg->{SoundSoftwareQuery};
267 $usrprojpass = $cfg->{SoundSoftwareCacheCreds}->get($redmine_user.":".$project_id); 276 my $sth = $dbh->prepare($query);
268 return 1 if (defined $usrprojpass and ($usrprojpass eq $pass_digest)); 277 $sth->execute($redmine_user, $project_id);
269 } 278
270 my $query = $cfg->{SoundSoftwareQuery}; 279 my $ret;
271 my $sth = $dbh->prepare($query); 280 while (my ($hashed_password, $auth_source_id, $permissions) = $sth->fetchrow_array) {
272 $sth->execute($redmine_user, $project_id); 281
273 282 # Test permissions for this user before we verify credentials
274 my $ret; 283 # -- if the user is not permitted this action anyway, there's
275 while (my ($hashed_password, $auth_source_id, $permissions) = $sth->fetchrow_array) { 284 # not much point in e.g. contacting the LDAP
276 285
277 unless ($auth_source_id) { 286 my $method = $r->method;
278 my $method = $r->method; 287
279 if ($hashed_password eq $pass_digest && ((defined $read_only_methods{$method} && $permissions =~ /:browse_repository/) || $permissions =~ /:commit_access/) ) { 288 if ((defined $read_only_methods{$method} && $permissions =~ /:browse_repository/)
280 $ret = 1; 289 || $permissions =~ /:commit_access/) {
281 last; 290
282 } 291 # User would be permitted this action, if their
283 } else { 292 # credentials checked out -- test those now
284 my $sthldap = $dbh->prepare( 293
285 "SELECT host,port,tls,account,account_password,base_dn,attr_login from auth_sources WHERE id = ?;" 294 print STDERR "SoundSoftware.pm: User $redmine_user has required role, checking credentials\n";
286 ); 295
287 $sthldap->execute($auth_source_id); 296 unless ($auth_source_id) {
288 while (my @rowldap = $sthldap->fetchrow_array) { 297 if ($hashed_password eq $pass_digest) {
289 my $ldap = Authen::Simple::LDAP->new( 298 print STDERR "SoundSoftware.pm: User $redmine_user authenticated via password\n";
290 host => ($rowldap[2] eq "1" || $rowldap[2] eq "t") ? "ldaps://$rowldap[0]" : $rowldap[0], 299 $ret = 1;
291 port => $rowldap[1], 300 last;
292 basedn => $rowldap[5], 301 }
293 binddn => $rowldap[3] ? $rowldap[3] : "", 302 } else {
294 bindpw => $rowldap[4] ? $rowldap[4] : "", 303 my $sthldap = $dbh->prepare(
295 filter => "(".$rowldap[6]."=%s)" 304 "SELECT host,port,tls,account,account_password,base_dn,attr_login FROM auth_sources WHERE id = ?;"
296 ); 305 );
297 my $method = $r->method; 306 $sthldap->execute($auth_source_id);
298 $ret = 1 if ($ldap->authenticate($redmine_user, $redmine_pass) && ((defined $read_only_methods{$method} && $permissions =~ /:browse_repository/) || $permissions =~ /:commit_access/)); 307 while (my @rowldap = $sthldap->fetchrow_array) {
299 308 my $ldap = Authen::Simple::LDAP->new(
300 } 309 host => ($rowldap[2] eq "1" || $rowldap[2] eq "t") ? "ldaps://$rowldap[0]" : $rowldap[0],
301 $sthldap->finish(); 310 port => $rowldap[1],
302 undef $sthldap; 311 basedn => $rowldap[5],
303 } 312 binddn => $rowldap[3] ? $rowldap[3] : "",
304 } 313 bindpw => $rowldap[4] ? $rowldap[4] : "",
305 $sth->finish(); 314 filter => "(".$rowldap[6]."=%s)"
306 undef $sth; 315 );
307 $dbh->disconnect(); 316 if ($ldap->authenticate($redmine_user, $redmine_pass)) {
308 undef $dbh; 317 print STDERR "SoundSoftware.pm: User $redmine_user authenticated via LDAP\n";
309 318 $ret = 1;
310 if ($cfg->{SoundSoftwareCacheCredsMax} and $ret) { 319 }
311 if (defined $usrprojpass) { 320 }
312 $cfg->{SoundSoftwareCacheCreds}->set($redmine_user.":".$project_id, $pass_digest); 321 $sthldap->finish();
313 } else { 322 undef $sthldap;
314 if ($cfg->{SoundSoftwareCacheCredsCount} < $cfg->{SoundSoftwareCacheCredsMax}) { 323 }
315 $cfg->{SoundSoftwareCacheCreds}->set($redmine_user.":".$project_id, $pass_digest); 324 } else {
316 $cfg->{SoundSoftwareCacheCredsCount}++; 325 print STDERR "SoundSoftware.pm: User $redmine_user lacks required role for this project\n";
317 } else { 326 }
318 $cfg->{SoundSoftwareCacheCreds}->clear(); 327 }
319 $cfg->{SoundSoftwareCacheCredsCount} = 0; 328
320 } 329 $sth->finish();
321 } 330 undef $sth;
322 } 331
323 332 $ret;
324 $ret;
325 } 333 }
326 334
327 sub get_project_identifier { 335 sub get_project_identifier {
336 my $dbh = shift;
328 my $r = shift; 337 my $r = shift;
329 338
330 my $location = $r->location; 339 my $location = $r->location;
331 my ($repo) = $r->uri =~ m{$location/*([^/]+)}; 340 my ($repo) = $r->uri =~ m{$location/*([^/]+)};
332 $repo =~ s/[^a-zA-Z0-9\._-]//g; 341 $repo =~ s/[^a-zA-Z0-9\._-]//g;
333 342
334 my $dbh = connect_database($r); 343 # The original Redmine.pm returns the string just calculated as
344 # the project identifier. That won't do for us -- we may have
345 # (and in fact already do have, in our test instance) projects
346 # whose repository names differ from the project identifiers.
347
348 # This is a rather fundamental change because it means that almost
349 # every request needs more than one database query -- which
350 # prompts us to start passing around $dbh instead of connecting
351 # locally within each function as is done in Redmine.pm.
352
335 my $sth = $dbh->prepare( 353 my $sth = $dbh->prepare(
336 "SELECT projects.identifier FROM projects, repositories WHERE repositories.project_id = projects.id AND repositories.url LIKE ?;" 354 "SELECT projects.identifier FROM projects, repositories WHERE repositories.project_id = projects.id AND repositories.url LIKE ?;"
337 ); 355 );
338 356
357 my $cfg = Apache2::Module::get_config
358 (__PACKAGE__, $r->server, $r->per_dir_config);
359
360 my $prefix = $cfg->{SoundSoftwareRepoPrefix};
361 if (!defined $prefix) { $prefix = '%/'; }
362
339 my $identifier = ''; 363 my $identifier = '';
340 364
341 $sth->execute('%/' . $repo); 365 $sth->execute($prefix . $repo);
342 my $ret = 0; 366 my $ret = 0;
343 if (my @row = $sth->fetchrow_array) { 367 if (my @row = $sth->fetchrow_array) {
344 $identifier = $row[0]; 368 $identifier = $row[0];
345 } 369 }
346 $sth->finish(); 370 $sth->finish();
347 undef $sth; 371 undef $sth;
348 $dbh->disconnect(); 372
349 undef $dbh; 373 print STDERR "SoundSoftware.pm: Repository '$repo' belongs to project '$identifier'\n";
350
351 print STDERR "SoundSoftware.pm: Repository $repo belongs to project $identifier\n";
352 374
353 $identifier; 375 $identifier;
354 } 376 }
355 377
378 sub get_realm {
379 my $dbh = shift;
380 my $project_id = shift;
381 my $r = shift;
382
383 my $sth = $dbh->prepare(
384 "SELECT projects.name FROM projects WHERE projects.identifier = ?;"
385 );
386
387 my $name = $project_id;
388
389 $sth->execute($project_id);
390 my $ret = 0;
391 if (my @row = $sth->fetchrow_array) {
392 $name = $row[0];
393 }
394 $sth->finish();
395 undef $sth;
396
397 # be timid about characters not permitted in auth realm and revert
398 # to project identifier if any are found
399 if ($name =~ m/[^\w\d\s\._-]/) {
400 $name = $project_id;
401 }
402
403 my $realm = '"Mercurial repository for ' . "'$name'" . '"';
404
405 $realm;
406 }
407
356 sub connect_database { 408 sub connect_database {
357 my $r = shift; 409 my $r = shift;
358 410
359 my $cfg = Apache2::Module::get_config(__PACKAGE__, $r->server, $r->per_dir_config); 411 my $cfg = Apache2::Module::get_config
360 return DBI->connect($cfg->{SoundSoftwareDSN}, $cfg->{SoundSoftwareDbUser}, $cfg->{SoundSoftwareDbPass}); 412 (__PACKAGE__, $r->server, $r->per_dir_config);
413
414 return DBI->connect($cfg->{SoundSoftwareDSN},
415 $cfg->{SoundSoftwareDbUser},
416 $cfg->{SoundSoftwareDbPass});
361 } 417 }
362 418
363 1; 419 1;