aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorStonewall Jackson <stonewall@sacredheartsc.com>2023-01-18 18:33:58 -0500
committerStonewall Jackson <stonewall@sacredheartsc.com>2023-01-18 18:33:58 -0500
commitf9870c623cc8cb115016b22eb893e2e845e283c3 (patch)
tree4c461a3c5b342b816bbf8f966d7d27c3141e7ec0
downloadsabredav-freeipa-f9870c623cc8cb115016b22eb893e2e845e283c3.tar.gz
sabredav-freeipa-f9870c623cc8cb115016b22eb893e2e845e283c3.zip
initial commit
-rw-r--r--.gitignore5
-rw-r--r--LICENSE22
-rw-r--r--README.md179
-rw-r--r--composer.json12
-rw-r--r--pgsql.schema.sql184
-rw-r--r--server.example.php88
-rw-r--r--src/AuthBackend.php205
-rw-r--r--src/Connection.php228
-rw-r--r--src/Group.php182
-rw-r--r--src/PrincipalBackend.php419
-rw-r--r--src/User.php204
-rw-r--r--src/Util.php114
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
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..788ae6d
--- /dev/null
+++ b/LICENSE
@@ -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);
+ }
+}