Lots of entangled changes...
This commit is contained in:
		
							parent
							
								
									57f4784158
								
							
						
					
					
						commit
						a47ee921ec
					
				
							
								
								
									
										287
									
								
								Redmine.pm
									
									
									
									
									
								
							
							
						
						
									
										287
									
								
								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; | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user