Skip to content
Merged

Fixes #289

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,15 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

- name: Install MySQL / GD / ZIP PHP extensions
- name: Install MySQL / GD / ZIP / LDAP PHP extensions
run: |
apk add $PHPIZE_DEPS icu-libs icu-dev libpng-dev libzip-dev
apk add $PHPIZE_DEPS icu-libs icu-dev libpng-dev libzip-dev openldap-dev
docker-php-ext-configure intl
docker-php-ext-configure gd
docker-php-ext-configure zip
docker-php-ext-install pdo pdo_mysql intl gd zip
# ext-ldap is optional for Davis (only AUTH_METHOD=LDAP needs it) but the LDAP tests
# skip themselves without it, and we would rather run them
docker-php-ext-install pdo pdo_mysql intl gd zip ldap

- name: Install Composer
run: wget -qO - https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet
Expand Down
21 changes: 4 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,24 +438,11 @@ More examples and information [here](https://symfony.com/doc/current/setup/web_s

Web-based protocols like CalDAV and CardDAV can be found using a discovery service. Some clients require that you implement a path prefix to point to the correct location for your service. See [here](https://en.wikipedia.org/wiki/List_of_/.well-known/_services_offered_by_webservers) for more info.

If you use Apache as your webserver, you can enable the redirections with:
Davis answers `/.well-known/caldav` and `/.well-known/carddav` itself and redirects them to its DAV endpoint, so **no web server configuration is needed**. Because the redirect is built from the application's own base path, it also works when Davis is installed in a sub-directory (`https://example.org/davis/`), which a hard-coded `/dav/` rewrite does not.

```apache
RewriteEngine On
RewriteRule ^\.well-known/carddav /dav/ [R=301,L]
RewriteRule ^\.well-known/caldav /dav/ [R=301,L]
```

Make sure that `mod_rewrite` is enabled on your installation beforehand.

If you use Nginx, you can add this to your configuration:

```nginx
location / {
rewrite ^/.well-known/carddav /dav/ redirect;
rewrite ^/.well-known/caldav /dav/ redirect;
}
```
> [!NOTE]
>
> If your web server still rewrites these two paths itself (earlier versions of this README suggested doing so), you can remove those rules: they take precedence over Davis and will send clients to the wrong place on a sub-directory installation.

# 🐳 Dockerized installation

Expand Down
4 changes: 0 additions & 4 deletions docker/configurations/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,6 @@
}

:9000 {
# Redirect .well-known
redir /.well-known/caldav /dav/
redir /.well-known/carddav /dav/

root * /var/www/davis/public
php_fastcgi unix//var/run/php-fpm/php-fpm.sock {
# Preserve the original X-Forwarded-Proto from upstream, as it might be HTTPS
Expand Down
3 changes: 0 additions & 3 deletions docker/configurations/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ server {
root /var/www/davis/public/;
index index.php;

rewrite ^/.well-known/caldav /dav/ redirect;
rewrite ^/.well-known/carddav /dav/ redirect;

charset utf-8;

# Security headers (add `Strict-Transport-Security` once TLS is terminated in front of nginx)
Expand Down
4 changes: 0 additions & 4 deletions public/.htaccess
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,6 @@ DirectoryIndex index.php
<IfModule mod_rewrite.c>
RewriteEngine On

# Add .well-known redirections
RewriteRule ^\.well-known/carddav /dav/ [R=301,L]
RewriteRule ^\.well-known/caldav /dav/ [R=301,L]

# Determine the RewriteBase automatically and set it as environment variable.
# If you are using Apache aliases to do mass virtual hosting or installed the
# project in a subdirectory, the base path will be prepended to allow proper
Expand Down
3 changes: 2 additions & 1 deletion src/Controller/Api/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Entity\CalendarSubscription;
use App\Entity\Principal;
use App\Entity\User;
use App\Services\Utils;
use Doctrine\Persistence\ManagerRegistry;
use Sabre\DAV\Sharing\Plugin as SharingPlugin;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
Expand All @@ -26,7 +27,7 @@ class ApiController extends AbstractController
*/
private function validateUsername(string $username): bool
{
return !empty($username) && is_string($username) && !preg_match('/[^a-zA-Z0-9_.@-]/', $username);
return Utils::isValidUsername($username);
}

/**
Expand Down
25 changes: 24 additions & 1 deletion src/Controller/DAVController.php
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,20 @@ private function initExceptionListener()
});
}

/**
* Service discovery (RFC 6764).
*
* This lives in the application rather than in each web server's configuration so that
* the redirect is built from the real base path: a hard-coded `/dav/` sends clients to
* the wrong place whenever Davis is installed under a sub-directory.
*/
#[Route('/.well-known/caldav', name: 'well_known_caldav')]
#[Route('/.well-known/carddav', name: 'well_known_carddav')]
public function wellKnown(): Response
{
return $this->redirectToRoute('dav', ['path' => ''], Response::HTTP_MOVED_PERMANENTLY);
}

#[Route('/dav/{path}', name: 'dav', requirements: ['path' => '.*'])]
public function dav(Request $request, ?string $path, ?Profiler $profiler = null)
{
Expand All @@ -327,7 +341,16 @@ public function dav(Request $request, ?string $path, ?Profiler $profiler = null)

// Adapted from CorePlugin's httpOptions()
// https://github.com/sabre-io/dav/blob/master/lib/DAV/CorePlugin.php#L210
$methods = $this->server->getAllowedMethods('');
//
// The methods depend on the node being asked about: MKCALENDAR, for instance, is
// only offered inside a calendar home. Answering for the root instead of the
// requested path told every client the same, incomplete story.
try {
$methods = $this->server->getAllowedMethods($path ?? '');
} catch (\Throwable $e) {
// An unresolvable path should still get a usable answer
$methods = $this->server->getAllowedMethods('');
}

$response->headers->set('Allow', strtoupper(implode(', ', $methods)));
$features = ['1', '3', 'extended-mkcol'];
Expand Down
10 changes: 10 additions & 0 deletions src/Entity/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,18 @@ class User
#[ORM\Column(type: 'integer')]
private $id;

/**
* A username ends up in the principal URI (`principals/<username>`), so it must not carry
* anything that would change that path's structure. Letters, digits and `_ . @ + ' -` are allowed:
* the punctuation is what shows up in mail-derived login names. Enforced when a user is created; existing
* accounts are left alone so that an odd username created before this rule stays editable.
*/
public const USERNAME_PATTERN = '/^[a-zA-Z0-9_.@+\'-]+$/';

#[ORM\Column(type: 'string', length: 255, unique: true)]
#[Assert\NotBlank]
#[Assert\Length(max: 255, groups: ['creation'])]
#[Assert\Regex(pattern: self::USERNAME_PATTERN, message: 'form.username.invalid', groups: ['creation'])]
private $username;

#[ORM\Column(name: 'digesta1', type: 'string', length: 255)]
Expand Down
6 changes: 6 additions & 0 deletions src/Form/UserType.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class UserType extends AbstractType
Expand Down Expand Up @@ -55,6 +56,11 @@ public function configureOptions(OptionsResolver $resolver): void
$resolver->setDefaults([
'new' => false,
'data_class' => User::class,
// The username rule only applies to new accounts: the field is disabled when editing,
// and an account created before the rule (or by LDAP/IMAP) must stay editable.
'validation_groups' => static fn (FormInterface $form): array => $form->getConfig()->getOption('new')
? ['Default', 'creation']
: ['Default'],
]);
}
}
56 changes: 56 additions & 0 deletions src/Services/AbstractAuth.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace App\Services;

use Sabre\DAV\Auth\Backend\AbstractBasic;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;

/**
* Common base for the HTTP Basic authentication backends (internal, IMAP, LDAP).
Expand All @@ -12,24 +14,78 @@
* and an empty password means an *unauthenticated bind* for LDAP servers, which Active
* Directory (and OpenLDAP with `allow bind_anon_cred`) answers with success, i.e. it
* would log the caller in as any user.
*
* It also rejects usernames that would not survive being put in a principal URI. sabre
* derives the principal from the login name (`principals/<username>`), so a name containing
* a slash would address a different, possibly existing, node: `alice/calendar-proxy-write`
* is exactly the URI Davis uses for alice's delegation proxy. Only structural characters are
* refused here, not the stricter set required when creating an account, so that an unusual
* but working username keeps authenticating.
*/
abstract class AbstractAuth extends AbstractBasic
{
/**
* The username as the backend spells it, when that differs from what the client sent.
*/
private ?string $canonicalUsername = null;

/**
* @param string $username
* @param string $password
*/
final protected function validateUserPass($username, $password): bool
{
$this->canonicalUsername = null;

if (!is_string($username) || !is_string($password) || '' === $username || '' === $password) {
return false;
}

if (self::breaksPrincipalUri($username)) {
return false;
}

return $this->checkCredentials($username, $password);
}

/**
* Validates a non-empty username and password against the backend.
*/
abstract protected function checkCredentials(string $username, string $password): bool;

/**
* Backends call this when the directory spells the username differently from what the
* client sent — LDAP matches `ALICE` against `uid=alice` quite happily. The principal is
* then built from that spelling instead, so the login, the account and the principal URI
* cannot drift apart and produce a second, empty account.
*/
protected function setCanonicalUsername(string $username): void
{
// It ends up in a principal URI like any other username
if ('' !== $username && !self::breaksPrincipalUri($username)) {
$this->canonicalUsername = $username;
}
}

/**
* @return array{0: bool, 1: string}
*/
public function check(RequestInterface $request, ResponseInterface $response)
{
$result = parent::check($request, $response);

if (true === $result[0] && null !== $this->canonicalUsername) {
return [true, $this->principalPrefix.$this->canonicalUsername];
}

return $result;
}

private static function breaksPrincipalUri(string $username): bool
{
// Anything that would change the shape of `principals/<username>`:
// [/\\] a forward or back slash, which would add a path segment
// [\x00-\x20\x7f] any control character, plus space (0x20) and DEL (0x7f)
return 1 === preg_match('~[/\\\\]|[\\x00-\\x20\\x7f]~', $username);
}
}
18 changes: 10 additions & 8 deletions src/Services/IMAPAuth.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,17 @@ protected function imapOpen(string $username, string $password): bool
$user = $this->doctrine->getRepository(User::class)->findOneBy(['username' => $username]);

if (!$user) {
// We only have a username, so we use it for displayname and email
$this->utils->createPasswordlessUserWithDefaultObjects($username, $username, $username);

$em = $this->doctrine->getManager();

try {
$em->flush();
} catch (\Exception $e) {
error_log('IMAP Error (flush): '.$e->getMessage());
// We only have a username, so we use it for displayname and email
$this->utils->createPasswordlessUserWithDefaultObjects($username, $username, $username);
$this->doctrine->getManager()->flush();
} catch (\Throwable $e) {
// Letting the login through without a principal would leave the account
// authenticated but unusable: no calendar home, so clients fall back to the
// server root and every write is refused.
error_log('IMAP Error (could not create the user "'.$username.'"): '.$e->getMessage());

return false;
}
}
}
Expand Down
Loading
Loading