From a47ee921ecd6a8d36b08cd9031ea62844cb92121 Mon Sep 17 00:00:00 2001 From: Adirelle Date: Sun, 13 Nov 2011 10:45:04 +0100 Subject: [PATCH] Lots of entangled changes... --- Redmine.pm | 287 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 163 insertions(+), 124 deletions(-) diff --git a/Redmine.pm b/Redmine.pm index 9fc2ce4..8db3439 100644 --- a/Redmine.pm +++ b/Redmine.pm @@ -49,7 +49,7 @@ Authen::Simple::LDAP (and IO::Socket::SSL if LDAPS is used): PerlAuthenHandler Apache::Authn::Redmine::authen_handler PerlAuthzHandler Apache::Authn::Redmine::authz_handler - + ## for mysql RedmineDSN "DBI:mysql:database=databasename;host=my.db.server" ## for postgres @@ -145,6 +145,18 @@ my @directives = ( req_override => OR_AUTHCFG, args_how => TAKE1, }, + { + name => 'RedmineReadPermissions', + req_override => OR_AUTHCFG, + args_how => ITERATE, + errmsg => 'list of permissions to allow read access', + }, + { + name => 'RedmineWritePermissions', + req_override => OR_AUTHCFG, + args_how => ITERATE, + errmsg => 'list of permissions to allow other than read access', + }, { name => 'RedmineCacheCredsMax', req_override => OR_AUTHCFG, @@ -159,41 +171,51 @@ my @directives = ( }, ); -sub RedmineDSN { - my ($self, $parms, $arg) = @_; - $self->{RedmineDSN} = $arg; - my $query = "SELECT - hashed_password, salt, auth_source_id, permissions - FROM members, projects, users, roles, member_roles - WHERE - projects.id=members.project_id - AND member_roles.member_id=members.id - AND users.id=members.user_id - AND roles.id=member_roles.role_id - AND users.status=1 - AND login=? - AND identifier=? "; - $self->{RedmineQuery} = trim($query); +sub RedmineDSN { + my ($cfg, $parms, $arg) = @_; + $cfg->{DSN} = $arg; + $cfg->{Query} = trim(" + SELECT permissions FROM users, members, member_roles, roles + WHERE users.login = ? + AND users.id = members.user_id + AND users.status = 1 + AND members.project_id = ? + AND members.id = member_roles.member_id + AND member_roles.role_id = roles.id + "); } -sub RedmineDbUser { set_val('RedmineDbUser', @_); } -sub RedmineDbPass { set_val('RedmineDbPass', @_); } -sub RedmineDbWhereClause { - my ($self, $parms, $arg) = @_; - $self->{RedmineQuery} = trim($self->{RedmineQuery}.($arg ? $arg : "")." "); +sub RedmineDbUser { set_val('DbUser', @_); } +sub RedmineDbPass { set_val('DbPass', @_); } +sub RedmineCacheCredsMaxAge { set_val('CacheCredsMaxAge', @_); } +sub RedmineProject { set_val('Project', @_); } + +sub RedmineDbWhereClause { + my ($cfg, $parms, $arg) = @_; + $cfg->{Query} = trim($cfg->{Query}.($arg || "")." "); } -sub RedmineCacheCredsMax { - my ($self, $parms, $arg) = @_; +sub RedmineCacheCredsMax { + my ($cfg, $parms, $arg) = @_; if ($arg) { - $self->{RedmineCachePool} = APR::Pool->new; - $self->{RedmineCacheCreds} = APR::Table::make($self->{RedmineCachePool}, $arg); - $self->{RedmineCacheCredsCount} = 0; - $self->{RedmineCacheCredsMax} = $arg; + $cfg->{CachePool} = APR::Pool->new; + $cfg->{CacheCreds} = APR::Table::make($cfg->{CachePool}, $arg); + $cfg->{CacheCredsCount} = 0; + $cfg->{CacheCredsMax} = $arg; + $cfg->{CacheCredsMaxAge} ||= 300; } } -sub RedmineCacheCredsMaxAge { set_val('RedmineCacheCredsMaxAge', @_); } + +sub RedmineReadPermissions { + my ( $cfg, $parms, $arg ) = @_; + push @{ $cfg->{ReadPermissions} }, $arg; +} + +sub RedmineWritePermissions { + my ( $cfg, $parms, $arg ) = @_; + push @{ $cfg->{WritePermissions} }, $arg; +} sub trim { my $string = shift; @@ -208,11 +230,13 @@ sub set_val { Apache2::Module::add(__PACKAGE__, \@directives); -my %read_only_methods = map { $_ => ':browse_repository' } qw/GET PROPFIND REPORT OPTIONS/; +my %read_only_methods = map { $_ => 1 } qw/GET PROPFIND REPORT OPTIONS/; +my @default_read_permissions = ( ':browse_repository' ); +my @default_write_permissions = ( ':commit_access' ); sub authen_handler { my $r = shift; - + unless ($r->some_auth_required) { $r->log_reason("No authentication has been configured"); return FORBIDDEN; @@ -220,7 +244,7 @@ sub authen_handler { my ($res, $password) = $r->get_basic_auth_pw(); my $reason; - + if($res == OK) { # Got user and password @@ -229,17 +253,17 @@ sub authen_handler { if(cache_get($r, $cache_key)) { $r->log->debug("reusing cached credentials for user '", $r->user, "'"); $r->set_handlers(PerlAuthzHandler => undef); - + } else { # Else check them my $dbh = connect_database($r); ($res, $reason) = check_login($r, $dbh, $password); $dbh->disconnect(); - + # Store the cache key for latter use - $r->pnotes("RedmineCacheKey" => $cache_key) if $res == OK; + $r->pnotes("RedmineCacheKey" => $cache_key) if $res == OK; } - + } elsif($res == AUTH_REQUIRED) { my $dbh = connect_database($r); if(is_authentication_forced($dbh)) { @@ -250,11 +274,11 @@ sub authen_handler { $res = OK; } $dbh->disconnect(); - - } + + } $r->log_reason($reason) if defined($reason); - $r->note_basic_auth_failure unless $res == OK; + $r->note_basic_auth_failure unless $res == OK; return $res; } @@ -263,16 +287,16 @@ sub authen_handler { sub check_login { my ($r, $dbh, $password) = @_; my $user = $r->user; - - 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); - + + my ($hashed_password, $status, $auth_source_id, $salt) = $dbh->selectrow_arrayref('SELECT hashed_password, status, auth_source_id, salt FROM users WHERE login = ?', $user); + # Not found return (AUTH_REQUIRED, "unknown user '$user'") unless defined($hashed_password); - # Check password + # Check password if($auth_source_id) { # LDAP authentication - + # Ensure Authen::Simple::LDAP is available return (SERVER_ERROR, "Redmine LDAP authentication requires Authen::Simple::LDAP") unless $CanUseLDAPAuth; @@ -283,12 +307,12 @@ sub check_login { "SELECT host,port,tls,account,account_password,base_dn,attr_login from auth_sources WHERE id = ?", $auth_source_id ); - + # Check them return (SERVER_ERROR, "Undefined authentication source for '$user'") unless defined $host; - # Connect to the LDAP server + # Connect to the LDAP server my $ldap = Authen::Simple::LDAP->new( host => is_true($tls) ? "ldaps://$host:$port" : $host, port => $port, @@ -297,31 +321,31 @@ sub check_login { bindpw => $account_password || "", filter => '('.$attr_login.'=%s)' ); - + # Finally check user login return (AUTH_REQUIRED, "LDAP authentication failed (user: '$user', server: '$host')") unless $ldap->authenticate($user, $password); - - } else { + + } else { # Database authentication my $pass_digest = Digest::SHA1::sha1_hex($password); return (AUTH_REQUIRED, "wrong password for '$user'") unless $hashed_password eq Digest::SHA1::sha1_hex($salt.$pass_digest); } - - # Password is ok, check if account if locked + + # Password is ok, check if account if locked return (FORBIDDEN, "inactive account: '$user'") unless $status == 1; $r->log->debug("successfully authenticated as active redmine user '$user'"); - # Everything's ok + # Everything's ok return OK; } # check if authentication is forced sub is_authentication_forced { my $dbh = shift; - return is_true(query_fetch_first($dbh, "SELECT value FROM settings WHERE settings.name = 'login_required'")); + return is_true($dbh->selectrow_arrayref("SELECT value FROM settings WHERE settings.name = 'login_required'")); } sub authz_handler { @@ -332,19 +356,19 @@ sub authz_handler { return FORBIDDEN; } - my $dbh = connect_database($r); - + my $dbh = connect_database($r); + my ($identifier, $project_id, $is_public, $status) = get_project_data($r, $dbh); $is_public = is_true($is_public); my($res, $reason) = FORBIDDEN; - + unless(defined($project_id)) { # Unknown project $res = DECLINED; $reason = "not a redmine project"; - - } elsif($status ne 1 && !defined($read_only_methods{$r->method})) { + + } elsif($status != 1 && !is_read_request($r)) { # Write operation on archived project is forbidden $reason = "write operations on inactive project '$identifier' are forbidden"; @@ -352,41 +376,36 @@ sub authz_handler { # Anonymous access $res = AUTH_REQUIRED; $reason = "anonymous access to '$identifier' denied"; - + if($is_public) { # Check anonymous permissions - my $required = required_permission($r); - my ($id) = query_fetch_first($dbh, "SELECT id FROM roles WHERE builtin = 2 AND permissions LIKE ?", '%'.$required.'%'); - $res = OK if defined $id; + my ($permissions) = $dbh->selectrow_arrayref("SELECT permissions FROM roles WHERE builtin = 2"); + $res = OK if check_permissions($r, $permissions); } - + # Force login if failed $r->note_auth_failure unless $res == OK; - + } else { # Logged in user - my $required = required_permission($r); + my @permissions = (); my $user = $r->user; - - # Look for membership with required role - my($id) = query_fetch_first($dbh, q{ - SELECT roles.id FROM users, members, member_roles, roles - WHERE users.login = ? - AND users.id = members.user_id - AND members.project_id = ? - AND members.id = member_roles.member_id - AND member_roles.role_id = roles.id - AND roles.permissions LIKE ? - }, $user, $project_id, '%'.$required.'%'); - if(!defined($id) && $is_public) { - # Fallback to non-member role for public projects - $id = query_fetch_first($dbh, "SELECT id FROM roles WHERE builtin = 1 AND permissions LIKE ?", '%'.$required.'%'); + # Membership permissions + if(my @membership = $dbh->selectcol_arrayref($cfg->{Query}, $user, $project_id)) { + push @permissions, @membership; } - - if(defined($id)) { + + if($is_public) { + # Add non-member permissions for public projects + if(my ($non_member) = $dbh->selectrow_arrayref("SELECT permissions FROM roles WHERE builtin = 1")) { + push @permissions, $non_member; + } + } + + if(check_permissions($r, @permissions)) { $res = OK; - + my $cache_key = $r->pnotes("RedmineCacheKey"); cache_set($r, $cache_key) if defined $cache_key; @@ -395,64 +414,78 @@ sub authz_handler { } } - $r->log->debug("access granted: user '", ($r->user || 'anonymous'), "', project '$identifier', method: '", $r->method, "'") if $res == OK; + $r->log->debug("access granted: user '", ($r->user || 'anonymous'), "', project '$identifier', method: '", $r->method, "'") if $res == OK; $r->log_reason($reason) if $res != OK && defined $reason; - + return $res; } -# get project identifier +# get the project identifier sub get_project_identifier { - my $r = shift; - my $dbh = shift; - - my $location = $r->location; - my ($identifier) = $r->uri =~ m{^\Q$location\E/*([^/]+)}; + my ($r, $dbh) = @_; + + my $cfg = get_config($r); + my $identifier = $cfg->{Project}; + unless($identifier) { + my $location = $r->location; + ($identifier) = $r->uri =~ m{^\Q$location\E/*([^/]+)}; + } + return $identifier; } +# tell if the given request is a read operation +sub is_read_request { + my $r = shift; + + return defined $read_only_methods{$r->method}; +} + +# check if one of the required permissions is in the passed list +sub check_permissions { + my $r = shift; + + my $permissions = join(' ', @_) + or return 0; + + $cfg = get_config($r); + my @required; + if(is_read_request($r)) { + @required = $cfg->{ReadPermissions} || @default_read_permissions; + } else { + @required = $cfg->{WritePermissions} || @default_write_permissions; + } + + foreach (@required) { + return 1 if $permissions =~ m{\Q$_\E}; + } + + return 0; +} + # get information about the project sub get_project_data { my $r = shift; my $dbh = shift; - - my $identifier = get_project_identifier($r); - return $identifier, query_fetch_first($dbh, "SELECT id, is_public, status FROM projects WHERE identifier = ?", $identifier); -} -# get redmine permission based on HTTP method -sub required_permission { - my $r = shift; - $read_only_methods{$r->method} || ':commit_access'; + my $identifier = get_project_identifier($r); + return $identifier, $dbh->selectrow_arrayref("SELECT id, is_public, status FROM projects WHERE identifier = ?", $identifier); } # return module configuration for current directory sub get_config { my $r = shift; - Apache2::Module::get_config(__PACKAGE__, $r->server, $r->per_dir_config); + + return Apache2::Module::get_config(__PACKAGE__, $r->server, $r->per_dir_config); } # get a connection to the redmine database sub connect_database { - my $r = shift; + my $r = shift; my $cfg = get_config($r); - return DBI->connect($cfg->{RedmineDSN}, $cfg->{RedmineDbUser}, $cfg->{RedmineDbPass}); -} - -# execute a query and return the first row -sub query_fetch_first { - my $dbh = shift; - my $query = shift; - - my $sth = $dbh->prepare($query); - $sth->execute(@_); - my @row = $sth->fetchrow_array(); - $sth->finish(); - undef $sth; - - @row; + return DBI->connect($cfg->{DSN}, $cfg->{DbUser}, $cfg->{DbPass}); } # tell if a value returned from SQL is "true" @@ -464,36 +497,42 @@ sub is_true { # build credential cache key sub get_cache_key { my ($r, $password) = @_; - return Digest::SHA1::sha1_hex(join(':', get_project_identifier($r), $r->user, $password, required_permission($r))); + return Digest::SHA1::sha1_hex(join(':', get_project_identifier($r), $r->user, $password, is_read_request($r) ? 'read' : 'write'); } # check if credentials exist in cache sub cache_get { my($r, $key) = @_; + my $cfg = get_config($r); - my $cache = $cfg->{RedmineCacheCreds}; + my $cache = $cfg->{CacheCreds}; return unless $cache; - my $time = $cache->get($key) or return 0; - if($cfg->{RedmineCacheCredsMaxAge} && ($r->request_time - $time) > $cfg->{RedmineCacheCredsMaxAge}) { + + my $time = $cache->get($key) + or return 0; + + if($cfg->{CacheCredsMaxAge} && ($r->request_time - $time) > $cfg->{CacheCredsMaxAge}) { $cache->unset($key); - $cfg->{RedmineCacheCredsCount}--; + $cfg->{CacheCredsCount}--; return 0; } - 1; + return 1; } # put credentials in cache sub cache_set { my($r, $key) = @_; + my $cfg = get_config($r); - my $cache = $cfg->{RedmineCacheCreds}; + my $cache = $cfg->{CacheCreds}; return unless $cache; - if($cfg->{RedmineCacheCredsCount} >= $cfg->{RedmineCacheCredsMax}) { + + if($cfg->{CacheCredsCount} >= $cfg->{CacheCredsMax}) { $cache->clear; - $cfg->{RedmineCacheCredsCount} = 0; + $cfg->{CacheCredsCount} = 0; } $cache->set($key, $r->request_time); - $cfg->{RedmineCacheCredsCount}++; + $cfg->{CacheCredsCount}++; } 1;