Next major version 2 with PHP 8.1, Symfony 6, Tabler UI, 2FA ... (#2902)

This commit is contained in:
Kevin Papst
2022-12-31 21:19:55 +01:00
committed by GitHub
parent 95e06746bd
commit 90a0fd8a22
2164 changed files with 83700 additions and 86426 deletions

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\API\Authentication;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
final class ApiRequestMatcher implements RequestMatcherInterface
{
public function matches(Request $request): bool
{
if (str_contains($request->getRequestUri(), '/api/doc')) {
return false;
}
if (str_contains($request->getRequestUri(), '/api/')) {
return false;
}
return !$request->headers->has(SessionAuthenticator::HEADER_JAVASCRIPT) &&
$request->headers->has(TokenAuthenticator::HEADER_USERNAME) &&
$request->headers->has(TokenAuthenticator::HEADER_TOKEN);
}
}

View File

@@ -0,0 +1,59 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\API\Authentication;
use App\Entity\User;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
final class ApiTokenMigratingListener implements EventSubscriberInterface
{
public function __construct(private PasswordHasherFactoryInterface $hasherFactory)
{
}
public function onLoginSuccess(LoginSuccessEvent $event): void
{
$passport = $event->getPassport();
if (!$passport->hasBadge(ApiTokenUpgradeBadge::class)) {
return;
}
/** @var ApiTokenUpgradeBadge $badge */
$badge = $passport->getBadge(ApiTokenUpgradeBadge::class);
$plaintextApiToken = $badge->getAndErasePlaintextApiToken();
if ('' === $plaintextApiToken) {
return;
}
$user = $passport->getUser();
if (!($user instanceof User)) {
return;
}
if (null === $user->getApiToken()) {
return;
}
$passwordHasher = $this->hasherFactory->getPasswordHasher($user);
if (!$passwordHasher->needsRehash($user->getApiToken())) {
return;
}
$badge->getPasswordUpgrader()->upgradePassword($user, $passwordHasher->hash($plaintextApiToken));
}
public static function getSubscribedEvents(): array
{
return [LoginSuccessEvent::class => 'onLoginSuccess'];
}
}

View File

@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\API\Authentication;
use Symfony\Component\Security\Core\Exception\LogicException;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
final class ApiTokenUpgradeBadge implements BadgeInterface
{
public function __construct(private ?string $plaintextApiToken, private PasswordUpgraderInterface $passwordUpgrader)
{
}
public function getAndErasePlaintextApiToken(): string
{
$password = $this->plaintextApiToken;
if (null === $password) {
throw new LogicException('The api token is erased as another listener already used this badge.');
}
$this->plaintextApiToken = null;
return $password;
}
public function getPasswordUpgrader(): PasswordUpgraderInterface
{
return $this->passwordUpgrader;
}
public function isResolved(): bool
{
return true;
}
}

View File

@@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\API\Authentication;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
final class SessionAuthenticator extends AbstractAuthenticator
{
public const HEADER_JAVASCRIPT = 'X-AUTH-SESSION';
public function __construct(private TokenAuthenticator $authenticator)
{
}
public function supports(Request $request): ?bool
{
if (str_contains($request->getRequestUri(), '/api/')) {
// API docs can only be access, when the user is logged in
if (str_contains($request->getRequestUri(), '/api/doc')) {
return false;
}
return !$request->headers->has(self::HEADER_JAVASCRIPT);
}
return false;
}
public function authenticate(Request $request): Passport
{
return $this->authenticator->authenticate($request);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return $this->authenticator->onAuthenticationSuccess($request, $token, $firewallName);
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return $this->authenticator->onAuthenticationFailure($request, $exception);
}
}

View File

@@ -0,0 +1,106 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\API\Authentication;
use App\Entity\User;
use App\Repository\ApiUserRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CustomCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
final class TokenAuthenticator extends AbstractAuthenticator
{
public const HEADER_USERNAME = 'X-AUTH-USER';
public const HEADER_TOKEN = 'X-AUTH-TOKEN';
public function __construct(private ApiUserRepository $userProvider, private PasswordHasherFactoryInterface $passwordHasherFactory)
{
}
public function supports(Request $request): ?bool
{
if (str_contains($request->getRequestUri(), '/api/')) {
return !str_contains($request->getRequestUri(), '/api/doc');
}
return false;
}
private function getCredentials(Request $request): array
{
$apiUser = $request->headers->get(self::HEADER_USERNAME);
if (null === $apiUser || '' === $apiUser) {
throw new CustomUserMessageAuthenticationException('Authentication required, missing user header: ' . self::HEADER_USERNAME);
}
$apiToken = $request->headers->get(self::HEADER_TOKEN);
if (null === $apiToken || '' === $apiToken) {
throw new CustomUserMessageAuthenticationException('Authentication required, missing token header: ' . self::HEADER_TOKEN);
}
return [
'username' => $apiUser,
'password' => $apiToken
];
}
public function authenticate(Request $request): Passport
{
$credentials = $this->getCredentials($request);
$checkCredentials = function (?string $presentedPassword, User $user) {
if ('' === $presentedPassword) {
throw new BadCredentialsException('The presented password cannot be empty.');
}
if (null === $user->getApiToken()) {
throw new BadCredentialsException('The user has no activated API account.');
}
if ($this->passwordHasherFactory->getPasswordHasher($user)->verify($user->getApiToken(), $presentedPassword)) {
return true;
}
throw new BadCredentialsException('The presented password is invalid.');
};
$passport = new Passport(
new UserBadge($credentials['username'], [$this->userProvider, 'loadUserByIdentifier']),
new CustomCredentials($checkCredentials, $credentials['password'])
);
$passport->addBadge(new ApiTokenUpgradeBadge($credentials['password'], $this->userProvider));
return $passport;
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$data = [
'message' => $exception instanceof CustomUserMessageAuthenticationException ? $exception->getMessage() : 'Invalid credentials'
];
return new JsonResponse($data, Response::HTTP_FORBIDDEN);
}
}