added API tokens, deprecate API passwords (#4637)
This commit is contained in:
46
src/API/Authentication/AccessTokenHandler.php
Normal file
46
src/API/Authentication/AccessTokenHandler.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?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\Repository\AccessTokenRepository;
|
||||
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
|
||||
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
|
||||
final class AccessTokenHandler implements AccessTokenHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccessTokenRepository $accessTokenRepository
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function getUserBadgeFrom(string $accessToken): UserBadge
|
||||
{
|
||||
$accessToken = $this->accessTokenRepository->findByToken($accessToken);
|
||||
|
||||
if (null === $accessToken) {
|
||||
throw new BadCredentialsException('Invalid credentials.');
|
||||
}
|
||||
|
||||
if (!$accessToken->isValid()) {
|
||||
throw new BadCredentialsException('Invalid token.');
|
||||
}
|
||||
|
||||
$now = new \DateTimeImmutable();
|
||||
// record last usage only if this is the first time OR once every minute
|
||||
if ($accessToken->getLastUsage() === null || $now->getTimestamp() > $accessToken->getLastUsage()->getTimestamp() + 60) {
|
||||
$accessToken->setLastUsage($now);
|
||||
$this->accessTokenRepository->saveAccessToken($accessToken);
|
||||
}
|
||||
|
||||
return new UserBadge($accessToken->getUser()->getUserIdentifier(), fn (string $userIdentifier) => $accessToken->getUser());
|
||||
}
|
||||
}
|
||||
@@ -16,16 +16,29 @@ final class ApiRequestMatcher implements RequestMatcherInterface
|
||||
{
|
||||
public function matches(Request $request): bool
|
||||
{
|
||||
if (str_contains($request->getRequestUri(), '/api/doc')) {
|
||||
// we do not want to handle URLs that
|
||||
if (!str_starts_with($request->getRequestUri(), '/api/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_contains($request->getRequestUri(), '/api/')) {
|
||||
// API documentation is only available to registered users
|
||||
if (str_starts_with($request->getRequestUri(), '/api/doc')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$request->headers->has(SessionAuthenticator::HEADER_JAVASCRIPT) &&
|
||||
$request->headers->has(TokenAuthenticator::HEADER_USERNAME) &&
|
||||
$request->headers->has(TokenAuthenticator::HEADER_TOKEN);
|
||||
// let's use this firewall if a Bearer token is set in the header
|
||||
if ($request->headers->has('Authorization')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// let's use this firewall if the deprecated username & token combination is available
|
||||
if ($request->headers->has(TokenAuthenticator::HEADER_USERNAME) &&
|
||||
$request->headers->has(TokenAuthenticator::HEADER_TOKEN)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// checking for a previous session allows us to skip the API firewall and token access handler
|
||||
// we simply re-use the existing session when doing API calls from the frontend
|
||||
return !$request->hasPreviousSession();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<?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 Scheb\TwoFactorBundle\Security\Http\Authenticator\TwoFactorAuthenticator;
|
||||
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 createToken(Passport $passport, string $firewallName): TokenInterface
|
||||
{
|
||||
$token = parent::createToken($passport, $firewallName);
|
||||
|
||||
// this should not be necessary, as /api/ is excluded from 2FA process, but just to make sure this
|
||||
// authenticator never triggers 2FA, we add the attribute to the token
|
||||
|
||||
// https://symfony.com/bundles/SchebTwoFactorBundle/6.x/custom_conditions.html
|
||||
$token->setAttribute(TwoFactorAuthenticator::FLAG_2FA_COMPLETE, true);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -29,14 +29,25 @@ 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 __construct(
|
||||
private readonly ApiUserRepository $userProvider,
|
||||
private readonly PasswordHasherFactoryInterface $passwordHasherFactory
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
if (str_contains($request->getRequestUri(), '/api/')) {
|
||||
return !str_contains($request->getRequestUri(), '/api/doc');
|
||||
if (str_contains($request->getRequestUri(), '/api/doc')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($request->headers->has(self::HEADER_USERNAME) && $request->headers->has(self::HEADER_TOKEN)) {
|
||||
@trigger_error('You are using deprecated API access, please upgrade your APP to use API tokens instead.', E_USER_DEPRECATED);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user