Add authMethod for /dav

Add guard authentification for the administration interface, with a login form
This commit is contained in:
Cyril 2019-10-31 16:14:20 +01:00
parent 75a84eece5
commit d1f1981ac7
13 changed files with 429 additions and 33 deletions

4
.env
View File

@ -42,6 +42,10 @@ ADMIN_PASSWORD=test
# Auth Realm for HTTP auth
AUTH_REALM=SabreDAV
# Auth Method for the frontend
# "Basic" or "Digest"
AUTH_METHOD=Digest
# Do we enable caldav and carddav ?
CALDAV_ENABLED=true
CARDDAV_ENABLED=true

View File

@ -1,22 +1,23 @@
security:
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
providers:
in_memory: { memory: null }
admin_user_provider:
id: App\Security\AdminUserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
anonymous: true
guard:
authenticators:
- App\Security\LoginFormAuthenticator
provider: admin_user_provider
logout:
path: app_logout
target: dashboard
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#firewalls-authentication
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
- { path: ^/$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/dav, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/dashboard, roles: ROLE_ADMIN }
- { path: ^/users, roles: ROLE_ADMIN }

View File

@ -17,23 +17,28 @@ services:
resource: '../src/*'
exclude: '../src/{DependencyInjection,Entity,Migrations,Tests,Kernel.php}'
App\Services\Utils:
arguments:
$authRealm: "%env(AUTH_REALM)%"
# controllers are imported separately to make sure services can be injected
# as action arguments even if you don't extend any base controller class
App\Controller\:
resource: '../src/Controller'
tags: ['controller.service_arguments']
App\Controller\DAVController:
arguments:
$calDAVEnabled: "%env(bool:CALDAV_ENABLED)%"
$cardDAVEnabled: "%env(bool:CARDDAV_ENABLED)%"
$webDAVEnabled: "%env(bool:WEBDAV_ENABLED)%"
$inviteAddress: "%env(INVITE_FROM_ADDRESS)%"
$authMethod: "%env(AUTH_METHOD)%"
$authRealm: "%env(AUTH_REALM)%"
$publicDir: "%env(PUBLIC_DIR)%"
$tmpDir: "%env(TMP_DIR)%"
App\Controller\AdminController:
App\Security\LoginFormAuthenticator:
arguments:
$authRealm: "%env(AUTH_REALM)%"
$adminLogin: "%env(ADMIN_LOGIN)%"
$adminPassword: "%env(ADMIN_PASSWORD)%"

View File

@ -14,6 +14,7 @@ use App\Entity\User;
use App\Form\AddressBookType;
use App\Form\CalendarInstanceType;
use App\Form\UserType;
use App\Services\Utils;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
@ -21,18 +22,6 @@ use Symfony\Component\Translation\TranslatorInterface;
class AdminController extends AbstractController
{
/**
* HTTP authentication realm.
*
* @var string
*/
protected $authRealm;
public function __construct(?string $authRealm)
{
$this->authRealm = $authRealm ?? 'SabreDAV';
}
/**
* @Route("/dashboard", name="dashboard")
*/
@ -72,7 +61,7 @@ class AdminController extends AbstractController
* @Route("/users/new", name="user_create")
* @Route("/users/edit/{username}", name="user_edit")
*/
public function userCreate(Request $request, ?string $username, TranslatorInterface $trans)
public function userCreate(Utils $utils, Request $request, ?string $username, TranslatorInterface $trans)
{
if ($username) {
$user = $this->get('doctrine')->getRepository(User::class)->findOneByUsername($username);
@ -102,7 +91,7 @@ class AdminController extends AbstractController
// The user is not new and does not want to change its password
$user->setPassword($oldHash);
} else {
$hash = md5($user->getUsername().':'.$this->authRealm.':'.$user->getPassword());
$hash = $utils->hashPassword($user->getUsername(), $user->getPassword());
$user->setPassword($hash);
}

View File

@ -3,12 +3,16 @@
namespace App\Controller;
use App\Entity\User;
use App\Services\BasicAuth;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class DAVController extends AbstractController
{
const AUTH_DIGEST = 'Digest';
const AUTH_BASIC = 'Basic';
/**
* Is CalDAV enabled?
*
@ -58,12 +62,14 @@ class DAVController extends AbstractController
*/
protected $tmpDir;
public function __construct(bool $calDAVEnabled = true, bool $cardDAVEnabled = true, bool $webDAVEnabled = false, ?string $inviteAddress, ?string $authRealm, ?string $publicDir, ?string $tmpDir)
public function __construct(bool $calDAVEnabled = true, bool $cardDAVEnabled = true, bool $webDAVEnabled = false, ?string $inviteAddress, ?string $authMethod, ?string $authRealm, ?string $publicDir, ?string $tmpDir)
{
$this->calDAVEnabled = $calDAVEnabled;
$this->cardDAVEnabled = $cardDAVEnabled;
$this->webDAVEnabled = $webDAVEnabled;
$this->inviteAddress = $inviteAddress ?? null;
$this->authMethod = $authMethod;
$this->authRealm = $authRealm ?? User::DEFAULT_AUTH_REALM;
$this->publicDir = $publicDir;
@ -81,15 +87,25 @@ class DAVController extends AbstractController
/**
* @Route("/dav/{path}", name="dav", requirements={"path":".*"})
*/
public function dav()
public function dav(BasicAuth $basicAuthBackend)
{
$pdo = $this->get('doctrine')->getEntityManager()->getConnection()->getWrappedConnection();
/**
/*
* The backends.
*/
$authBackend = new \Sabre\DAV\Auth\Backend\PDO($pdo);
switch ($this->authMethod) {
case self::AUTH_DIGEST:
$authBackend = new \Sabre\DAV\Auth\Backend\PDO($pdo);
break;
case self::AUTH_BASIC:
default:
$authBackend = $basicAuthBackend;
break;
}
$authBackend->setRealm($this->authRealm);
$principalBackend = new \Sabre\DAVACL\PrincipalBackend\PDO($pdo);
/**

View File

@ -0,0 +1,36 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
/**
* @Route("/login", name="app_login")
*/
public function login(AuthenticationUtils $authenticationUtils): Response
{
// if ($this->getUser()) {
// return $this->redirectToRoute('target_path');
// }
// get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();
// last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]);
}
/**
* @Route("/logout", name="app_logout")
*/
public function logout()
{
throw new \Exception('This method can be blank - it will be intercepted by the logout key on your firewall');
}
}

70
src/Entity/AdminUser.php Normal file
View File

@ -0,0 +1,70 @@
<?php
namespace App\Entity;
use Symfony\Component\Security\Core\User\UserInterface;
class AdminUser implements UserInterface
{
private $username;
private $password;
public function __construct(string $username, string $password)
{
$this->username = $username;
$this->password = $password;
}
/**
* @return (Role|string)[] The user roles
*/
public function getRoles()
{
return ['ROLE_ADMIN'];
}
/**
* Returns the password used to authenticate the user.
*
* This should be the encoded password. On authentication, a plain-text
* password will be salted, encoded, and then compared to this value.
*
* @return string|null The encoded password if any
*/
public function getPassword()
{
return $this->password;
}
/**
* Returns the salt that was originally used to encode the password.
*
* This can return null if the password was not encoded using a salt.
*
* @return string|null The salt
*/
public function getSalt()
{
return null;
}
/**
* Returns the username used to authenticate the user.
*
* @return string The username
*/
public function getUsername()
{
return $this->username;
}
/**
* Removes sensitive data from the user.
*
* This is important if, at any given point, sensitive information like
* the plain-text password is stored on this object.
*/
public function eraseCredentials()
{
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Security;
use App\Entity\AdminUser;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
class AdminUserProvider implements UserProviderInterface
{
/**
* Symfony calls this method if you use features like switch_user
* or remember_me.
*
* If you're not using these features, you do not need to implement
* this method.
*
*
* @param mixed $username
*
* @throws UsernameNotFoundException if the user is not found
*
* @return UserInterface
*/
public function loadUserByUsername($username)
{
throw new \Exception('Not implemented, because not needed');
}
/**
* Refreshes the user after being reloaded from the session.
*
* When a user is logged in, at the beginning of each request, the
* User object is loaded from the session and then this method is
* called. Your job is to make sure the user's data is still fresh by,
* for example, re-querying for fresh User data.
*
* If your firewall is "stateless: true" (for a pure API), this
* method is not called.
*
* @return UserInterface
*/
public function refreshUser(UserInterface $user)
{
if (!$user instanceof AdminUser) {
throw new UnsupportedUserException(sprintf('Invalid user class "%s".', get_class($user)));
}
return $user;
}
/**
* Tells Symfony to use this provider for this User class.
*
* @param mixed $class
*/
public function supportsClass($class)
{
return AdminUser::class === $class;
}
}

View File

@ -0,0 +1,92 @@
<?php
namespace App\Security;
use App\Entity\AdminUser;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
use TargetPathTrait;
private $urlGenerator;
private $csrfTokenManager;
private $adminLogin;
private $adminPassword;
public function __construct(UrlGeneratorInterface $urlGenerator, CsrfTokenManagerInterface $csrfTokenManager, string $adminLogin, string $adminPassword)
{
$this->urlGenerator = $urlGenerator;
$this->csrfTokenManager = $csrfTokenManager;
$this->adminLogin = $adminLogin;
$this->adminPassword = $adminPassword;
}
public function supports(Request $request)
{
return 'app_login' === $request->attributes->get('_route')
&& $request->isMethod('POST');
}
public function getCredentials(Request $request)
{
$credentials = [
'username' => $request->request->get('username'),
'password' => $request->request->get('password'),
'csrf_token' => $request->request->get('_csrf_token'),
];
$request->getSession()->set(
Security::LAST_USERNAME,
$credentials['username']
);
return $credentials;
}
public function getUser($credentials, UserProviderInterface $userProvider)
{
$token = new CsrfToken('authenticate', $credentials['csrf_token']);
if (!$this->csrfTokenManager->isTokenValid($token)) {
throw new InvalidCsrfTokenException();
}
if ($credentials['username'] !== $this->adminLogin) {
// fail authentication with a custom error
throw new CustomUserMessageAuthenticationException('Username could not be found.');
}
return new AdminUser($this->adminLogin, $this->adminPassword);
}
public function checkCredentials($credentials, UserInterface $user)
{
return $user->getPassword() === $credentials['password'];
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
return new RedirectResponse($targetPath);
}
return new RedirectResponse($this->urlGenerator->generate('dashboard'));
}
protected function getLoginUrl()
{
return $this->urlGenerator->generate('app_login');
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Services;
use App\Repository\UserRepository;
use Sabre\DAV\Auth\Backend\AbstractBasic;
final class BasicAuth extends AbstractBasic
{
/**
* Utils class.
*
* @var Utils
*/
private $utils;
/**
* Doctrine User repository.
*
* @var UserRepository
*/
private $userRepository;
public function __construct(UserRepository $userRepository, Utils $utils)
{
$this->utils = $utils;
$this->userRepository = $userRepository;
}
/**
* {@inheritdoc}
*/
protected function validateUserPass($username, $password)
{
$user = $this->userRepository->findOneByUsername($username);
if (!$user) {
return false;
}
$hash = $this->utils->hashPassword($username, $password);
if ($hash === $user->getPassword()) {
$this->currentUser = $username;
return true;
}
return false;
}
}

26
src/Services/Utils.php Normal file
View File

@ -0,0 +1,26 @@
<?php
namespace App\Services;
final class Utils
{
/**
* Authentication realm.
*
* @var string
*/
private $authRealm;
public function __construct(?string $authRealm)
{
$this->authRealm = $authRealm ?? User::DEFAULT_AUTH_REALM;
}
/**
* Hash a password according to the realm.
*/
public function hashPassword(string $username, string $password): string
{
return md5($username.':'.$this->authRealm.':'.$password);
}
}

View File

@ -15,6 +15,18 @@
<a class="nav-link" href="{{ path('users') }}">{{ "title.users_and_resources"|trans }}</a>
</li>
</ul>
{% if app.user %}
<ul class="navbar-nav ml-auto">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ app.user.username }}
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
<a class="dropdown-item" href="{{ path('app_logout') }}">Logout</a>
</div>
</li>
</ul>
{% endif %}
</div>
</div>
</nav>

View File

@ -0,0 +1,31 @@
{% extends 'base.html.twig' %}
{% set menu = null %}
{% block body %}
<form method="post">
{% if error %}
<div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
{% endif %}
{% if app.user %}
<div class="mb-3">
You are logged in as {{ app.user.username }}, <a href="{{ path('app_logout') }}">Logout</a>
</div>
{% endif %}
<h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
<div class="form-group">
<label for="inputUsername" class="sr-only">Login</label>
<input type="text" value="{{ last_username }}" name="username" id="inputUsername" class="form-control" placeholder="Username" required autofocus>
<small id="emailHelp" class="form-text text-muted">We'll never share your email with anyone else.</small>
</div>
<div class="form-group">
<label for="inputPassword" class="sr-only">Password</label>
<input type="password" name="password" id="inputPassword" class="form-control" placeholder="Password" required>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
</form>
{% endblock %}