diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index b805c0f350..ae2801842d 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -1,8 +1,8 @@ doctrine: dbal: - # TODO In case of special URL characters, this needs to be handled differently url: '%env(resolve:DATABASE_URL)%' charset: UTF8 + schema_filter: ~^(?!rememberme_token)~ # Ignore these in migrations orm: auto_generate_proxy_classes: true naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 0e4cf3d15d..6366543e40 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -10,6 +10,20 @@ security: anonymous: true lazy: true provider: users_in_memory + guard: + authenticators: + - App\Security\Authenticator + logout: + path: logout + # where to redirect after logout + target: main_all + + remember_me: + secret: '%kernel.secret%' + secure: true + httponly: '%remember_me_httponly%' + samesite: '%remember_me_samesite%' + token_provider: 'Symfony\Bridge\Doctrine\Security\RememberMe\DoctrineTokenProvider' # activate different ways to authenticate # https://symfony.com/doc/current/security.html#firewalls-authentication @@ -20,5 +34,5 @@ security: # 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: ^/admin, roles: ROLE_ADMIN } + - { path: ^/settings, roles: ROLE_USER } diff --git a/config/services.yaml b/config/services.yaml index 710504346e..9ae4950028 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -43,3 +43,5 @@ services: App\Core\Queue\MessageHandler: tags: [messenger.message_handler] + + Symfony\Bridge\Doctrine\Security\RememberMe\DoctrineTokenProvider: ~ diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php new file mode 100644 index 0000000000..7e84a0669b --- /dev/null +++ b/src/Controller/SecurityController.php @@ -0,0 +1,29 @@ +getUser()) { + return $this->redirectToRoute('main_all'); + } + + // 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]); + } + + public function logout() + { + throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); + } +} diff --git a/src/Core/UserRoles.php b/src/Core/UserRoles.php new file mode 100644 index 0000000000..eab3fcc058 --- /dev/null +++ b/src/Core/UserRoles.php @@ -0,0 +1,52 @@ +. +// }}} + +namespace App\Core; + +/** + * User role enum + * + * @category User + * @package GNUsocial + * + * @author Hugo Sales + * @copyright 2020 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ +abstract class UserRoles +{ + const ADMIN = 1; + const MODERATOR = 2; + const USER = 4; + + public static function bitmapToStrings(int $r): array + { + $roles = []; + $consts = (new ReflectionClass(__CLASS__))->getConstants(); + while ($r != 0) { + foreach ($consts as $c => $v) { + if ($r & $v !== 0) { + $r &= ~$v; + $roles[] = "ROLE_{$c}"; + } + } + } + return $roles; + } +} diff --git a/src/Entity/RememberMeToken.php b/src/Entity/RememberMeToken.php new file mode 100644 index 0000000000..e90088639e --- /dev/null +++ b/src/Entity/RememberMeToken.php @@ -0,0 +1,114 @@ +. + +// }}} + +namespace App\Entity; + +use DateTimeInterface; + +/** + * Entity for the remember_me token + * + * @category DB + * @package GNUsocial + * + * @author Hugo Sales + * @copyright 2020 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ +class RememberMeToken +{ + // {{{ Autocode + + private string $series; + private string $value; + private \DateTimeInterface $lastUsed; + private string $class; + private string $username; + + public function setSeries(string $series): self + { + $this->series = $series; + return $this; + } + public function getSeries(): string + { + return $this->series; + } + + public function setValue(string $value): self + { + $this->value = $value; + return $this; + } + public function getValue(): string + { + return $this->value; + } + + public function setLastUsed(DateTimeInterface $lastUsed): self + { + $this->lastUsed = $lastUsed; + return $this; + } + public function getLastUsed(): DateTimeInterface + { + return $this->lastUsed; + } + + public function setClass(string $class): self + { + $this->class = $class; + return $this; + } + public function getClass(): string + { + return $this->class; + } + + public function setUsername(string $username): self + { + $this->username = $username; + return $this; + } + public function getUsername(): string + { + return $this->username; + } + + // }}} Autocode + + public static function schemaDef(): array + { + $def = [ + 'name' => 'rememberme_token', + 'fields' => [ + 'series' => ['type' => 'char', 'length' => 88, 'not null' => true], + 'value' => ['type' => 'char', 'length' => 88, 'not null' => true], + 'lastUsed' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP'], + 'class' => ['type' => 'varchar', 'length' => 100, 'not null' => true], + 'username' => ['type' => 'varchar', 'length' => 64, 'not null' => true], + ], + 'primary key' => ['series'], + ]; + + return $def; + } +} diff --git a/src/Routes/Main.php b/src/Routes/Main.php index ab1c6eb62d..98c056556b 100644 --- a/src/Routes/Main.php +++ b/src/Routes/Main.php @@ -40,7 +40,10 @@ abstract class Main public static function load(RouteLoader $r): void { $r->connect('main_all', '/main/all', C\NetworkPublic::class); - $r->connect('config_admin', '/config/admin', C\AdminConfigController::class); + $r->connect('admin_config', '/admin/config', C\AdminConfigController::class); + + $r->connect('login', '/login', [C\SecurityController::class, 'login']); + $r->connect('logout', '/logout', [C\SecurityController::class, 'logout']); // FAQ static pages foreach (['faq', 'contact', 'tags', 'groups', 'openid'] as $s) { diff --git a/src/Security/Authenticator.php b/src/Security/Authenticator.php new file mode 100644 index 0000000000..0381e88302 --- /dev/null +++ b/src/Security/Authenticator.php @@ -0,0 +1,148 @@ +. +// }}} + +namespace App\Security; + +use App\Core\DB\DB; +use function App\Core\I18n\_m; +use App\Core\Log; +use App\Entity\User; +use App\Util\Nickname; +use Doctrine\ORM\EntityManagerInterface; +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; + +/** + * User authenticator + * + * @category Authentication + * @package GNUsocial + * + * @author Hugo Sales + * @copyright 2020 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ +class Authenticator extends AbstractFormLoginAuthenticator +{ + use TargetPathTrait; + + public const LOGIN_ROUTE = 'login'; + + private $entityManager; + private $urlGenerator; + private $csrfTokenManager; + + public function __construct(EntityManagerInterface $entityManager, UrlGeneratorInterface $urlGenerator, CsrfTokenManagerInterface $csrfTokenManager) + { + $this->entityManager = $entityManager; + $this->urlGenerator = $urlGenerator; + $this->csrfTokenManager = $csrfTokenManager; + } + + public function supports(Request $request) + { + return self::LOGIN_ROUTE === $request->attributes->get('_route') && $request->isMethod('POST'); + } + + public function getCredentials(Request $request) + { + $credentials = [ + 'nickname' => $request->request->get('nickname'), + 'password' => $request->request->get('password'), + 'csrf_token' => $request->request->get('_csrf_token'), + ]; + $request->getSession()->set( + Security::LAST_USERNAME, + $credentials['nickname'] + ); + + return $credentials; + } + + public function getUser($credentials, UserProviderInterface $userProvider) + { + $token = new CsrfToken('authenticate', $credentials['csrf_token']); + if (!$this->csrfTokenManager->isTokenValid($token)) { + throw new InvalidCsrfTokenException(); + } + + $nick = Nickname::normalize($credentials['nickname']); + $user = DB::findOneBy('local_user', ['or' => ['nickname' => $nick, 'outgoing_email' => $nick]]); + + if (!$user) { + throw new CustomUserMessageAuthenticationException( + _m('Either \'{nickname}\' doesn\'t match any registered nickname or email, or the supplied password is incorrect.', ['{nickname}' => $credentials['nickname']])); + } + + return $user; + } + + public function checkCredentials($credentials, UserInterface $user) + { + $password = $user->getPassword(); + Log::error(print_r($user, true)); + // crypt understands what the salt part of $user->password is + if ($password === crypt($credentials['password'], $user->password)) { + $this->changePassword($user->nickname, null, $password); + return $user; + } + + // If we check StatusNet hash, for backwards compatibility and migration + if ($this->statusnet && $user->password === md5($password . $user->id)) { + // and update password hash entry to crypt() compatible + if ($this->overwrite) { + $this->changePassword($user->nickname, null, $password); + } + return $user; + } + + // Timing safe password verification on supported PHP versions + if (password_verify($password, $user->password)) { + return $user; + } + + return false; + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey) + { + if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) { + return new RedirectResponse($targetPath); + } + + // For example : return new RedirectResponse($this->urlGenerator->generate('some_route')); + throw new \Exception('TODO: provide a valid redirect inside ' . __FILE__); + } + + protected function getLoginUrl() + { + return $this->urlGenerator->generate(self::LOGIN_ROUTE); + } +} diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig new file mode 100644 index 0000000000..67c1978918 --- /dev/null +++ b/templates/security/login.html.twig @@ -0,0 +1,34 @@ +{% extends 'base.html.twig' %} + +{% block title %}Log in!{% endblock %} + +{% block body %} +
+ {% if error %} +
{{ error.messageKey|trans(error.messageData, 'security') }}
+ {% endif %} + + {% if app.user %} +
+ You are logged in as {{ app.user.username }}, Logout +
+ {% endif %} + +

Please sign in

+ + + + + + +
+ +
+ + +
+{% endblock %}