added API tokens, deprecate API passwords (#4637)

This commit is contained in:
Kevin Papst
2024-04-05 23:51:16 +02:00
committed by GitHub
parent dd51c8dfba
commit afe0656502
60 changed files with 889 additions and 624 deletions

View File

@@ -18,7 +18,6 @@ use App\Event\PageActionsEvent;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -73,8 +72,6 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)]
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[Route(methods: ['GET'], path: '/timesheet/{id}/{view}/{locale}', name: 'get_timesheet_actions', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getTimesheetActions(Timesheet $timesheet, string $view, string $locale): Response
{
$event = new PageActionsEvent($this->getUser(), ['timesheet' => $timesheet], 'timesheet', $view);
@@ -93,8 +90,6 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)]
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[Route(methods: ['GET'], path: '/activity/{id}/{view}/{locale}', name: 'get_activity_actions', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getActivityActions(Activity $activity, string $view, string $locale): Response
{
$event = new PageActionsEvent($this->getUser(), ['activity' => $activity], 'activity', $view);
@@ -113,8 +108,6 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)]
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[Route(methods: ['GET'], path: '/project/{id}/{view}/{locale}', name: 'get_project_actions', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getProjectActions(Project $project, string $view, string $locale): Response
{
$event = new PageActionsEvent($this->getUser(), ['project' => $project], 'project', $view);
@@ -133,8 +126,6 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)]
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[Route(methods: ['GET'], path: '/customer/{id}/{view}/{locale}', name: 'get_customer_actions', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getCustomerActions(Customer $customer, string $view, string $locale): Response
{
$event = new PageActionsEvent($this->getUser(), ['customer' => $customer], 'customer', $view);

View File

@@ -24,7 +24,6 @@ use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
@@ -56,8 +55,6 @@ final class ActivityController extends BaseApiController
*/
#[OA\Response(response: 200, description: 'Returns a collection of activities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ActivityCollection')))]
#[Route(methods: ['GET'], path: '', name: 'get_activities')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\QueryParam(name: 'project', requirements: '\d+', strict: true, nullable: true, description: 'Project ID to filter activities')]
#[Rest\QueryParam(name: 'projects', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of project IDs to filter activities, e.g.: projects[]=1&projects[]=2')]
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter activities: 1=visible, 2=hidden, 3=all')]
@@ -126,8 +123,6 @@ final class ActivityController extends BaseApiController
#[OA\Response(response: 200, description: 'Returns one activity entity', content: new OA\JsonContent(ref: '#/components/schemas/ActivityEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Activity ID to fetch', required: true)]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_activity', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[IsGranted('view', 'activity')]
public function getAction(Activity $activity): Response
{
@@ -143,8 +138,6 @@ final class ActivityController extends BaseApiController
#[OA\Post(description: 'Creates a new activity and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created activity', content: new OA\JsonContent(ref: '#/components/schemas/ActivityEntity'))])]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ActivityEditForm'))]
#[Route(methods: ['POST'], path: '', name: 'post_activity')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postAction(Request $request): Response
{
if (!$this->isGranted('create_activity')) {
@@ -186,8 +179,6 @@ final class ActivityController extends BaseApiController
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ActivityEditForm'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Activity ID to update', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_activity', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function patchAction(Request $request, Activity $activity): Response
{
$event = new ActivityMetaDefinitionEvent($activity);
@@ -223,8 +214,6 @@ final class ActivityController extends BaseApiController
#[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/ActivityEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Activity record ID to set the meta-field value for', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}/meta', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\RequestParam(name: 'name', strict: true, nullable: false, description: 'The meta-field name')]
#[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')]
public function metaAction(Activity $activity, ParamFetcherInterface $paramFetcher): Response
@@ -256,8 +245,6 @@ final class ActivityController extends BaseApiController
#[OA\Response(response: 200, description: 'Returns a collection of activity rate entities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ActivityRate')))]
#[OA\Parameter(name: 'id', in: 'path', description: 'The activity whose rates will be returned', required: true)]
#[Route(methods: ['GET'], path: '/{id}/rates', name: 'get_activity_rates', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getRatesAction(Activity $activity): Response
{
$rates = $this->activityRateRepository->getRatesForActivity($activity);
@@ -275,8 +262,6 @@ final class ActivityController extends BaseApiController
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Returns no content: 204 on successful delete')])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The activity whose rate will be removed', required: true)]
#[OA\Parameter(name: 'rateId', in: 'path', description: 'The rate to remove', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}/rates/{rateId}', name: 'delete_activity_rate', requirements: ['id' => '\d+', 'rateId' => '\d+'])]
public function deleteRateAction(Activity $activity, #[MapEntity(mapping: ['rateId' => 'id'])] ActivityRate $rate): Response
{
@@ -299,8 +284,6 @@ final class ActivityController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'The activity to add the rate for', required: true)]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ActivityRateForm'))]
#[Route(methods: ['POST'], path: '/{id}/rates', name: 'post_activity_rate', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postRateAction(Activity $activity, Request $request): Response
{
$rate = new ActivityRate();

View 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());
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -14,7 +14,6 @@ use App\Configuration\SystemConfiguration;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -33,8 +32,6 @@ final class ConfigurationController extends BaseApiController
*/
#[OA\Response(response: 200, description: 'Returns the instance specific timesheet configuration', content: new OA\JsonContent(ref: new Model(type: TimesheetConfig::class)))]
#[Route(methods: ['GET'], path: '/config/timesheet')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function timesheetConfigAction(SystemConfiguration $configuration): Response
{
$model = new TimesheetConfig();

View File

@@ -24,7 +24,6 @@ use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
@@ -56,8 +55,6 @@ final class CustomerController extends BaseApiController
*/
#[OA\Response(response: 200, description: 'Returns a collection of customers', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/CustomerCollection')))]
#[Route(methods: ['GET'], path: '', name: 'get_customers')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter customers: 1=visible, 2=hidden, 3=both')]
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|name', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, name (default: name)')]
@@ -103,8 +100,6 @@ final class CustomerController extends BaseApiController
*/
#[OA\Response(response: 200, description: 'Returns one customer entity', content: new OA\JsonContent(ref: '#/components/schemas/CustomerEntity'))]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_customer', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[IsGranted('view', 'customer')]
public function getAction(Customer $customer): Response
{
@@ -120,8 +115,6 @@ final class CustomerController extends BaseApiController
#[OA\Post(description: 'Creates a new customer and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created customer', content: new OA\JsonContent(ref: '#/components/schemas/CustomerEntity'))])]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CustomerEditForm'))]
#[Route(methods: ['POST'], path: '', name: 'post_customer')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postAction(Request $request, CustomerService $customerService): Response
{
if (!$this->isGranted('create_customer')) {
@@ -163,8 +156,6 @@ final class CustomerController extends BaseApiController
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CustomerEditForm'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Customer ID to update', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_customer', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function patchAction(Request $request, Customer $customer): Response
{
$event = new CustomerMetaDefinitionEvent($customer);
@@ -200,8 +191,6 @@ final class CustomerController extends BaseApiController
#[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/CustomerEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Customer record ID to set the meta-field value for', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}/meta', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\RequestParam(name: 'name', strict: true, nullable: false, description: 'The meta-field name')]
#[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')]
public function metaAction(Customer $customer, ParamFetcherInterface $paramFetcher): Response
@@ -233,8 +222,6 @@ final class CustomerController extends BaseApiController
#[OA\Response(response: 200, description: 'Returns a collection of customer rate entities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/CustomerRate')))]
#[OA\Parameter(name: 'id', in: 'path', description: 'The customer whose rates will be returned', required: true)]
#[Route(methods: ['GET'], path: '/{id}/rates', name: 'get_customer_rates', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getRatesAction(Customer $customer): Response
{
$rates = $this->customerRateRepository->getRatesForCustomer($customer);
@@ -252,8 +239,6 @@ final class CustomerController extends BaseApiController
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Returns no content: 204 on successful delete')])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The customer whose rate will be removed', required: true)]
#[OA\Parameter(name: 'rateId', in: 'path', description: 'The rate to remove', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}/rates/{rateId}', name: 'delete_customer_rate', requirements: ['id' => '\d+', 'rateId' => '\d+'])]
public function deleteRateAction(Customer $customer, #[MapEntity(mapping: ['rateId' => 'id'])] CustomerRate $rate): Response
{
@@ -276,8 +261,6 @@ final class CustomerController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'The customer to add the rate for', required: true)]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CustomerRateForm'))]
#[Route(methods: ['POST'], path: '/{id}/rates', name: 'post_customer_rate', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postRateAction(Customer $customer, Request $request): Response
{
$rate = new CustomerRate();

View File

@@ -25,7 +25,6 @@ use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
@@ -59,8 +58,6 @@ final class ProjectController extends BaseApiController
*/
#[OA\Response(response: 200, description: 'Returns a collection of projects', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ProjectCollection')))]
#[Route(methods: ['GET'], path: '', name: 'get_projects')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\QueryParam(name: 'customer', requirements: '\d+', strict: true, nullable: true, description: 'Customer ID to filter projects')]
#[Rest\QueryParam(name: 'customers', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of customer IDs to filter, e.g.: customers[]=1&customers[]=2')]
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter projects: 1=visible, 2=hidden, 3=both')]
@@ -157,8 +154,6 @@ final class ProjectController extends BaseApiController
*/
#[OA\Response(response: 200, description: 'Returns one project entity', content: new OA\JsonContent(ref: '#/components/schemas/ProjectEntity'))]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_project', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[IsGranted('view', 'project')]
public function getAction(Project $project): Response
{
@@ -174,8 +169,6 @@ final class ProjectController extends BaseApiController
#[OA\Post(description: 'Creates a new project and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created project', content: new OA\JsonContent(ref: '#/components/schemas/ProjectEntity'))])]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ProjectEditForm'))]
#[Route(methods: ['POST'], path: '', name: 'post_project')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postAction(Request $request): Response
{
if (!$this->isGranted('create_project')) {
@@ -216,8 +209,6 @@ final class ProjectController extends BaseApiController
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ProjectEditForm'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Project ID to update', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_project', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function patchAction(Request $request, Project $project): Response
{
$event = new ProjectMetaDefinitionEvent($project);
@@ -255,8 +246,6 @@ final class ProjectController extends BaseApiController
#[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/ProjectEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Project record ID to set the meta-field value for', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}/meta', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\RequestParam(name: 'name', strict: true, nullable: false, description: 'The meta-field name')]
#[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')]
public function metaAction(Project $project, ParamFetcherInterface $paramFetcher): Response
@@ -288,8 +277,6 @@ final class ProjectController extends BaseApiController
#[OA\Response(response: 200, description: 'Returns a collection of project rate entities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ProjectRate')))]
#[OA\Parameter(name: 'id', in: 'path', description: 'The project whose rates will be returned', required: true)]
#[Route(methods: ['GET'], path: '/{id}/rates', name: 'get_project_rates', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getRatesAction(Project $project): Response
{
$rates = $this->projectRateRepository->getRatesForProject($project);
@@ -307,8 +294,6 @@ final class ProjectController extends BaseApiController
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Returns no content: 204 on successful delete')])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The project whose rate will be removed', required: true)]
#[OA\Parameter(name: 'rateId', in: 'path', description: 'The rate to remove', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}/rates/{rateId}', name: 'delete_project_rate', requirements: ['id' => '\d+', 'rateId' => '\d+'])]
public function deleteRateAction(Project $project, #[MapEntity(mapping: ['rateId' => 'id'])] ProjectRate $rate): Response
{
@@ -331,8 +316,6 @@ final class ProjectController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'The project to add the rate for', required: true)]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ProjectRateForm'))]
#[Route(methods: ['POST'], path: '/{id}/rates', name: 'post_project_rate', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postRateAction(Project $project, Request $request): Response
{
$rate = new ProjectRate();

View File

@@ -15,7 +15,6 @@ use App\Plugin\PluginManager;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -34,8 +33,6 @@ final class StatusController extends BaseApiController
*/
#[OA\Response(response: 200, description: "A simple route that returns a 'pong', which you can use for testing the API", content: new OA\JsonContent(example: "{'message': 'pong'}"))]
#[Route(methods: ['GET'], path: '/ping')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function pingAction(): Response
{
$view = new View(['message' => 'pong'], 200);
@@ -48,8 +45,6 @@ final class StatusController extends BaseApiController
*/
#[OA\Response(response: 200, description: 'Returns version information about the current release', content: new OA\JsonContent(ref: new Model(type: Version::class)))]
#[Route(methods: ['GET'], path: '/version')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function versionAction(): Response
{
return $this->viewHandler->handle(new View(new Version(), 200));
@@ -60,8 +55,6 @@ final class StatusController extends BaseApiController
*/
#[OA\Response(response: 200, description: 'Returns a list of plugin names and versions', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: new Model(type: Plugin::class))))]
#[Route(methods: ['GET'], path: '/plugins')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function pluginAction(PluginManager $pluginManager): Response
{
$plugins = [];

View File

@@ -16,7 +16,6 @@ use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -41,8 +40,6 @@ final class TagController extends BaseApiController
*/
#[OA\Response(response: 200, description: 'Returns the collection of all existing tags as string array', content: new OA\JsonContent(type: 'array', items: new OA\Items(type: 'string')))]
#[Route(methods: ['GET'], name: 'get_tags')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\QueryParam(name: 'name', strict: true, nullable: true, description: 'Search term to filter tag list')]
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
{
@@ -62,8 +59,6 @@ final class TagController extends BaseApiController
#[OA\Post(description: 'Creates a new tag and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created tag', content: new OA\JsonContent(ref: '#/components/schemas/TagEntity'))])]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TagEditForm'))]
#[Route(methods: ['POST'], name: 'post_tag')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postAction(Request $request): Response
{
if (!$this->isGranted('manage_tag') && !$this->isGranted('create_tag')) {
@@ -97,8 +92,6 @@ final class TagController extends BaseApiController
#[IsGranted('delete_tag')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'HTTP code 204 for a successful delete')])]
#[OA\Parameter(name: 'id', in: 'path', description: 'Tag ID to delete', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_tag')]
public function deleteAction(Tag $tag): Response
{

View File

@@ -21,7 +21,6 @@ use App\Repository\ProjectRepository;
use App\Repository\TeamRepository;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\Request;
@@ -49,8 +48,6 @@ final class TeamController extends BaseApiController
#[IsGranted('view_team')]
#[OA\Response(response: 200, description: 'Returns the collection of teams', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TeamCollection')))]
#[Route(methods: ['GET'], path: '', name: 'get_teams')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function cgetAction(): Response
{
$data = $this->repository->findAll();
@@ -67,8 +64,6 @@ final class TeamController extends BaseApiController
#[IsGranted('view_team')]
#[OA\Response(response: 200, description: 'Returns one team entity', content: new OA\JsonContent(ref: '#/components/schemas/Team'))]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_team', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getAction(Team $team): Response
{
$view = new View($team, 200);
@@ -83,8 +78,6 @@ final class TeamController extends BaseApiController
#[IsGranted('delete_team')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one team')])]
#[OA\Parameter(name: 'id', in: 'path', description: 'Team ID to delete', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_team', requirements: ['id' => '\d+'])]
public function deleteAction(Team $team): Response
{
@@ -102,8 +95,6 @@ final class TeamController extends BaseApiController
#[OA\Post(description: 'Creates a new team and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TeamEditForm'))]
#[Route(methods: ['POST'], path: '', name: 'post_team')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postAction(Request $request): Response
{
$team = new Team('');
@@ -134,8 +125,6 @@ final class TeamController extends BaseApiController
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TeamEditForm'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Team ID to update', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_team', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function patchAction(Request $request, Team $team): Response
{
if ($request->request->has('members')) {
@@ -174,8 +163,6 @@ final class TeamController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'The team which will receive the new member', required: true)]
#[OA\Parameter(name: 'userId', in: 'path', description: 'The team member to add (User ID)', required: true)]
#[Route(methods: ['POST'], path: '/{id}/members/{userId}', name: 'post_team_member', requirements: ['id' => '\d+', 'userId' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postMemberAction(Team $team, #[MapEntity(mapping: ['userId' => 'id'])] User $member): Response
{
if ($member->isInTeam($team)) {
@@ -199,8 +186,6 @@ final class TeamController extends BaseApiController
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a user from the team. The teamlead cannot be removed.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team from which the member will be removed', required: true)]
#[OA\Parameter(name: 'userId', in: 'path', description: 'The team member to remove (User ID)', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}/members/{userId}', name: 'delete_team_member', requirements: ['id' => '\d+', 'userId' => '\d+'])]
public function deleteMemberAction(Team $team, #[MapEntity(mapping: ['userId' => 'id'])] User $member): Response
{
@@ -230,8 +215,6 @@ final class TeamController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
#[OA\Parameter(name: 'customerId', in: 'path', description: 'The customer to grant acecess to (Customer ID)', required: true)]
#[Route(methods: ['POST'], path: '/{id}/customers/{customerId}', name: 'post_team_customer', requirements: ['id' => '\d+', 'customerId' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postCustomerAction(Team $team, #[MapEntity(mapping: ['customerId' => 'id'])] Customer $customer, CustomerRepository $customerRepository): Response
{
if ($team->hasCustomer($customer)) {
@@ -254,8 +237,6 @@ final class TeamController extends BaseApiController
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a customer from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)]
#[OA\Parameter(name: 'customerId', in: 'path', description: 'The customer to remove (Customer ID)', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}/customers/{customerId}', name: 'delete_team_customer', requirements: ['id' => '\d+', 'customerId' => '\d+'])]
public function deleteCustomerAction(Team $team, #[MapEntity(mapping: ['customerId' => 'id'])] Customer $customer, CustomerRepository $customerRepository): Response
{
@@ -280,8 +261,6 @@ final class TeamController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
#[OA\Parameter(name: 'projectId', in: 'path', description: 'The project to grant acecess to (Project ID)', required: true)]
#[Route(methods: ['POST'], path: '/{id}/projects/{projectId}', name: 'post_team_project', requirements: ['id' => '\d+', 'projectId' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postProjectAction(Team $team, #[MapEntity(mapping: ['projectId' => 'id'])] Project $project, ProjectRepository $projectRepository): Response
{
if ($team->hasProject($project)) {
@@ -304,8 +283,6 @@ final class TeamController extends BaseApiController
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a project from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)]
#[OA\Parameter(name: 'projectId', in: 'path', description: 'The project to remove (Project ID)', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}/projects/{projectId}', name: 'delete_team_project', requirements: ['id' => '\d+', 'projectId' => '\d+'])]
public function deleteProjectAction(Team $team, #[MapEntity(mapping: ['projectId' => 'id'])] Project $project, ProjectRepository $projectRepository): Response
{
@@ -330,8 +307,6 @@ final class TeamController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
#[OA\Parameter(name: 'activityId', in: 'path', description: 'The activity to grant acecess to (Activity ID)', required: true)]
#[Route(methods: ['POST'], path: '/{id}/activities/{activityId}', name: 'post_team_activity', requirements: ['id' => '\d+', 'activityId' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postActivityAction(Team $team, #[MapEntity(mapping: ['activityId' => 'id'])] Activity $activity, ActivityRepository $activityRepository): Response
{
if ($team->hasActivity($activity)) {
@@ -354,8 +329,6 @@ final class TeamController extends BaseApiController
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a activity from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)]
#[OA\Parameter(name: 'activityId', in: 'path', description: 'The activity to remove (Activity ID)', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}/activities/{activityId}', name: 'delete_team_activity', requirements: ['id' => '\d+', 'activityId' => '\d+'])]
public function deleteActivityAction(Team $team, #[MapEntity(mapping: ['activityId' => 'id'])] Activity $activity, ActivityRepository $activityRepository): Response
{

View File

@@ -31,7 +31,6 @@ use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\ExpressionLanguage\Expression;
@@ -73,8 +72,6 @@ final class TimesheetController extends BaseApiController
#[IsGranted(new Expression("is_granted('view_own_timesheet') or is_granted('view_other_timesheet')"))]
#[OA\Response(response: 200, description: 'Returns a collection of timesheet records. The datetime fields are given in the users local time including the timezone offset (ISO-8601).', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollection')))]
#[Route(methods: ['GET'], path: '', name: 'get_timesheets')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\QueryParam(name: 'user', requirements: '\d+|all', strict: true, nullable: true, description: "User ID to filter timesheets. Needs permission 'view_other_timesheet', pass 'all' to fetch data for all user (default: current user)")]
#[Rest\QueryParam(name: 'users', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of user IDs to filter, e.g.: users[]=1&users[]=2 (ignored if user=all)')]
#[Rest\QueryParam(name: 'customer', requirements: '\d+', strict: true, nullable: true, description: 'Customer ID to filter timesheets')]
@@ -275,8 +272,6 @@ final class TimesheetController extends BaseApiController
#[OA\Response(response: 200, description: 'Returns one timesheet record. Be aware that the datetime fields are given in the users local time including the timezone offset via ISO 8601.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to fetch', required: true)]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_timesheet', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getAction(Timesheet $timesheet): Response
{
$view = new View($timesheet, 200);
@@ -292,8 +287,6 @@ final class TimesheetController extends BaseApiController
#[OA\Post(description: 'Creates a new timesheet record for the current user and returns it afterwards.', responses: [new OA\Response(response: 200, description: 'Returns the new created timesheet', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))])]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEditForm'))]
#[Route(methods: ['POST'], path: '', name: 'post_timesheet')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\QueryParam(name: 'full', strict: true, nullable: true, description: 'Allows to fetch fully serialized objects including subresources (TimesheetExpanded). Allowed values: true (default: false)')]
public function postAction(Request $request, ParamFetcherInterface $paramFetcher): Response
{
@@ -348,8 +341,6 @@ final class TimesheetController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to update', required: true)]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEditForm'))]
#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_timesheet', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function patchAction(Request $request, Timesheet $timesheet): Response
{
$event = new TimesheetMetaDefinitionEvent($timesheet);
@@ -391,8 +382,6 @@ final class TimesheetController extends BaseApiController
#[IsGranted('delete', 'timesheet')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one timesheet record')])]
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to delete', required: true)]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_timesheet', requirements: ['id' => '\d+'])]
public function deleteAction(Timesheet $timesheet): Response
{
@@ -409,8 +398,6 @@ final class TimesheetController extends BaseApiController
#[IsGranted('view_own_timesheet')]
#[OA\Response(response: 200, description: 'Returns the collection of recent user activities (always the latest entry of a unique working set grouped by customer, project and activity)', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollectionExpanded')))]
#[Route(methods: ['GET'], path: '/recent', name: 'recent_timesheet')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included. Default: today - 1 year (format: HTML5)')]
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries (default: 10)')]
public function recentAction(ParamFetcherInterface $paramFetcher): Response
@@ -445,8 +432,6 @@ final class TimesheetController extends BaseApiController
#[IsGranted('view_own_timesheet')]
#[OA\Response(response: 200, description: 'Returns the collection of active timesheet records for the current user', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollectionExpanded')))]
#[Route(methods: ['GET'], path: '/active', name: 'active_timesheet')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function activeAction(): Response
{
/** @var User $user */
@@ -471,8 +456,6 @@ final class TimesheetController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to stop', required: true)]
#[Route(methods: ['GET'], path: '/{id}/stop', name: 'stop_timesheet_get', requirements: ['id' => '\d+'])]
#[Route(methods: ['PATCH'], path: '/{id}/stop', name: 'stop_timesheet', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function stopAction(Timesheet $timesheet): Response
{
$this->service->stopTimesheet($timesheet);
@@ -491,8 +474,6 @@ final class TimesheetController extends BaseApiController
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to restart', required: true)]
#[Route(methods: ['GET'], path: '/{id}/restart', name: 'restart_timesheet_get', requirements: ['id' => '\d+'])]
#[Route(methods: ['PATCH'], path: '/{id}/restart', name: 'restart_timesheet', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\RequestParam(name: 'copy', requirements: 'all', strict: true, nullable: true, description: 'Whether data should be copied to the new entry. Allowed values: all (default: nothing is copied)')]
#[Rest\RequestParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Changes the restart date to the given one (default: now)')]
public function restartAction(Timesheet $timesheet, ParamFetcherInterface $paramFetcher): Response
@@ -553,8 +534,6 @@ final class TimesheetController extends BaseApiController
#[OA\Response(response: 200, description: 'Duplicates a timesheet record, resetting the export state only.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to duplicate', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}/duplicate', name: 'duplicate_timesheet', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function duplicateAction(Timesheet $timesheet): Response
{
$copyTimesheet = clone $timesheet;
@@ -576,8 +555,6 @@ final class TimesheetController extends BaseApiController
#[OA\Response(response: 200, description: 'Switches the exported state on the record and therefor locks / unlocks it for further updates. Needs edit_export_*_timesheet permission.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to switch export state', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}/export', name: 'export_timesheet', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function exportAction(Timesheet $timesheet): Response
{
if ($timesheet->isExported() && !$this->isGranted('edit_exported_timesheet')) {
@@ -601,8 +578,6 @@ final class TimesheetController extends BaseApiController
#[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to set the meta-field value for', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}/meta', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\RequestParam(name: 'name', strict: true, nullable: false, description: 'The meta-field name')]
#[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')]
public function metaAction(Timesheet $timesheet, ParamFetcherInterface $paramFetcher): Response

View File

@@ -10,10 +10,12 @@
namespace App\API;
use App\Configuration\SystemConfiguration;
use App\Entity\AccessToken;
use App\Entity\User;
use App\Event\PrepareUserEvent;
use App\Form\API\UserApiCreateForm;
use App\Form\API\UserApiEditForm;
use App\Repository\AccessTokenRepository;
use App\Repository\Query\UserQuery;
use App\Repository\UserRepository;
use App\Utils\SearchTerm;
@@ -21,7 +23,6 @@ use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
@@ -42,10 +43,10 @@ final class UserController extends BaseApiController
public const GROUPS_COLLECTION_FULL = ['Default', 'Collection', 'User', 'User_Entity'];
public function __construct(
private ViewHandlerInterface $viewHandler,
private UserRepository $repository,
private UserPasswordHasherInterface $passwordHasher,
private SystemConfiguration $configuration
private readonly ViewHandlerInterface $viewHandler,
private readonly UserRepository $repository,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly SystemConfiguration $configuration
) {
}
@@ -55,8 +56,6 @@ final class UserController extends BaseApiController
#[IsGranted('view_user')]
#[OA\Response(response: 200, description: 'Returns the collection of users. Required permission: view_user', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/UserCollection')))]
#[Route(methods: ['GET'], path: '', name: 'get_users')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter users: 1=visible, 2=hidden, 3=all')]
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|username|alias|email', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, username, alias, email (default: username)')]
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
@@ -108,8 +107,6 @@ final class UserController extends BaseApiController
#[OA\Response(response: 200, description: 'Return one user entity.', content: new OA\JsonContent(ref: '#/components/schemas/UserEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'User ID to fetch', required: true)]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_user', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function getAction(User $profile, EventDispatcherInterface $dispatcher): Response
{
// we need to prepare the user preferences, which is done via an EventSubscriber
@@ -127,8 +124,6 @@ final class UserController extends BaseApiController
*/
#[OA\Response(response: 200, description: 'Return the current user entity.', content: new OA\JsonContent(ref: '#/components/schemas/UserEntity'))]
#[Route(methods: ['GET'], path: '/me', name: 'me_user')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function meAction(): Response
{
$view = new View($this->getUser(), 200);
@@ -144,8 +139,6 @@ final class UserController extends BaseApiController
#[OA\Post(description: 'Creates a new user and returns it afterwards')]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/UserCreateForm'))]
#[Route(methods: ['POST'], path: '', name: 'post_user')]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function postAction(Request $request): Response
{
$user = new User();
@@ -198,8 +191,6 @@ final class UserController extends BaseApiController
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/UserEditForm'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'User ID to update', required: true)]
#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_user', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
public function patchAction(Request $request, User $profile): Response
{
$form = $this->createForm(UserApiEditForm::class, $profile, [
@@ -227,4 +218,29 @@ final class UserController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Delete an API token for the current user
*/
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Success if the token could be deleted.')])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The API token ID to remove', required: true)]
#[Route(methods: ['DELETE'], path: '/api-token/{id}', name: 'delete_api_token', requirements: ['id' => '\d+'])]
public function deleteApiToken(AccessToken $accessToken, AccessTokenRepository $accessTokenRepository): Response
{
$user = $this->getUser();
if (!$this->isGranted('api-token', $user)) {
throw $this->createAccessDeniedException('User has no access to API tokens');
}
if ($accessToken->getUser() !== $user) {
throw $this->createAccessDeniedException('You are not allowed to delete this access token');
}
$accessTokenRepository->deleteAccessToken($accessToken);
$view = new View(null, Response::HTTP_OK);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
return $this->viewHandler->handle($view);
}
}

View File

@@ -9,6 +9,8 @@
namespace App\Command;
use App\DataFixtures\UserFixtures;
use App\Entity\AccessToken;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
@@ -34,7 +36,10 @@ use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:reset:test', description: 'Resets the "test" environment')]
final class ResetTestCommand extends AbstractResetCommand
{
public function __construct(private EntityManagerInterface $entityManager, string $kernelEnvironment)
public function __construct(
private readonly EntityManagerInterface $entityManager,
string $kernelEnvironment
)
{
parent::__construct($kernelEnvironment);
}
@@ -95,7 +100,8 @@ final class ResetTestCommand extends AbstractResetCommand
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_customer',
],
[
2,
@@ -115,7 +121,8 @@ final class ResetTestCommand extends AbstractResetCommand
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_user',
],
[
3,
@@ -135,7 +142,8 @@ final class ResetTestCommand extends AbstractResetCommand
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_inactive',
],
[
4,
@@ -155,7 +163,8 @@ final class ResetTestCommand extends AbstractResetCommand
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_teamlead',
],
[
5,
@@ -175,7 +184,8 @@ final class ResetTestCommand extends AbstractResetCommand
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_admin',
],
[
6,
@@ -195,7 +205,8 @@ final class ResetTestCommand extends AbstractResetCommand
'2020-04-14 09:50:38',
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_super',
],
[
7,
@@ -215,7 +226,8 @@ final class ResetTestCommand extends AbstractResetCommand
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_qa1',
],
[
8,
@@ -235,7 +247,8 @@ final class ResetTestCommand extends AbstractResetCommand
null,
null,
null,
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
UserFixtures::DEFAULT_API_TOKEN . '_qa2',
],
];
@@ -283,6 +296,10 @@ final class ResetTestCommand extends AbstractResetCommand
$user->setApiToken($userConf[17]);
}
$accessToken = new AccessToken($user, $userConf[18]);
$accessToken->setName('Test fixture');
$this->entityManager->persist($accessToken);
$this->entityManager->persist($user);
$userEntities[] = $user;
}

View File

@@ -9,11 +9,13 @@
namespace App\Controller;
use App\Entity\AccessToken;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Event\PrepareUserEvent;
use App\Form\AccessTokenForm;
use App\Form\Model\TotpActivation;
use App\Form\UserApiTokenType;
use App\Form\UserApiPasswordType;
use App\Form\UserContractType;
use App\Form\UserEditType;
use App\Form\UserPasswordType;
@@ -21,6 +23,7 @@ use App\Form\UserPreferencesForm;
use App\Form\UserRolesType;
use App\Form\UserTeamsType;
use App\Form\UserTwoFactorType;
use App\Repository\AccessTokenRepository;
use App\Repository\Query\TimesheetStatisticQuery;
use App\Repository\TeamRepository;
use App\Repository\TimesheetRepository;
@@ -151,24 +154,74 @@ final class ProfileController extends AbstractController
]);
}
#[Route(path: '/{username}/api-token', name: 'user_profile_api_token', methods: ['GET', 'POST'])]
#[Route(path: '/{username}/create-access-token', name: 'user_profile_access_token', methods: ['GET', 'POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('api-token', 'profile')]
public function apiTokenAction(User $profile, Request $request, UserService $userService): Response
public function createAccessToken(User $profile, Request $request, AccessTokenRepository $accessTokenRepository): Response
{
$form = $this->createApiTokenForm($profile);
$accessToken = new AccessToken($profile, substr(bin2hex(random_bytes(100)), 0, 25));
$form = $this->createForm(AccessTokenForm::class, $accessToken, [
'action' => $this->generateUrl('user_profile_access_token', ['username' => $profile->getUserIdentifier()]),
'method' => 'POST'
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$userService->updateUser($profile);
$accessTokenRepository->saveAccessToken($accessToken);
$this->flashSuccess('action.update.success');
$request->getSession()->set('_show_access_token', $accessToken->getId());
return new Response();
}
return $this->render('user/access-token.html.twig', [
'access_token' => $accessToken,
'user' => $profile,
'form' => $form->createView(),
]);
}
#[Route(path: '/{username}/api-token', name: 'user_profile_api_token', methods: ['GET', 'POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('api-token', 'profile')]
public function apiTokenAction(User $profile, Request $request, UserService $userService, AccessTokenRepository $accessTokenRepository): Response
{
$form = $this->createForm(UserApiPasswordType::class, $profile, [
'action' => $this->generateUrl('user_profile_api_token', ['username' => $profile->getUserIdentifier()]),
'method' => 'POST'
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
@trigger_error('User ' . $profile->getUsername() . ' created deprecated API password.', E_USER_DEPRECATED);
$userService->saveUser($profile);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('user_profile_api_token', ['username' => $profile->getUserIdentifier()]);
}
$accessTokens = $accessTokenRepository->findForUser($profile);
$createdToken = null;
$createdId = $request->getSession()->get('_show_access_token');
$request->getSession()->remove('_show_access_token');
if ($createdId !== null) {
foreach ($accessTokens as $accessToken) {
if ($accessToken->getId() === $createdId) {
$createdToken = $accessToken;
}
}
}
return $this->render('user/api-token.html.twig', [
'tab' => 'api-token',
'created_token' => $createdToken,
'access_tokens' => $accessTokens,
'page_setup' => $this->getPageSetup($profile, 'api-token'),
'user' => $profile,
'form' => $form->createView(),
@@ -390,18 +443,6 @@ final class ProfileController extends AbstractController
);
}
private function createApiTokenForm(User $user): FormInterface
{
return $this->createForm(
UserApiTokenType::class,
$user,
[
'action' => $this->generateUrl('user_profile_api_token', ['username' => $user->getUserIdentifier()]),
'method' => 'POST'
]
);
}
#[Route(path: '/{username}/2fa', name: 'user_profile_2fa', methods: ['GET', 'POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('2fa', 'profile')]

View File

@@ -9,6 +9,7 @@
namespace App\DataFixtures;
use App\Entity\AccessToken;
use App\Entity\User;
use App\Entity\UserPreference;
use Doctrine\Bundle\FixturesBundle\Fixture;
@@ -86,6 +87,10 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
*/
$manager->persist($prefs[0]);
$manager->persist($prefs[1]);
$accessToken = new AccessToken($user, $userData[10]);
$accessToken->setName('Test fixture');
$manager->persist($accessToken);
}
$manager->flush();
@@ -168,7 +173,8 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
// enabled = $userData[6]
// timezone = $userData[7]
// password = $userData[8]
// api = $userData[9]
// api old = $userData[9]
// api new = $userData[10]
return [
[
@@ -182,6 +188,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
'America/Vancouver',
self::DEFAULT_PASSWORD,
self::DEFAULT_API_TOKEN,
self::DEFAULT_API_TOKEN . '_john',
],
[
'John Doe',
@@ -194,6 +201,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
'America/Vancouver',
'password',
'password',
self::DEFAULT_API_TOKEN . '_user',
],
// inactive user to test login
[
@@ -207,6 +215,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
'Australia/Sydney',
self::DEFAULT_PASSWORD,
self::DEFAULT_API_TOKEN,
self::DEFAULT_API_TOKEN . '_inactive',
],
[
'Tony Maier',
@@ -219,6 +228,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
'Asia/Bangkok',
self::DEFAULT_PASSWORD,
self::DEFAULT_API_TOKEN,
self::DEFAULT_API_TOKEN . '_teamlead',
],
[
'Tony Maier',
@@ -231,6 +241,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
'Asia/Bangkok',
'password',
'password',
self::DEFAULT_API_TOKEN . '_tony',
],
// no avatar to test default image macro
[
@@ -244,6 +255,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
'Europe/London',
self::DEFAULT_PASSWORD,
self::DEFAULT_API_TOKEN,
self::DEFAULT_API_TOKEN . '_anna',
],
[
'Anna Smith',
@@ -256,6 +268,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
'Europe/London',
'password',
'password',
self::DEFAULT_API_TOKEN . '_admin',
],
// no alias to test twig username macro
[
@@ -269,6 +282,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
'Europe/Berlin',
self::DEFAULT_PASSWORD,
self::DEFAULT_API_TOKEN,
self::DEFAULT_API_TOKEN . '_susan',
],
[
null,
@@ -281,6 +295,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
'Europe/Berlin',
'password',
'password',
self::DEFAULT_API_TOKEN . '_super',
],
];
}

106
src/Entity/AccessToken.php Normal file
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\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_access_token')]
#[ORM\Entity(repositoryClass: 'App\Repository\AccessTokenRepository')]
#[ORM\UniqueConstraint(columns: ['token'])]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(fields: ['token'])]
class AccessToken
{
#[ORM\Column(name: 'id', type: 'integer')]
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private User $user;
#[ORM\Column(name: 'token', type: 'string', length: 100, nullable: false)]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100)]
private string $token;
#[ORM\Column(name: 'name', type: 'string', length: 50, nullable: false)]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 50)]
private ?string $name = null;
#[ORM\Column(name: 'last_usage', type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $lastUsage = null;
#[ORM\Column(name: 'expires_at', type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $expiresAt = null;
public function __construct(User $user, string $token)
{
$this->user = $user;
$this->token = $token;
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): User
{
return $this->user;
}
public function setName(?string $name): void
{
$this->name = $name;
}
public function getName(): ?string
{
return $this->name;
}
public function setLastUsage(\DateTimeImmutable $lastUsage): void
{
$this->lastUsage = $lastUsage;
}
public function getLastUsage(): ?\DateTimeImmutable
{
return $this->lastUsage;
}
public function getToken(): string
{
return $this->token;
}
public function getExpiresAt(): ?\DateTimeImmutable
{
return $this->expiresAt;
}
public function setExpiresAt(?\DateTimeImmutable $expiresAt): void
{
$this->expiresAt = $expiresAt;
}
public function isValid(): bool
{
return $this->expiresAt === null || $this->expiresAt > new \DateTimeImmutable();
}
public function __clone()
{
if ($this->id) {
$this->id = null;
}
}
}

View File

@@ -31,6 +31,11 @@ final class ProfileSubscriber implements EventSubscriberInterface
{
$request = $event->getRequest();
// make sure that we do NOT access the session, if the request is stateless
if ($request->attributes->getBoolean('_stateless')) {
return;
}
$profile = $this->profileManager->getProfileFromCookie($request);
if ($this->profileManager->isValidProfile($profile)) {
$this->profileManager->setProfile($request->getSession(), $profile);

View 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\Form;
use App\Entity\AccessToken;
use App\Form\Type\DatePickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class AccessTokenForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'required' => true,
])
->add('expiresAt', DatePickerType::class, [
'label' => 'expires',
'required' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AccessToken::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'access_token_form',
'attr' => [
'data-form-event' => 'kimai.accessToken'
],
]);
}
}

View File

@@ -17,10 +17,10 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Defines the form used to set the users API token.
* Defines the form used to set the users API password.
* @extends AbstractType<User>
*/
final class UserApiTokenType extends AbstractType
final class UserApiPasswordType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
@@ -40,7 +40,7 @@ final class UserApiTokenType extends AbstractType
'data_class' => User::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'edit_user_api_token',
'csrf_token_id' => 'edit_user_password_token',
]);
}
}

View File

@@ -0,0 +1,47 @@
<?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\Repository;
use App\Entity\AccessToken;
use App\Entity\User;
use Doctrine\ORM\EntityRepository;
/**
* @extends EntityRepository<AccessToken>
*/
class AccessTokenRepository extends EntityRepository
{
public function findByToken(string $token): ?AccessToken
{
return $this->findOneBy(['token' => $token]);
}
/**
* @return array<AccessToken>
*/
public function findForUser(User $user): array
{
return $this->findBy(['user' => $user]);
}
public function saveAccessToken(AccessToken $accessToken): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($accessToken);
$entityManager->flush();
}
public function deleteAccessToken(AccessToken $accessToken): void
{
$entityManager = $this->getEntityManager();
$entityManager->remove($accessToken);
$entityManager->flush();
}
}