Lots of entangled changes...

1.x
Adirelle 2011-11-13 10:45:04 +01:00
parent 57f4784158
commit a47ee921ec
1 changed files with 163 additions and 124 deletions

View File

@ -49,7 +49,7 @@ Authen::Simple::LDAP (and IO::Socket::SSL if LDAPS is used):
PerlAuthenHandler Apache::Authn::Redmine::authen_handler PerlAuthenHandler Apache::Authn::Redmine::authen_handler
PerlAuthzHandler Apache::Authn::Redmine::authz_handler PerlAuthzHandler Apache::Authn::Redmine::authz_handler
## for mysql ## for mysql
RedmineDSN "DBI:mysql:database=databasename;host=my.db.server" RedmineDSN "DBI:mysql:database=databasename;host=my.db.server"
## for postgres ## for postgres
@ -145,6 +145,18 @@ my @directives = (
req_override => OR_AUTHCFG, req_override => OR_AUTHCFG,
args_how => TAKE1, 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', name => 'RedmineCacheCredsMax',
req_override => OR_AUTHCFG, req_override => OR_AUTHCFG,
@ -159,41 +171,51 @@ my @directives = (
}, },
); );
sub RedmineDSN { sub RedmineDSN {
my ($self, $parms, $arg) = @_; my ($cfg, $parms, $arg) = @_;
$self->{RedmineDSN} = $arg; $cfg->{DSN} = $arg;
my $query = "SELECT $cfg->{Query} = trim("
hashed_password, salt, auth_source_id, permissions SELECT permissions FROM users, members, member_roles, roles
FROM members, projects, users, roles, member_roles WHERE users.login = ?
WHERE AND users.id = members.user_id
projects.id=members.project_id AND users.status = 1
AND member_roles.member_id=members.id AND members.project_id = ?
AND users.id=members.user_id AND members.id = member_roles.member_id
AND roles.id=member_roles.role_id AND member_roles.role_id = roles.id
AND users.status=1 ");
AND login=?
AND identifier=? ";
$self->{RedmineQuery} = trim($query);
} }
sub RedmineDbUser { set_val('RedmineDbUser', @_); } sub RedmineDbUser { set_val('DbUser', @_); }
sub RedmineDbPass { set_val('RedmineDbPass', @_); } sub RedmineDbPass { set_val('DbPass', @_); }
sub RedmineDbWhereClause { sub RedmineCacheCredsMaxAge { set_val('CacheCredsMaxAge', @_); }
my ($self, $parms, $arg) = @_; sub RedmineProject { set_val('Project', @_); }
$self->{RedmineQuery} = trim($self->{RedmineQuery}.($arg ? $arg : "")." ");
sub RedmineDbWhereClause {
my ($cfg, $parms, $arg) = @_;
$cfg->{Query} = trim($cfg->{Query}.($arg || "")." ");
} }
sub RedmineCacheCredsMax { sub RedmineCacheCredsMax {
my ($self, $parms, $arg) = @_; my ($cfg, $parms, $arg) = @_;
if ($arg) { if ($arg) {
$self->{RedmineCachePool} = APR::Pool->new; $cfg->{CachePool} = APR::Pool->new;
$self->{RedmineCacheCreds} = APR::Table::make($self->{RedmineCachePool}, $arg); $cfg->{CacheCreds} = APR::Table::make($cfg->{CachePool}, $arg);
$self->{RedmineCacheCredsCount} = 0; $cfg->{CacheCredsCount} = 0;
$self->{RedmineCacheCredsMax} = $arg; $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 { sub trim {
my $string = shift; my $string = shift;
@ -208,11 +230,13 @@ sub set_val {
Apache2::Module::add(__PACKAGE__, \@directives); 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 { sub authen_handler {
my $r = shift; my $r = shift;
unless ($r->some_auth_required) { unless ($r->some_auth_required) {
$r->log_reason("No authentication has been configured"); $r->log_reason("No authentication has been configured");
return FORBIDDEN; return FORBIDDEN;
@ -220,7 +244,7 @@ sub authen_handler {
my ($res, $password) = $r->get_basic_auth_pw(); my ($res, $password) = $r->get_basic_auth_pw();
my $reason; my $reason;
if($res == OK) { if($res == OK) {
# Got user and password # Got user and password
@ -229,17 +253,17 @@ sub authen_handler {
if(cache_get($r, $cache_key)) { if(cache_get($r, $cache_key)) {
$r->log->debug("reusing cached credentials for user '", $r->user, "'"); $r->log->debug("reusing cached credentials for user '", $r->user, "'");
$r->set_handlers(PerlAuthzHandler => undef); $r->set_handlers(PerlAuthzHandler => undef);
} else { } else {
# Else check them # Else check them
my $dbh = connect_database($r); my $dbh = connect_database($r);
($res, $reason) = check_login($r, $dbh, $password); ($res, $reason) = check_login($r, $dbh, $password);
$dbh->disconnect(); $dbh->disconnect();
# Store the cache key for latter use # 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) { } elsif($res == AUTH_REQUIRED) {
my $dbh = connect_database($r); my $dbh = connect_database($r);
if(is_authentication_forced($dbh)) { if(is_authentication_forced($dbh)) {
@ -250,11 +274,11 @@ sub authen_handler {
$res = OK; $res = OK;
} }
$dbh->disconnect(); $dbh->disconnect();
} }
$r->log_reason($reason) if defined($reason); $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; return $res;
} }
@ -263,16 +287,16 @@ sub authen_handler {
sub check_login { sub check_login {
my ($r, $dbh, $password) = @_; my ($r, $dbh, $password) = @_;
my $user = $r->user; 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 # Not found
return (AUTH_REQUIRED, "unknown user '$user'") unless defined($hashed_password); return (AUTH_REQUIRED, "unknown user '$user'") unless defined($hashed_password);
# Check password # Check password
if($auth_source_id) { if($auth_source_id) {
# LDAP authentication # LDAP authentication
# Ensure Authen::Simple::LDAP is available # Ensure Authen::Simple::LDAP is available
return (SERVER_ERROR, "Redmine LDAP authentication requires Authen::Simple::LDAP") return (SERVER_ERROR, "Redmine LDAP authentication requires Authen::Simple::LDAP")
unless $CanUseLDAPAuth; 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 = ?", "SELECT host,port,tls,account,account_password,base_dn,attr_login from auth_sources WHERE id = ?",
$auth_source_id $auth_source_id
); );
# Check them # Check them
return (SERVER_ERROR, "Undefined authentication source for '$user'") return (SERVER_ERROR, "Undefined authentication source for '$user'")
unless defined $host; unless defined $host;
# Connect to the LDAP server # Connect to the LDAP server
my $ldap = Authen::Simple::LDAP->new( my $ldap = Authen::Simple::LDAP->new(
host => is_true($tls) ? "ldaps://$host:$port" : $host, host => is_true($tls) ? "ldaps://$host:$port" : $host,
port => $port, port => $port,
@ -297,31 +321,31 @@ sub check_login {
bindpw => $account_password || "", bindpw => $account_password || "",
filter => '('.$attr_login.'=%s)' filter => '('.$attr_login.'=%s)'
); );
# Finally check user login # Finally check user login
return (AUTH_REQUIRED, "LDAP authentication failed (user: '$user', server: '$host')") return (AUTH_REQUIRED, "LDAP authentication failed (user: '$user', server: '$host')")
unless $ldap->authenticate($user, $password); unless $ldap->authenticate($user, $password);
} else { } else {
# Database authentication # Database authentication
my $pass_digest = Digest::SHA1::sha1_hex($password); my $pass_digest = Digest::SHA1::sha1_hex($password);
return (AUTH_REQUIRED, "wrong password for '$user'") return (AUTH_REQUIRED, "wrong password for '$user'")
unless $hashed_password eq Digest::SHA1::sha1_hex($salt.$pass_digest); 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; return (FORBIDDEN, "inactive account: '$user'") unless $status == 1;
$r->log->debug("successfully authenticated as active redmine user '$user'"); $r->log->debug("successfully authenticated as active redmine user '$user'");
# Everything's ok # Everything's ok
return OK; return OK;
} }
# check if authentication is forced # check if authentication is forced
sub is_authentication_forced { sub is_authentication_forced {
my $dbh = shift; 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 { sub authz_handler {
@ -332,19 +356,19 @@ sub authz_handler {
return FORBIDDEN; return FORBIDDEN;
} }
my $dbh = connect_database($r); my $dbh = connect_database($r);
my ($identifier, $project_id, $is_public, $status) = get_project_data($r, $dbh); my ($identifier, $project_id, $is_public, $status) = get_project_data($r, $dbh);
$is_public = is_true($is_public); $is_public = is_true($is_public);
my($res, $reason) = FORBIDDEN; my($res, $reason) = FORBIDDEN;
unless(defined($project_id)) { unless(defined($project_id)) {
# Unknown project # Unknown project
$res = DECLINED; $res = DECLINED;
$reason = "not a redmine project"; $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 # Write operation on archived project is forbidden
$reason = "write operations on inactive project '$identifier' are forbidden"; $reason = "write operations on inactive project '$identifier' are forbidden";
@ -352,41 +376,36 @@ sub authz_handler {
# Anonymous access # Anonymous access
$res = AUTH_REQUIRED; $res = AUTH_REQUIRED;
$reason = "anonymous access to '$identifier' denied"; $reason = "anonymous access to '$identifier' denied";
if($is_public) { if($is_public) {
# Check anonymous permissions # Check anonymous permissions
my $required = required_permission($r); my ($permissions) = $dbh->selectrow_arrayref("SELECT permissions FROM roles WHERE builtin = 2");
my ($id) = query_fetch_first($dbh, "SELECT id FROM roles WHERE builtin = 2 AND permissions LIKE ?", '%'.$required.'%'); $res = OK if check_permissions($r, $permissions);
$res = OK if defined $id;
} }
# Force login if failed # Force login if failed
$r->note_auth_failure unless $res == OK; $r->note_auth_failure unless $res == OK;
} else { } else {
# Logged in user # Logged in user
my $required = required_permission($r); my @permissions = ();
my $user = $r->user; 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) { # Membership permissions
# Fallback to non-member role for public projects if(my @membership = $dbh->selectcol_arrayref($cfg->{Query}, $user, $project_id)) {
$id = query_fetch_first($dbh, "SELECT id FROM roles WHERE builtin = 1 AND permissions LIKE ?", '%'.$required.'%'); 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; $res = OK;
my $cache_key = $r->pnotes("RedmineCacheKey"); my $cache_key = $r->pnotes("RedmineCacheKey");
cache_set($r, $cache_key) if defined $cache_key; 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; $r->log_reason($reason) if $res != OK && defined $reason;
return $res; return $res;
} }
# get project identifier # get the project identifier
sub get_project_identifier { sub get_project_identifier {
my $r = shift; my ($r, $dbh) = @_;
my $dbh = shift;
my $cfg = get_config($r);
my $location = $r->location; my $identifier = $cfg->{Project};
my ($identifier) = $r->uri =~ m{^\Q$location\E/*([^/]+)}; unless($identifier) {
my $location = $r->location;
($identifier) = $r->uri =~ m{^\Q$location\E/*([^/]+)};
}
return $identifier; 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 # get information about the project
sub get_project_data { sub get_project_data {
my $r = shift; my $r = shift;
my $dbh = 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 my $identifier = get_project_identifier($r);
sub required_permission { return $identifier, $dbh->selectrow_arrayref("SELECT id, is_public, status FROM projects WHERE identifier = ?", $identifier);
my $r = shift;
$read_only_methods{$r->method} || ':commit_access';
} }
# return module configuration for current directory # return module configuration for current directory
sub get_config { sub get_config {
my $r = shift; 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 # get a connection to the redmine database
sub connect_database { sub connect_database {
my $r = shift; my $r = shift;
my $cfg = get_config($r); my $cfg = get_config($r);
return DBI->connect($cfg->{RedmineDSN}, $cfg->{RedmineDbUser}, $cfg->{RedmineDbPass}); return DBI->connect($cfg->{DSN}, $cfg->{DbUser}, $cfg->{DbPass});
}
# 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;
} }
# tell if a value returned from SQL is "true" # tell if a value returned from SQL is "true"
@ -464,36 +497,42 @@ sub is_true {
# build credential cache key # build credential cache key
sub get_cache_key { sub get_cache_key {
my ($r, $password) = @_; 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 # check if credentials exist in cache
sub cache_get { sub cache_get {
my($r, $key) = @_; my($r, $key) = @_;
my $cfg = get_config($r); my $cfg = get_config($r);
my $cache = $cfg->{RedmineCacheCreds}; my $cache = $cfg->{CacheCreds};
return unless $cache; 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); $cache->unset($key);
$cfg->{RedmineCacheCredsCount}--; $cfg->{CacheCredsCount}--;
return 0; return 0;
} }
1; return 1;
} }
# put credentials in cache # put credentials in cache
sub cache_set { sub cache_set {
my($r, $key) = @_; my($r, $key) = @_;
my $cfg = get_config($r); my $cfg = get_config($r);
my $cache = $cfg->{RedmineCacheCreds}; my $cache = $cfg->{CacheCreds};
return unless $cache; return unless $cache;
if($cfg->{RedmineCacheCredsCount} >= $cfg->{RedmineCacheCredsMax}) {
if($cfg->{CacheCredsCount} >= $cfg->{CacheCredsMax}) {
$cache->clear; $cache->clear;
$cfg->{RedmineCacheCredsCount} = 0; $cfg->{CacheCredsCount} = 0;
} }
$cache->set($key, $r->request_time); $cache->set($key, $r->request_time);
$cfg->{RedmineCacheCredsCount}++; $cfg->{CacheCredsCount}++;
} }
1; 1;