diff options
author | Stonewall Jackson <stonewall@sacredheartsc.com> | 2023-01-18 18:33:58 -0500 |
---|---|---|
committer | Stonewall Jackson <stonewall@sacredheartsc.com> | 2023-01-18 18:33:58 -0500 |
commit | f9870c623cc8cb115016b22eb893e2e845e283c3 (patch) | |
tree | 4c461a3c5b342b816bbf8f966d7d27c3141e7ec0 | |
download | sabredav-freeipa-f9870c623cc8cb115016b22eb893e2e845e283c3.tar.gz sabredav-freeipa-f9870c623cc8cb115016b22eb893e2e845e283c3.zip |
initial commit
-rw-r--r-- | .gitignore | 5 | ||||
-rw-r--r-- | LICENSE | 22 | ||||
-rw-r--r-- | README.md | 179 | ||||
-rw-r--r-- | composer.json | 12 | ||||
-rw-r--r-- | pgsql.schema.sql | 184 | ||||
-rw-r--r-- | server.example.php | 88 | ||||
-rw-r--r-- | src/AuthBackend.php | 205 | ||||
-rw-r--r-- | src/Connection.php | 228 | ||||
-rw-r--r-- | src/Group.php | 182 | ||||
-rw-r--r-- | src/PrincipalBackend.php | 419 | ||||
-rw-r--r-- | src/User.php | 204 | ||||
-rw-r--r-- | src/Util.php | 114 |
12 files changed, 1842 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ea92ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +composer.lock +server.php +vendor +tmpdata +webdav @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2023 stonewall + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8866cb --- /dev/null +++ b/README.md @@ -0,0 +1,179 @@ +# sabredav-freeipa + +FreeIPA integration for SabreDAV + +## What is this? + +This project extends the [SabreDAV](https://sabre.io/dav/) framework with +authentication and principal backends for FreeIPA. You can use this to run your +own CalDAV/CardDAV server that retrieves users and groups from the local FreeIPA +domain. + +Two backends are provided: + +- `\FreeIPA\AuthBackend`: this is an authentication backend that uses the + `REMOTE_USER` environment variable set by the webserver. You should configure + [mod\_gssapi](https://github.com/gssapi/mod_auth_gssapi) to handle user authentication. + Optionally, you can limit logins to members of certain FreeIPA groups using the + `$allowedGroups` parameter. + + Upon successful login, a default calendar and addressbook will be created for the + user if none already exist. + +- `\FreeIPA\PrincipalBackend`: this is a principal backend that retrieves users and + groups from FreeIPA. You can (and should) limit the users and groups returned + using the `$allowedGroups` parameter. + +Both backends require `php-ldap` compiled with SASL support. Check `phpinfo()` to +verify you have a compatible version. In addition, the PHP process needs access +to kerberos credentials in order to perform LDAP queries (see [below](#apache-configuration)). + +I've only tested this on Rocky Linux 8 with Apache 2.4 and PHP 7.4. + + +## Limitations + +SabreDAV assumes that user and group principals are both stored in the same +`principals/` namespace. Practically, this means that you can't have a user +and group in FreeIPA with the same name. + +While you can [allegedly](https://sabre.io/dav/principals/#custom-principal-url-schemes) +work around this limitation, it is neither tested nor supported. + +In the event a username and groupname clash, the user takes precendence and the +group will not be visible to SabreDAV. + + +## Setup + +Clone this repostory into your webroot: + +```bash +webroot=/var/www/html/sabredav +mkdir $webroot +git clone https://git.sacredheartsc.com/sabredav-freeipa $webroot +``` + +Install dependences using [composer](https://getcomposer.org/): + +```bash +cd $webroot +composer install +``` + +Rename the sample configuration and modify to suit your needs: + +```bash +cp server.example.php server.php +``` + +Most of this file is boilerplate common to all SabreDAV installations. Note the +salient FreeIPA parts: + +```php +$ipa = new \FreeIPA\Connection(); + +$allowedGroups = [ + 'dav-access' +]; + +$authBackend = new \FreeIPA\AuthBackend( + $ipa, + $caldavBackend, + $carddavBackend, + $allowedGroups); + +$principalBackend = new \FreeIPA\PrincipalBackend( + $ipa, + $allowedGroups); +``` + +Note especially the `$allowedGroups` array. You should use this parameter to limit +the FreeIPA users and groups visible to SabreDAV. If you leave it empty, then all +users and groups will be visible. This is bad for two reasons: + +1. It results in poor client experience by littering the interface with a + bunch of groups that no one will ever use. + +2. Sabredav makes a *lot* of group membership queries, seemingly on every + request. Querying group memberships across your entire FreeIPA domain on + every CalDAV operation is ridiculously expensive. + +Consider the example configuration above. Assuming the `dav-access` FreeIPA group +looks like this: + + $ ipa group-show dav-access + Group name: dav-access + Description: CalDAV/CardDAV access + Member groups: accounting, human-resources + Indirect Member users: benedict, leo, michael + +then SabreDAV would only see the following groups: +`dav-access`, `accounting`, `human-resources` + +And similarly, only the members of those groups would show up as SabreDAV users: +`benedict`, `leo`, `michael` + +This type of configuration is possible because FreeIPA supports nested groups +(a group itself can be a member of another group). + + +## Apache Configuration + +The following apache configuration provides Kerberos SSO for SabreDAV, falling +back to Basic authentication. In addition, it redirects well-known URLs to aid +in client autodiscovery. + +```apache +Redirect /.well-known/caldav /server.php +Redirect /.well-known/carddav /server.php + +RewriteEngine On +RewriteCond %{REQUEST_URI} !^/\.well-known/ +RewriteRule .* /server.php [E=HTTP_AUTHORIZATION:%{HTTP:Authorization},L] + +<Location /> + AuthType GSSAPI + AuthName "FreeIPA Single Sign-On" + GssapiBasicAuth On + GssapiNegotiateOnce On + Require valid-user +</Location> +``` + +Apache needs a keytab for `HTTP/dav.example.com`, and PHP needs a kerberos ticket +to perform LDAP queries. The following `gssproxy.conf` snippet is sufficient (this +also works for kerberized postgres queries): + +```dosini +[service/sabredav] +mechs = krb5 +cred_store = client_keytab:/var/lib/gssproxy/clients/sabredav.keytab +euid = apache + +[service/HTTP] +mechs = krb5 +cred_store = keytab:/var/lib/gssproxy/clients/httpd.keytab +euid = apache +program = /usr/sbin/httpd +``` + +Be sure to export `GSS_AUTH_PROXY=yes` for your httpd and php-fpm daemons: + +``` +# /etc/systemd/system/httpd.service.d/override.conf +[Service] +Environment=GSS_USE_PROXY=yes + +# /etc/php-fpm.d/www.conf +env[GSS_USE_PROXY] = yes +``` + +If you're not using gssproxy, you'll need the usual `KRB5_KTNAME` and +`KRB5_CLIENT_KTNAME` with appropriate permissions. + +You'll also need the following if SELinux is enabled: + +```bash +setsebool -P httpd_can_connect_ldap on +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..17936c1 --- /dev/null +++ b/composer.json @@ -0,0 +1,12 @@ +{ + "name": "sacredheartsc/sabredav-freeipa", + "description": "FreeIPA integration for sabredav", + "require": { + "sabre/dav": "~4.3.1" + }, + "autoload": { + "psr-4": { + "FreeIPA\\": "src/" + } + } +} diff --git a/pgsql.schema.sql b/pgsql.schema.sql new file mode 100644 index 0000000..2588da1 --- /dev/null +++ b/pgsql.schema.sql @@ -0,0 +1,184 @@ +CREATE TABLE addressbooks ( + id SERIAL NOT NULL, + principaluri VARCHAR(255), + displayname VARCHAR(255), + uri VARCHAR(200), + description TEXT, + synctoken INTEGER NOT NULL DEFAULT 1 +); + +ALTER TABLE ONLY addressbooks + ADD CONSTRAINT addressbooks_pkey PRIMARY KEY (id); + +CREATE UNIQUE INDEX addressbooks_ukey + ON addressbooks USING btree (principaluri, uri); + +CREATE TABLE cards ( + id SERIAL NOT NULL, + addressbookid INTEGER NOT NULL, + carddata TEXT USING convert_from(carddata, 'utf8'), + uri VARCHAR(200), + lastmodified INTEGER, + etag VARCHAR(32), + size INTEGER NOT NULL +); + +ALTER TABLE ONLY cards + ADD CONSTRAINT cards_pkey PRIMARY KEY (id); + +CREATE UNIQUE INDEX cards_ukey + ON cards USING btree (addressbookid, uri); + +CREATE TABLE addressbookchanges ( + id SERIAL NOT NULL, + uri VARCHAR(200) NOT NULL, + synctoken INTEGER NOT NULL, + addressbookid INTEGER NOT NULL, + operation SMALLINT NOT NULL +); + +ALTER TABLE ONLY addressbookchanges + ADD CONSTRAINT addressbookchanges_pkey PRIMARY KEY (id); + +CREATE INDEX addressbookchanges_addressbookid_synctoken_ix + ON addressbookchanges USING btree (addressbookid, synctoken); +CREATE TABLE calendarobjects ( + id SERIAL NOT NULL, + calendardata TEXT, + uri VARCHAR(200), + calendarid INTEGER NOT NULL, + lastmodified INTEGER, + etag VARCHAR(32), + size INTEGER NOT NULL, + componenttype VARCHAR(8), + firstoccurence INTEGER, + lastoccurence INTEGER, + uid VARCHAR(200) +); + +ALTER TABLE ONLY calendarobjects + ADD CONSTRAINT calendarobjects_pkey PRIMARY KEY (id); + +CREATE UNIQUE INDEX calendarobjects_ukey + ON calendarobjects USING btree (calendarid, uri); + + +CREATE TABLE calendars ( + id SERIAL NOT NULL, + synctoken INTEGER NOT NULL DEFAULT 1, + components VARCHAR(21) +); + +ALTER TABLE ONLY calendars + ADD CONSTRAINT calendars_pkey PRIMARY KEY (id); + + +CREATE TABLE calendarinstances ( + id SERIAL NOT NULL, + calendarid INTEGER NOT NULL, + principaluri VARCHAR(100), + access SMALLINT NOT NULL DEFAULT '1', -- '1 = owner, 2 = read, 3 = readwrite' + displayname VARCHAR(100), + uri VARCHAR(200), + description TEXT, + calendarorder INTEGER NOT NULL DEFAULT 0, + calendarcolor VARCHAR(10), + timezone TEXT, + transparent SMALLINT NOT NULL DEFAULT '0', + share_href VARCHAR(100), + share_displayname VARCHAR(100), + share_invitestatus SMALLINT NOT NULL DEFAULT '2' -- '1 = noresponse, 2 = accepted, 3 = declined, 4 = invalid' +); + +ALTER TABLE ONLY calendarinstances + ADD CONSTRAINT calendarinstances_pkey PRIMARY KEY (id); + +CREATE UNIQUE INDEX calendarinstances_principaluri_uri + ON calendarinstances USING btree (principaluri, uri); + + +CREATE UNIQUE INDEX calendarinstances_principaluri_calendarid + ON calendarinstances USING btree (principaluri, calendarid); + +CREATE UNIQUE INDEX calendarinstances_principaluri_share_href + ON calendarinstances USING btree (principaluri, share_href); + +CREATE TABLE calendarsubscriptions ( + id SERIAL NOT NULL, + uri VARCHAR(200) NOT NULL, + principaluri VARCHAR(100) NOT NULL, + source TEXT, + displayname VARCHAR(100), + refreshrate VARCHAR(10), + calendarorder INTEGER NOT NULL DEFAULT 0, + calendarcolor VARCHAR(10), + striptodos SMALLINT NULL, + stripalarms SMALLINT NULL, + stripattachments SMALLINT NULL, + lastmodified INTEGER +); + +ALTER TABLE ONLY calendarsubscriptions + ADD CONSTRAINT calendarsubscriptions_pkey PRIMARY KEY (id); + +CREATE UNIQUE INDEX calendarsubscriptions_ukey + ON calendarsubscriptions USING btree (principaluri, uri); + +CREATE TABLE calendarchanges ( + id SERIAL NOT NULL, + uri VARCHAR(200) NOT NULL, + synctoken INTEGER NOT NULL, + calendarid INTEGER NOT NULL, + operation SMALLINT NOT NULL DEFAULT 0 +); + +ALTER TABLE ONLY calendarchanges + ADD CONSTRAINT calendarchanges_pkey PRIMARY KEY (id); + +CREATE INDEX calendarchanges_calendarid_synctoken_ix + ON calendarchanges USING btree (calendarid, synctoken); + +CREATE TABLE schedulingobjects ( + id SERIAL NOT NULL, + principaluri VARCHAR(255), + calendardata TEXT, + uri VARCHAR(200), + lastmodified INTEGER, + etag VARCHAR(32), + size INTEGER NOT NULL +); + +ALTER TABLE ONLY schedulingobjects + ADD CONSTRAINT schedulingobjects_pkey PRIMARY KEY (id); +CREATE TABLE locks ( + id SERIAL NOT NULL, + owner VARCHAR(100), + timeout INTEGER, + created INTEGER, + token VARCHAR(100), + scope SMALLINT, + depth SMALLINT, + uri TEXT +); + +ALTER TABLE ONLY locks + ADD CONSTRAINT locks_pkey PRIMARY KEY (id); + +CREATE INDEX locks_token_ix + ON locks USING btree (token); + +CREATE INDEX locks_uri_ix + ON locks USING btree (uri); +CREATE TABLE propertystorage ( + id SERIAL NOT NULL, + path VARCHAR(1024) NOT NULL, + name VARCHAR(100) NOT NULL, + valuetype INT, + value TEXT +); + +ALTER TABLE ONLY propertystorage + ADD CONSTRAINT propertystorage_pkey PRIMARY KEY (id); + +CREATE UNIQUE INDEX propertystorage_ukey + ON propertystorage (path, name); diff --git a/server.example.php b/server.example.php new file mode 100644 index 0000000..e91088e --- /dev/null +++ b/server.example.php @@ -0,0 +1,88 @@ +<?php +/** -JMJ- + * + * Example sabredav configuration for FreeIPA + * + * @author stonewall + * @license https://opensource.org/licenses/MIT + * @version 0.01 + * + * Rename this file to config.php and edit to suit your needs. After running + * `composer install` in this directory, you should be good to go. + */ + +// timezone +date_default_timezone_set('UTC'); + +// database +$pdo = new PDO('pgsql:dbname=sabredav;host=postgres.example.com', 'sabredav'); +$pdo->setAttribute(PDO::ATTR_ERRMODE,PDO::ERRMODE_EXCEPTION); + +// autoloader +require_once 'vendor/autoload.php'; + +// freeipa +$ipa = new \FreeIPA\Connection(); + +/** + * If $allowedGroups is nonempty, only users and groups that are members of one + * of the specified groups will be visible to SabreDAV. Recall that in FreeIPA, + * groups can be members of other groups. + * + * In addition, only members of one of the specified groups will be allowed to + * login. + * + * If $allowedGroups is empty, then *every* FreeIPA user and *every* FreeIPA + * group will be visible as a SabreDAV principal. This can cause performance + * issues due to the large number of LDAP queries issued. + */ +$allowedGroups = [ + 'dav-access' +]; + +// backends +$caldavBackend = new \Sabre\CalDAV\Backend\PDO($pdo); +$carddavBackend = new \Sabre\CardDAV\Backend\PDO($pdo); +$principalBackend = new \FreeIPA\PrincipalBackend($ipa, $allowedGroups); +$authBackend = new \FreeIPA\AuthBackend($ipa, $caldavBackend, $carddavBackend, $allowedGroups); +$lockBackend = new \Sabre\DAV\Locks\Backend\PDO($pdo); + +// directory structure +$server = new Sabre\DAV\Server([ + new \Sabre\CalDAV\Principal\Collection($principalBackend), + new \Sabre\CalDAV\CalendarRoot($principalBackend, $caldavBackend), + new \Sabre\CardDAV\AddressBookRoot($principalBackend, $carddavBackend), + new \Sabre\DAVACL\FS\HomeCollection($principalBackend, __DIR__."/webdav") +]); + +// if you run sabredav from a subdirectory, set it here +$server->setBaseUri('/'); + +// plugins +$server->addPlugin(new \Sabre\DAV\Auth\Plugin($authBackend,'SabreDAV')); +$server->addPlugin(new \Sabre\DAV\Browser\Plugin()); +$server->addPlugin(new \Sabre\DAV\Sync\Plugin()); +$server->addPlugin(new \Sabre\DAV\Sharing\Plugin()); +$server->addPlugin(new \Sabre\DAV\Locks\Plugin($lockBackend)); +$server->addPlugin(new \Sabre\DAV\Browser\GuessContentType()); +$server->addPlugin(new \Sabre\DAV\TemporaryFileFilterPlugin(__DIR__."/tmpdata")); + +$aclPlugin = new \Sabre\DAVACL\Plugin(); +$aclPlugin->hideNodesFromListings = true; +$server->addPlugin($aclPlugin); + +// caldav plugins +$server->addPlugin(new \Sabre\CalDAV\Plugin()); +$server->addPlugin(new \Sabre\CalDAV\Schedule\Plugin()); +$server->addPlugin(new \Sabre\CalDAV\Schedule\IMipPlugin('calendar-noreply@example.com')); +$server->addPlugin(new \Sabre\CalDAV\Subscriptions\Plugin()); +$server->addPlugin(new \Sabre\CalDAV\Notifications\Plugin()); +$server->addPlugin(new \Sabre\CalDAV\SharingPlugin()); +$server->addPlugin(new \Sabre\CalDAV\ICSExportPlugin()); + +// carddav plugins +$server->addPlugin(new \Sabre\CardDAV\Plugin()); +$server->addPlugin(new \Sabre\CardDAV\VCFExportPlugin()); + +// run the server +$server->exec(); diff --git a/src/AuthBackend.php b/src/AuthBackend.php new file mode 100644 index 0000000..ab45b7b --- /dev/null +++ b/src/AuthBackend.php @@ -0,0 +1,205 @@ +<?php +/** -JMJ- + * + * FreeIPA/Apache authentication backend + * + * @author stonewall + * @license https://opensource.org/licenses/MIT + * @version 0.01 + * + * This backend assumes that the webserver handles authentication using + * mod_gssapi or similar, and checks for membership in one or more groups + * before granting access. + * + * Upon successful login, a default calendar and group are created for the user + * if none already exist. + * + * php-ldap compiled with SASL support is required, along with accessible + * kerberos credentials. Check the README for more information. + * + * Add this backend in server.php with the following invocation: + * + * $ipa = new \FreeIPA\Connection(); + * $allowedGroups = ['sabredav-access']; + * $authBackend = new \FreeIpa\AuthBackend( + * $ipa, + * $caldavBackend, + * $carddavBackend, + * $allowedGroups); + * + * If the $allowedGroups argument is given, then membership in at least one of + * the specified groups is required to login. + * + * If the $allowedGroups argument is not given (or any empty array is provided), + * then no group memberships are checked. + */ + +declare(strict_types=1); + +namespace FreeIPA; + +use \Sabre\HTTP\RequestInterface; +use \Sabre\HTTP\ResponseInterface; + +class AuthBackend implements \Sabre\DAV\Auth\Backend\BackendInterface { + + const PRINCIPAL_PREFIX = 'principals/'; + + protected $ipa; + protected $caldavBackend; + protected $carddavBackend; + protected $allowedGroups; + + protected $defaultCalendarName = 'personal'; + protected $defaultCalendarDescription = 'Personal'; + protected $defaultAddressBookName = 'personal'; + protected $defaultAddressBookDescription = 'Personal'; + + public function __construct( + \FreeIPA\Connection $ipa, + \Sabre\CalDAV\Backend\BackendInterface $caldavBackend, + \Sabre\CardDAV\Backend\BackendInterface $carddavBackend, + array $allowedGroups = []) + { + $this->ipa = $ipa; + $this->caldavBackend = $caldavBackend; + $this->carddavBackend = $carddavBackend; + $this->allowedGroups = $allowedGroups; + } + + /** + * When this method is called, the backend must check if authentication was + * successful. + * + * The returned value must be one of the following + * + * [true, "principals/username"] + * [false, "reason for failure"] + * + * If authentication was successful, it's expected that the authentication + * backend returns a so-called principal url. + * + * Examples of a principal url: + * + * principals/admin + * principals/user1 + * principals/users/joe + * principals/uid/123457 + * + * If you don't use WebDAV ACL (RFC3744) we recommend that you simply + * return a string such as: + * + * principals/users/[username] + * + * @return array + */ + public function check(RequestInterface $request, ResponseInterface $response) { + $remoteUser = $request->getRawServerValue('REMOTE_USER'); + + if (is_null($remoteUser)) { + return [false, 'REMOTE_USER variable not set']; + } + + /* If REMOTE_USER has a realm component, and the realm does not match the + * local FreeIPA kerberos realm, deny the request. + */ + $userParts = explode('@', $remoteUser, 2); + + if (count($userParts) == 2 && $userParts[1] != $this->ipa->getRealm()) { + return [false, "REMOTE_USER has unknown realm: {$userParts[1]}"]; + } + + if (!User::get($this->ipa, $userParts[0], [], null, $this->allowedGroups)) { + return [false, "user {$userParts[0]} failed group authorization"]; + } + + $userPrincipal = self::PRINCIPAL_PREFIX . $userParts[0]; + + /* Create a default calendar and addressbook if none exist. + */ + if (empty($this->caldavBackend->getCalendarsForUser($userPrincipal))) { + $this->caldavBackend->createCalendar( + $userPrincipal, + $this->defaultCalendarName, + ['{DAV:}displayname' => $this->defaultCalendarDescription]); + } + + if (empty($this->carddavBackend->getAddressBooksForUser($userPrincipal))) { + $this->carddavBackend->createAddressBook( + $userPrincipal, + $this->defaultAddressBookName, + ['{DAV:}displayname' => $this->defaultAddressBookDescription]); + } + + return [true, $userPrincipal]; + } + + /** + * This method is called when a user could not be authenticated, and + * authentication was required for the current request. + * + * This gives you the opportunity to set authentication headers. The 401 + * status code will already be set. + * + * In this case of Basic Auth, this would for example mean that the + * following header needs to be set: + * + * $response->addHeader('WWW-Authenticate', 'Basic realm=SabreDAV'); + * + * Keep in mind that in the case of multiple authentication backends, other + * WWW-Authenticate headers may already have been set, and you'll want to + * append your own WWW-Authenticate header instead of overwriting the + * existing one. + */ + public function challenge(RequestInterface $request, ResponseInterface $response) { + // intentional no-op + } + + /** + * Set the name of the default calendar. This is the basename component of the + * calendar's URI. + * + * @param string $name + */ + public function setDefaultCalendarName($name) { + $this->defaultCalendarName = $name; + } + + /** + * Set the description for the default calendar. + * + * @param string $name + */ + public function setDefaultCalendarDescription($description) { + $this->defaultCalendarDescription = $description; + } + + /** + * Set the name of the default addressbook. This is the basename component of + * the * addressbook's URI. + * + * @param string $name + */ + public function setDefaultAddressBookName($name) { + $this->defaultAddressBookName = $name; + } + + /** + * Set the description for the default addressbook. + * + * @param string $name + */ + public function setDefaultAddressBookDescription($description) { + $this->defaultAddressBookDescription = $description; + } + + /** + * Set the groups that are allowed to login. + * + * @param array $allowedGroups + */ + public function setAllowedGroups(array $allowedGroups) { + $this->allowedGroups = $allowedGroups; + } + +} diff --git a/src/Connection.php b/src/Connection.php new file mode 100644 index 0000000..3a755b6 --- /dev/null +++ b/src/Connection.php @@ -0,0 +1,228 @@ +<?php +/** -JMJ- + * + * FreeIPA connection definition + * + * @author stonewall + * @license https://opensource.org/licenses/MIT + * @version 0.01 + * + * This class represents a connection to a FreeIPA domain. An instance should + * be instantiated in server.php and passed to the FreeIPA\AuthBackend and + * FreeIPA\PrincipalBackend objects. + * + * No arguments are necessary, but you may override the autodetection with + * the following invocation: + * + * new \FreeIPA\Connection(( + * domain = null, + * realm = null, + * baseDn = null, + * ldapUri = null + * ) + */ + +declare(strict_types=1); + +namespace FreeIPA; + +use Sabre\DAV\Exception; + +class Connection { + + protected $realm; + protected $domain; + protected $baseDn; + protected $ldapUri; + protected $ldapConn; + + /** + * Discover the local DNS domain by querying the local FQDN. + */ + protected function discoverDnsDomain() { + if ($localFqdn = gethostbyaddr(gethostbyname(gethostname()))) { + $domain = strtolower(explode('.', $localFqdn, 2)[1]); + if (!in_array($domain, [$localFqdn, 'localhost', 'localdomain', 'localhost.localdomain'])) { + return $this->domain = $domain; + } + } + throw new Exception("Failed to discover local FreeIPA domain"); + } + + /** + * Discover the local kerberos realm by querying the _kerberos SRV record. + */ + protected function discoverKerberosRealm() { + if ($kerberosTxtRecord = dns_get_record("_kerberos.{$this->domain}", DNS_TXT)) { + return $this->realm = $kerberosTxtRecord[0]['txt']; + } + return $this->realm = strtoupper($this->domain); + } + + /** + * Discover the local LDAP servers by querying the _ldap SRV record. + */ + protected function discoverLdapServers() { + if ($ldapSrvRecords = dns_get_record("_ldap._tcp.{$this->domain}", DNS_SRV)) { + return $this->ldapUri = implode(' ' , array_map(function($record) { + return "ldap://$record[target]:$record[port]"; + }, $ldapSrvRecords)); + } + throw new Exception("Failed to discover local LDAP servers via DNS"); + } + + /** + * Discover the LDAP basedn by querying the root DSE. On failure, guess the basedn + * from the local kerberos realm. + */ + protected function discoverBaseDn() { + $results = ldap_read($this->ldapConn, '', 'objectClass=*', ['defaultnamingcontext']); + if ($results && ldap_count_entries($this->ldapConn, $results) == 1) { + if ($rootDse = ldap_first_entry($this->ldapConn, $results)) { + $attributes = ldap_get_attributes($this->ldapConn, $rootDse); + if ($attributes['defaultnamingcontext']['count'] == 1) { + return $this->baseDn = $attributes['defaultnamingcontext'][0]; + } + } + } + return $this->guessBaseDnFromRealm(); + } + + /** + * Construct an (assumed) LDAP basen from the components of the local kerberos realm. + * + * @return string + */ + protected function guessBaseDnFromRealm() { + $this->baseDn = implode(',', preg_filter('/^/', 'dc=', explode('.', strtolower($this->realm)))); + } + + public function __construct($domain = null, $realm = null, $baseDn = null, $ldapUri = null) { + if (!function_exists('ldap_connect')) { + throw new Exception('FreeIPA integration requires php-ldap, and it is not installed'); + } + + // get local domain + if (!empty($domain)) { + $this->domain = $domain; + } else { + $this->discoverDnsDomain(); + } + + // get local realm + if (!empty($realm)) { + $this->realm = $realm; + } else { + $this->discoverKerberosRealm(); + } + + // get ldap servers + if (!empty($ldapUri)) { + $this->ldapUri = $ldapUri; + } else { + $this->discoverLdapServers(); + } + + // connect to ldap server + if (!($this->ldapConn = ldap_connect($this->ldapUri))) { + throw new Exception("Failed to connect to FreeIPA LDAP server"); + } + + // set protocol version 3 + if (!ldap_set_option($this->ldapConn, LDAP_OPT_PROTOCOL_VERSION, 3)) { + throw new Exception("Failed to set LDAP protocol version"); + } + + // start TLS session + if(!ldap_start_tls($this->ldapConn)) { + throw new Exception("Failed to establish TLS session with LDAP server"); + } + + // bind to ldap server using kerberos credentials + if (!ldap_sasl_bind($this->ldapConn, '', '', 'GSSAPI')) { + throw new Exception("Failed to bind to LDAP server"); + } + + // get base dn + if (!empty($baseDn)) { + $this->basedn = $baseDn; + } else { + $this->discoverBaseDn(); + } + } + + /** + * Perform an LDAP search of the FreeIPA directory with subtree scope. + * + * @param string $container : ldap container relative to basedn (eg. 'cn=users,cn=accounts') + * @param string $filter : ldap filter + * @param array $attributes : ldap attributes to return + * + * @return array|false + */ + public function search($container = null, $filter = null, $attributes = []) { + if ($result = ldap_search( + $this->ldapConn, + ($container ? "{$container},{$this->baseDn}" : $this->baseDn), + ($filter ? $filter : '(objectClass=*)'), + $attributes)) + { + if ($entries = ldap_get_entries($this->ldapConn, $result)) { + if ($entries['count'] > 0) { + return $entries; + } + } + } + return false; + } + + /** + * Perform an LDAP search of the FreeIPA directory with subtree base. + * + * @param string $container : ldap container relative to basedn (eg. 'cn=users,cn=accounts') + * @param string $filter : ldap filter + * @param array $attributes : ldap attributes to return + * + * @return array|false + */ + public function read($container = null, $filter = null, $attributes = []) { + if ($result = ldap_read( + $this->ldapConn, + ($container ? "{$container},{$this->baseDn}" : $this->baseDn), + ($filter ? $filter : '(objectClass=*)'), + $attributes)) + { + if ($entries = ldap_get_entries($this->ldapConn, $result)) { + if ($entries['count'] > 0) { + return $entries[0]; + } + } + } + return false; + } + + /** + * Given a list of DN components relative to the base DN, constructs the + * fully-qualified DN. + * + * For example: + * resolveDn('uid=joseph', 'cn=users,cn=accounts') + * -> 'uid=joseph,cn=users,cn=accounts,dc=ipa,dc=example,dc=com' + * + * @param string $components... + * + * @return string + */ + public function resolveDn(...$components) { + return implode(',', array_merge($components, [$this->baseDn])); + } + + /** + * Returns the local kerberos realm. + * + * @return string + */ + public function getRealm() { + return $this->realm; + } +} diff --git a/src/Group.php b/src/Group.php new file mode 100644 index 0000000..646830a --- /dev/null +++ b/src/Group.php @@ -0,0 +1,182 @@ +<?php +/** -JMJ- + * + * FreeIPA group definition + * + * @author stonewall + * @license https://opensource.org/licenses/MIT + * @version 0.01 + * + * This class represents a FreeIPA group object. It cannot not be instantiated + * directly. Rather, use the static Group::get() or Group::search() methods + * to retrieve one or more groups. + */ + +declare(strict_types=1); + +namespace FreeIPA; + +class Group { + const PRINCIPAL_PREFIX = 'principals/'; + const LDAP_CONTAINER = 'cn=groups,cn=accounts'; + const LDAP_OBJECT_CLASS = 'groupofnames'; + const LDAP_ATTRIBUTES = ['cn', 'description']; + + const LDAP_FIELD_MAP = [ + '{DAV:}displayname' => 'description', + '{http://sabredav.org/ns}email-address' => 'mail' + ]; + + protected $name; + protected $description; + + protected function __construct($name, $description) { + $this->name = $name; + $this->description = $description; + } + + /** + * Construct a Group object from an LDAP group entry. + * + * @param array $entry + * + * @return \FreeIPA\Group + */ + protected static function fromLdapEntry($entry) { + return new self( + $entry['cn'][0], + isset($entry['description'][0]) ? $entry['description'][0] : $entry['cn'][0] + ); + } + + /** + * Returns an array of Group objects for each group in the FreeIPA directory matching + * the given DAV search properties, subject to $allowedGroups. + * + * @param \FreeIPA\Connection $ipaConn : freeipa connection object + * @param array $searchProperties : search conditions, as requested by SabreDAV + * @param string $test : either 'allof' or 'anyof' + * @param array $allowedGroups : only consider the given groups + * + * @return array + */ + public static function search( + \FreeIPA\Connection $ipaConn, + array $searchProperties = [], + $test = 'anyof', + array $allowedGroups = []) + { + $groups = []; + + // for each group matching $filter + if ($entries = $ipaConn->search( + self::LDAP_CONTAINER, + Util::buildFilter('allof', + ['objectClass', self::LDAP_OBJECT_CLASS], + Util::buildMemberOfFilter($ipaConn, $allowedGroups, true), + Util::buildPrincipalFilter($searchProperties, self::LDAP_FIELD_MAP, $test)), + self::LDAP_ATTRIBUTES)) + { + for ($i = 0; $i < $entries['count']; $i++) { + $groups[] = self::fromLdapEntry($entries[$i]); + } + } + return $groups; + } + + /** + * Returns the Group from FreeIPA with the given groupname that matches the + * given DAV search properties, subject to $allowedGroups. + * + * If no matching group is found, null is returned. + * + * @param \FreeIPA\Connection $ipaConn : freeipa connection object + * @param string $groupname : freeipa group cn + * @param array $searchProperties : search conditions, as requested by SabreDAV + * @param string $test : either 'allof' or 'anyof' + * @param array $allowedGroups : only consider the given groups + * + * @return \FreeIPA\Group|null + */ + public static function get( + \FreeIPA\Connection $ipaConn, + $groupname, + array $searchProperties = [], + $test = 'anyof', + array $allowedGroups = []) + { + if ($entry = $ipaConn->read( + self::getRelativeDn($groupname), + Util::buildFilter('allof', + ['objectClass', self::LDAP_OBJECT_CLASS], + Util::buildMemberOfFilter($ipaConn, $allowedGroups, true), + Util::buildPrincipalFilter($searchProperties, self::LDAP_FIELD_MAP, $test)), + self::LDAP_ATTRIBUTES)) + { + return self::fromLdapEntry($entry); + } + return null; + } + + /** + * Convert a groupname to an escaped relative LDAP DN + * + * For example: + * getRelativeDn('hr') -> 'uid=\68\72,cn=groups,cn=accounts' + * + * @param string $groupname + * + * @return string + */ + public static function getRelativeDn($groupname) { + return 'cn=' . ldap_escape($groupname) . ',' . self::LDAP_CONTAINER; + } + + /** + * Returns an array of principal URIs corresponding to each of the group's + * members, subject to $allowedGroups. + * + * @param \FreeIPA\Connection $ipaConn : freeipa connection object + * @param array $allowedGroups : only consider members of the given groups + * + * @return array + */ + public function getMemberPrincipals(\FreeIPA\Connection $ipaConn, array $allowedGroups = []) { + $memberPrincipals = []; + + if ($entries = $ipaConn->search( + User::LDAP_CONTAINER, + Util::buildFilter('allof', + ['objectClass', User::LDAP_OBJECT_CLASS], + ['memberof', $ipaConn->resolveDn(self::getRelativeDn($this->name))], + Util::buildMemberOfFilter($ipaConn, $allowedGroups)), + ['uid'])) + { + for ($i = 0; $i < $entries['count']; $i++) { + $memberPrincipals[] = User::PRINCIPAL_PREFIX . $entries[$i]['uid'][0]; + } + } + return $memberPrincipals; + } + + /** + * Convert a Group to SabreDAV's representation of a principal. + * + * @return array + */ + public function toPrincipal() { + return [ + 'uri' => self::PRINCIPAL_PREFIX . $this->name, + '{DAV:}displayname' => $this->description + ]; + } + + /** + * Get the groupname. + * + * @return string + */ + public function getName() { + return $this->name; + } +} diff --git a/src/PrincipalBackend.php b/src/PrincipalBackend.php new file mode 100644 index 0000000..74fdb55 --- /dev/null +++ b/src/PrincipalBackend.php @@ -0,0 +1,419 @@ +<?php +/** -JMJ- + * + * FreeIPA/LDAP principal backend + * + * @author stonewall + * @license https://opensource.org/licenses/MIT + * @version 0.01 + * + * This backend constructs principals from the users and groups in the local + * FreeIPA domain. + * + * php-ldap compiled with SASL support is required, along with accessible + * kerberos credentials. Check the README for more information. + * + * Note that since sabredav assumes that users and groups exist in the same + * namespace, you can't have a FreeIPA user and FreeIPA group with the same + * name. In the event of a clash, only the user object is visible to sabredav. + * + * Add this backend in server.php with the following invocation: + * + * $ipa = new \FreeIPA\Connection(); + * $allowedGroups = ['sabredav-access']; + * $principalBackend = new \FreeIPA\PrincipalBackend($ipa, $allowedGroups); + * + * If the $allowedGroups argument is given, then only members of one of the + * specified groups are visible to sabredav. + * + * NOTE: This applies to both users AND groups!! (FreeIPA supports nested groups.) + * + * For example, if set $allowGroups = ['dav-access'], and the corresponding + * FreeIPA group looks like this: + * + * $ ipa group-show dav-access + * Group name: dav-access + * Description: CalDAV/CardDAV access + * Member groups: accounting, human-resources + * Indirect Member users: benedict, leo, michael + * + * Then sabredav would only see the following groups: + * - dav-access + * - accounting + * - human-resources + * + * and similarly, only the following users: + * - benedict + * - leo + * - michael + * + * If you don't set $allowedGroups, then all users and groups in your FreeIPA + * domain will be visible to sabredav. I don't recommend doing this, for two + * reasons: + * + * 1. It results in poor client experience by littering the interface with a + * bunch of groups that no one will ever use. + * + * 2. Sabredav makes a *lot* of group membership queries, seemingly on every + * request. Querying group memberships across your entire FreeIPA domain on + * every CalDAV operation is ridiculously expensive. + */ + +declare(strict_types=1); + +namespace FreeIPA; + +class PrincipalBackend extends \Sabre\DAVACL\PrincipalBackend\AbstractBackend { + + const PRINCIPAL_PREFIX = 'principals/'; + + const PROXY_CHILDREN = [ + 'calendar-proxy-read', + 'calendar-proxy-write' + ]; + + protected $ipa; + protected $allowedGroups; + + public function __construct(\FreeIPA\Connection $ipa, $allowedGroups = []) { + $this->ipa = $ipa; + $this->allowedGroups = $allowedGroups; + } + + /** + * Splits a string on the path separator (/). If any resulting substrings are + * empty, they are discarded. + * + * For example: + * splitPath('/one//two/') -> ['one', 'two'] + * + * @param string $path + * @return array + */ + protected static function splitPath($path) { + return preg_split('/\//', $path, -1, PREG_SPLIT_NO_EMPTY); + } + + /** + * Returns an array of principals for each user and group in the FreeIPA domain, + * subject to $allowedGroups. + * + * If $searchProperties is specified, only users or groups matching the given + * criteria are returned. + * + * If a matching user and group both have the same name, the group is ignored. + * + * @param array $searchProperties : DAV search properties + * @param string $test : either 'anyof' or 'allof' + * + * @return array : array of associative arrays, each representing a principal + */ + protected function getPrincipals(array $searchProperties = [], $test = 'anyof') { + $principals = []; + + // Get groups. + foreach(Group::search($this->ipa, $searchProperties, $test, $this->allowedGroups) as $group) { + $principals[$group->getName()] = $group->toPrincipal(); + } + + // Get users. If a user and a group have the name name, the user wins. + foreach(User::search($this->ipa, $searchProperties, $test, $this->allowedGroups) as $user) { + $principals[$user->getUid()] = $user->toPrincipal(); + } + + return array_values($principals); + } + + /** + * Returns a principal for the user or group with the given name, subject to + * $allowedGroups. + * + * If $searchProperties is specified, only a user or group matching the given + * criteria is returned. + * + * If a matching user and group both have the same name, the user principal is + * returned. + * + * If no matching user or group is found, null is returned. + * + * @param string $name : user or group name + * @param array $searchProperties : DAV search properties + * @param string $test : either 'anyof' or 'allof' + * + * @return array|null : associative array representing the principal + */ + protected function getPrincipal($name, array $searchProperties = [], $test = 'anyof') { + if ($user = User::get($this->ipa, $name, $searchProperties, $test, $this->allowedGroups)) { + return $user->toPrincipal(); + } elseif ($group = Group::get($this->ipa, $name, $searchProperties, $test, $this->allowedGroups)) { + return $group->toPrincipal(); + } + return null; + } + + /** + * Returns an array of principals corresponding to each DAV proxy principal for + * the given user or group, subject to $allowedGroups. + * + * If $searchProperties is specified, only proxy principals for a user or group + * matching the given criteria are considered. + * + * If a matching user and group both have the same name, the user is used. + * + * @param string $name : user or group name + * @param array $searchProperties : DAV search properties + * @param string $test : either 'anyof' or 'allof' + * + * @return array : array of associative arrays, each representing a proxy principal + */ + protected function getPrincipalChildren($name, array $searchProperties = [], $test = 'anyof') { + $principals = []; + + if ($parent = $this->getPrincipal($name, $searchProperties, $test)) { + foreach (self::PROXY_CHILDREN as $child) { + $principals[] = [ 'uri' => "$parent[uri]/$child" ]; + } + } + return $principals; + } + + /** + * Returns a principals corresponding to the requested proxy principal for + * the given user or group, subject to $allowedGroups. + * + * If $searchProperties is specified, only a user or group matching the given + * criteria is considered. + * + * If no matching user or group is found, null is returned. + * + * @param string $name : user or group name + * @param string $childName : proxy principal name + * @param array $searchProperties : DAV search properties + * @param string $test : either 'anyof' or 'allof' + * + * @return array|null : associative array representing the proxy principal + */ + protected function getPrincipalChild($name, $childName, array $searchProperties = [], $test = 'anyof') { + if (in_array($childName, self::PROXY_CHILDREN)) { + if ($parent = $this->getPrincipal($name, $searchProperties, $test)) { + return [ 'uri' => "$parent[uri]/$childName" ]; + } + } + return null; + } + + /** + * Returns a list of principals based on a prefix. + * + * This prefix will often contain something like 'principals'. You are only + * expected to return principals that are in this base path. + * + * You are expected to return at least a 'uri' for every user, you can + * return any additional properties if you wish so. Common properties are: + * {DAV:}displayname + * {http://sabredav.org/ns}email-address - This is a custom SabreDAV + * field that's actually injected in a number of other properties. If + * you have an email address, use this property. + * + * @param string $prefixPath + * + * @return array + */ + public function getPrincipalsByPrefix($prefixPath) { + $parts = self::splitPath($prefixPath); + + if ($parts[0] == 'principals') { + switch (count($parts)) { + case 1: return $this->getPrincipals(); + case 2: return $this->getPrincipalChildren($parts[1]); + } + } + return []; + } + + /** + * Returns a specific principal, specified by it's path. + * The returned structure should be the exact same as from + * getPrincipalsByPrefix. + * + * @param string $path + * + * @return array + */ + public function getPrincipalByPath($path) { + $parts = self::splitPath($path); + + if ($parts[0] == 'principals') { + switch(count($parts)) { + case 2: return $this->getPrincipal($parts[1]); + case 3: return $this->getPrincipalChild($parts[1], $parts[2]); + } + } + return null; + } + + /** + * Updates one ore more webdav properties on a principal. + * + * The list of mutations is stored in a Sabre\DAV\PropPatch object. + * To do the actual updates, you must tell this object which properties + * you're going to process with the handle() method. + * + * Calling the handle method is like telling the PropPatch object "I + * promise I can handle updating this property". + * + * Read the PropPatch documentation for more info and examples. + * + * @param string $path + */ + public function updatePrincipal($path, \Sabre\DAV\PropPatch $propPatch) { + throw new \Sabre\DAV\Exception\Forbidden('Permission denied to modify LDAP-backed principal'); + } + + /** + * This method is used to search for principals matching a set of + * properties. + * + * This search is specifically used by RFC3744's principal-property-search + * REPORT. + * + * The actual search should be a unicode-non-case-sensitive search. The + * keys in searchProperties are the WebDAV property names, while the values + * are the property values to search on. + * + * By default, if multiple properties are submitted to this method, the + * various properties should be combined with 'AND'. If $test is set to + * 'anyof', it should be combined using 'OR'. + * + * This method should simply return an array with full principal uri's. + * + * If somebody attempted to search on a property the backend does not + * support, you should simply return 0 results. + * + * You can also just return 0 results if you choose to not support + * searching at all, but keep in mind that this may stop certain features + * from working. + * + * @param string $prefixPath + * @param string $test + * + * @return array + */ + public function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof') { + $principals = []; + $parts = self::splitPath($prefixPath); + + if ($parts[0] == 'principals') { + switch (count($parts)) { + case 1: $principals = $this->getPrincipals($searchProperties, $test); + case 2: $principals = $this->getPrincipalChildren($parts[1], $searchProperties, $test); + } + } + return array_map(function($p) { return $p['uri']; }, $principals); + } + + /** + * Finds a principal by its URI. + * + * This method may receive any type of uri, but mailto: addresses will be + * the most common. + * + * Implementation of this API is optional. It is currently used by the + * CalDAV system to find principals based on their email addresses. If this + * API is not implemented, some features may not work correctly. + * + * This method must return a relative principal path, or null, if the + * principal was not found or you refuse to find it. + * + * @param string $uri + * @param string $principalPrefix + * + * @return string|null + */ + public function findByUri($uri, $principalPrefix) { + $uriParts = \Sabre\Uri\parse($uri); + $prefixParts = self::splitPath($principalPrefix); + + if (empty($uriParts['path'])) { + return null; + } + + if ('mailto' === $uriParts['scheme']) { + if ($prefixParts === ['principals']) { + $results = $this->getPrincipals(['{http://sabredav.org/ns}email-address' => $uriParts['path']]); + if (count($results) > 0) { + return $results[0]['uri']; + } + } + } else { + if (array_slice(self::splitPath($uriParts['path']), 0 -1) === array_slice($prefixParts, 0, -1)) { + return $this->getPrincipalByPath($uriParts['path']) ? $uriParts['path'] : null; + } + } + return null; + } + + /** + * Returns the list of members for a group-principal. + * + * @param string $principal + * + * @return array + */ + public function getGroupMemberSet($principal) { + $parts = self::splitPath($principal); + + if (count($parts) == 2 && $parts[0] == 'principals') { + if ($group = Group::get($this->ipa, $parts[1])) { + return $group->getMemberPrincipals($this->ipa); + } elseif ($user = User::get($this->ipa, $parts[1])) { + return []; // if principal is a user, just return nothing + } else { + throw new \Sabre\DAV\Exception('Principal not found'); + } + } + return []; + } + + /** + * Returns the list of groups a principal is a member of. + * + * @param string $principal + * + * @return array + */ + public function getGroupMembership($principal) { + $parts = self::splitPath($principal); + + if (count($parts) == 2 && $parts[0] == 'principals') { + if ($user = User::get($this->ipa, $parts[1])) { + return $user->getGroupPrincipals($this->ipa, $this->allowedGroups); + } elseif ($group = Group::get($this->ipa, $parts[1])) { + return []; // if principal is a group, just return nothing + } else { + throw new \Sabre\DAV\Exception('Principal not found'); + } + } + return []; + } + + /** + * Updates the list of group members for a group principal. + * + * The principals should be passed as a list of uri's. + * + * @param string $principal + */ + public function setGroupMemberSet($principal, array $members) { + throw new \Sabre\DAV\Exception\Forbidden('Permission denied to modify LDAP-backed principal'); + } + + /** + * Sets the groups from which user and group principals will be considered. + * + * @param array $allowedGroups + */ + public function setAllowedGroups(array $allowedGroups) { + $this->allowedGroups = $allowedGroups; + } +} diff --git a/src/User.php b/src/User.php new file mode 100644 index 0000000..c4e56eb --- /dev/null +++ b/src/User.php @@ -0,0 +1,204 @@ +<?php +/** -JMJ- + * + * FreeIPA user definition + * + * @author stonewall + * @license https://opensource.org/licenses/MIT + * @version 0.01 + * + * This class represents a FreeIPA user object. It cannot not be instantiated + * directly. Rather, use the static User::get() or User::search() methods + * to retrieve one or more users. + */ + +declare(strict_types=1); + +namespace FreeIPA; + +class User { + const PRINCIPAL_PREFIX = 'principals/'; + const LDAP_CONTAINER = 'cn=users,cn=accounts'; + const LDAP_OBJECT_CLASS = 'person'; + const LDAP_ATTRIBUTES = ['uid', 'displayname', 'mail']; + + const LDAP_FIELD_MAP = [ + '{DAV:}displayname' => 'displayname', + '{http://sabredav.org/ns}email-address' => 'mail' + ]; + + protected $uid; + protected $displayName; + protected $email; + + protected function __construct($uid, $displayName, $email) { + $this->uid = $uid; + $this->displayName = $displayName; + $this->email = $email; + } + + /** + * Construct a User object from an LDAP user entry. + * + * @param array $entry + * + * @return \FreeIPA\User + */ + protected static function fromLdapEntry(array $entry) { + return new self( + $entry['uid'][0], + isset($entry['displayname'][0]) ? $entry['displayname'][0] : $entry['uid'][0], + $entry['mail'][0] + ); + } + + /** + * Convert a username to an escaped relative LDAP DN + * + * For example: + * getRelativeDn('joe') -> 'uid=\6a\6f\65,cn=users,cn=accounts' + * + * @param string $username + * + * @return string + */ + public static function getRelativeDn($username) { + return 'uid=' . ldap_escape($username) . ',' . self::LDAP_CONTAINER; + } + + + /** + * Returns an array of User objects for each user in the FreeIPA directory matching + * the given DAV search properties, subject to $allowedGroups. + * + * @param \FreeIPA\Connection $ipaConn : freeipa connection object + * @param array $searchProperties : search conditions, as requested by SabreDAV + * @param string $test : either 'allof' or 'anyof' + * @param array $allowedGroups : only consider members of the given groups + * + * @return array + */ + public static function search( + \FreeIPA\Connection $ipaConn, + array $searchProperties = [], + $test = 'allof', + array $allowedGroups = []) + { + $users = []; + + // for each user matching filter + if ($entries = $ipaConn->search( + self::LDAP_CONTAINER, + Util::buildFilter('allof', + ['objectClass', self::LDAP_OBJECT_CLASS], + 'mail=*', + Util::buildMemberOfFilter($ipaConn, $allowedGroups), + Util::buildPrincipalFilter($searchProperties, self::LDAP_FIELD_MAP, $test)), + self::LDAP_ATTRIBUTES)) + { + for ($i = 0; $i < $entries['count']; $i++) { + $users[] = self::fromLdapEntry($entries[$i]); + } + } + return $users; + } + + /** + * Returns the User from FreeIPA with the given username that matches the + * given DAV search properties, subject to $allowedGroups. + * + * If no matching user is found, null is returned. + * + * @param \FreeIPA\Connection $ipaConn : freeipa connection object + * @param string $username : freeipa user uid + * @param array $searchProperties : search conditions, as requested by SabreDAV + * @param string $test : either 'allof' or 'anyof' + * @param array $allowedGroups : only consider members of the given groups + * + * @return \FreeIPA\User|null + */ + public static function get( + \FreeIPA\Connection $ipaConn, + $username, + array $searchProperties = [], + $test = 'allof', + array $allowedGroups = []) + { + if ($entry = $ipaConn->read( + self::getRelativeDn($username), + Util::buildFilter('allof', + ['objectClass', self::LDAP_OBJECT_CLASS], + 'mail=*', + Util::buildMemberOfFilter($ipaConn, $allowedGroups), + Util::buildPrincipalFilter($searchProperties, self::LDAP_FIELD_MAP, $test)), + self::LDAP_ATTRIBUTES)) + { + return self::fromLdapEntry($entry); + } + return null; + } + + /** + * Returns an array of principal URIs corresponding to each of the user's + * groups, subject to $allowedGroups. + * + * @param \FreeIPA\Connection $ipaConn : freeipa connection object + * @param array $allowedGroups : only consider the given groups + * + * @return array + */ + public function getGroupPrincipals($ipaConn, $allowedGroups = []) { + $groupPrincipals = []; + + // get the user's groups + if ($userEntry = $ipaConn->read( + self::getRelativeDn($this->uid), + Util::buildFilter('allof', + ['objectClass', self::LDAP_OBJECT_CLASS], + 'mail=*', + Util::buildMemberOfFilter($ipaConn, $allowedGroups)), + ['uid', 'memberof'])) + { + // get all allowed groups (and resolve any nested groups) + if ($allowedGroupEntries = $ipaConn->search( + Group::LDAP_CONTAINER, + Util::buildFilter('allof', + ['objectClass', Group::LDAP_OBJECT_CLASS], + Util::buildMemberOfFilter($ipaConn, $allowedGroups, true)), + ['cn'])) + { + // get the intersection of user's groups and allowed groups + for ($i = 0; $i < $userEntry['memberof']['count']; $i++) { + for ($j = 0; $j < $allowedGroupEntries['count']; $j++) { + if ($userEntry['memberof'][$i] == $allowedGroupEntries[$j]['dn']) { + $groupPrincipals[] = Group::PRINCIPAL_PREFIX . $allowedGroupEntries[$j]['cn'][0]; + } + } + } + } + } + return $groupPrincipals; + } + + /** + * Convert a User to SabreDAV's representation of a principal. + * + * @return array + */ + public function toPrincipal() { + return [ + 'uri' => self::PRINCIPAL_PREFIX . $this->uid, + '{DAV:}displayname' => $this->displayName, + '{http://sabredav.org/ns}email-address' => $this->email + ]; + } + + /** + * Get the username. + * + * @return string + */ + public function getUid() { + return $this->uid; + } +} diff --git a/src/Util.php b/src/Util.php new file mode 100644 index 0000000..a145ff0 --- /dev/null +++ b/src/Util.php @@ -0,0 +1,114 @@ +<?php +/** -JMJ- + * + * FreeIPA utility class + * + * @author stonewall + * @license https://opensource.org/licenses/MIT + * @version 0.01 + * + * This class contains various helper functions for querying FreeIPA. + * Static methods only. + */ + +declare(strict_types=1); + +namespace FreeIPA; + +class Util { + + private function __construct() { } + + /** + * Given a list of conditions, construct an ldap filter. The conditions can + * be raw strings (eg. "attr=value") or an array of attribute-value pairs. + * + * If a string is given, the enclosing parens are optional. + * + * If no conditions are provided, then the empty string is returned. + * + * For example: + * buildFilter('allof', 'mail=*', ['givenname', 'padre', 'sn', 'pio']) + * -> '(&(mail=*)(givenname=padre)(sn=pio))' + * + * @param string $test : either 'allof' (&) or 'anyof' (|) + * @param mixed string|array : filter conditions + * + * @return string + */ + public static function buildFilter($test, ...$conditions) { + $filter = ''; + + foreach ($conditions as $condition) { + if (is_array($condition)) { + for ($i = 0; $i < count($condition); $i+=2) { + $filter .= "({$condition[$i]}={$condition[$i+1]})"; + } + } elseif (!empty($condition)) { + if ($condition[0] != '(' && $condition[-1] != ')') { + $condition = "($condition)"; + } + $filter .= $condition; + } + } + + if ($filter) { + $filter = '(' . ($test === 'anyof' ? '|' : '&') . $filter . ')'; + } + return $filter; + } + + /** + * Given a list of DAV search properties and a mapping of DAV property names + * to LDAP attribute names, construct an LDAP filter. + * + * If no conditions are provided, then the empty string is returned. + * + * If a search property is given that does not exist in the LDAP mapping, then + * a BadRequest exception is thrown. + * + * @param array $searchProperties : search conditions, as requested by SabreDAV + * @param array $fieldMap : mapping of DAV properties to LDAP attributes + * @param string $test : either 'allof' of 'anyof' + * + * @return string + */ + public static function buildPrincipalFilter($searchProperties = [], $fieldMap = [], $test = 'allof') { + $conditions = []; + + foreach ($searchProperties as $property => $value) { + if (isset($fieldMap[$property])) { + $conditions[] = [$fieldMap[$property].':caseIgnoreIA5Match:', '*'.ldap_escape($value).'*']; + } else { + throw new \Sabre\DAV\Exception\BadRequest("Unknown property: $property"); + } + } + return self::buildFilter($test, ...$conditions); + } + + /** + * Given a list of group names, construct an ldap filter to test for membership + * in at least one of the groups. + * + * If $includeSelf == true, then each group object will be matched along with + * its members. The option is useful for getting all the groups in a nested + * hierarchy. + * + * @param \FreeIPA\Connection $ipaConn : freeipa connection object + * @param array $groupnames : list of group names + * @param bool $includeSelf : whether to match the groups themselves + * + * @return string + */ + public static function buildMemberOfFilter(\FreeIPA\Connection $ipaConn, $groupnames, $includeSelf = false) { + $conditions = []; + + foreach ($groupnames as $groupname) { + $conditions[] = ['memberOf', $ipaConn->resolveDn(Group::getRelativeDn($groupname))]; + if ($includeSelf) { + $conditions[] = 'cn=' . ldap_escape($groupname); + } + } + return self::buildFilter('anyof', ...$conditions); + } +} |