diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml index 7525577f..7bdafee9 100644 --- a/config/packages/nelmio_api_doc.yaml +++ b/config/packages/nelmio_api_doc.yaml @@ -48,19 +48,13 @@ nelmio_api_doc: title: Kimai - API Docs description: | JSON API for the Kimai time-tracking software: [API documentation](https://www.kimai.org/documentation/rest-api.html), [Swagger definition file](doc.json) - version: '0.7' + version: '1.0' components: securitySchemes: - apiUser: - type: apiKey - description: 'Value: {Username}' - name: X-AUTH-USER - in: header - apiToken: - type: apiKey - description: 'Value: {API Token}' - name: X-AUTH-TOKEN - in: header + bearer: + type: http + scheme: bearer + bearerFormat: KIMAI + description: API Token security: - - X-AUTH-USER: [] - X-AUTH-TOKEN: [] + - bearer: [] diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 05e3bdc1..f236d88b 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -18,6 +18,8 @@ security: security: false api: + access_token: + token_handler: App\API\Authentication\AccessTokenHandler request_matcher: App\API\Authentication\ApiRequestMatcher user_checker: App\Security\UserChecker stateless: true @@ -35,7 +37,6 @@ security: entry_point: form_login custom_authenticators: - - App\API\Authentication\SessionAuthenticator - App\Saml\SamlAuthenticator remember_me: diff --git a/config/services.yaml b/config/services.yaml index 07dcf388..cbf5fc7d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -197,6 +197,11 @@ services: factory: ['@doctrine.orm.entity_manager', getRepository] arguments: ['App\Entity\WorkingTime'] + App\Repository\AccessTokenRepository: + class: App\Repository\AccessTokenRepository + factory: ['@doctrine.orm.entity_manager', getRepository] + arguments: ['App\Entity\AccessToken'] + monolog.formatter.kimai: class: Monolog\Formatter\LineFormatter arguments: diff --git a/migrations/Version20240214061246.php b/migrations/Version20240214061246.php new file mode 100644 index 00000000..401ea3be --- /dev/null +++ b/migrations/Version20240214061246.php @@ -0,0 +1,56 @@ +createTable('kimai2_access_token'); + + $accessTokens->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]); + $accessTokens->addColumn('user_id', 'integer', ['notnull' => true]); + $accessTokens->addColumn('token', 'string', ['notnull' => true, 'length' => 100]); + $accessTokens->addColumn('name', 'string', ['notnull' => true, 'length' => 50]); + $accessTokens->addColumn('last_usage', 'datetime_immutable', ['notnull' => false, 'default' => null]); + $accessTokens->addColumn('expires_at', 'datetime_immutable', ['notnull' => false, 'default' => null]); + + $accessTokens->setPrimaryKey(['id']); + + $accessTokens->addIndex(['user_id'], 'IDX_6FB0DB1EA76ED395'); + $accessTokens->addUniqueIndex(['token'], 'UNIQ_6FB0DB1E5F37A13B'); + + $accessTokens->addForeignKeyConstraint('kimai2_users', ['user_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_6FB0DB1EA76ED395'); + } + + public function down(Schema $schema): void + { + $table = $schema->getTable('kimai2_access_token'); + $table->removeForeignKey('FK_6FB0DB1EA76ED395'); + + $schema->dropTable('kimai2_access_token'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/src/API/ActionsController.php b/src/API/ActionsController.php index 84c7fe9c..be72d9a7 100644 --- a/src/API/ActionsController.php +++ b/src/API/ActionsController.php @@ -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); diff --git a/src/API/ActivityController.php b/src/API/ActivityController.php index 518c8fe5..9851b87e 100644 --- a/src/API/ActivityController.php +++ b/src/API/ActivityController.php @@ -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(); diff --git a/src/API/Authentication/AccessTokenHandler.php b/src/API/Authentication/AccessTokenHandler.php new file mode 100644 index 00000000..fb41e899 --- /dev/null +++ b/src/API/Authentication/AccessTokenHandler.php @@ -0,0 +1,46 @@ +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()); + } +} diff --git a/src/API/Authentication/ApiRequestMatcher.php b/src/API/Authentication/ApiRequestMatcher.php index e4658ab2..82d97069 100644 --- a/src/API/Authentication/ApiRequestMatcher.php +++ b/src/API/Authentication/ApiRequestMatcher.php @@ -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(); } } diff --git a/src/API/Authentication/SessionAuthenticator.php b/src/API/Authentication/SessionAuthenticator.php deleted file mode 100644 index f365a51f..00000000 --- a/src/API/Authentication/SessionAuthenticator.php +++ /dev/null @@ -1,69 +0,0 @@ -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); - } -} diff --git a/src/API/Authentication/TokenAuthenticator.php b/src/API/Authentication/TokenAuthenticator.php index d7852106..565342a9 100644 --- a/src/API/Authentication/TokenAuthenticator.php +++ b/src/API/Authentication/TokenAuthenticator.php @@ -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; diff --git a/src/API/ConfigurationController.php b/src/API/ConfigurationController.php index a02438ec..83262c73 100644 --- a/src/API/ConfigurationController.php +++ b/src/API/ConfigurationController.php @@ -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(); diff --git a/src/API/CustomerController.php b/src/API/CustomerController.php index 3bdede78..b02c7a08 100644 --- a/src/API/CustomerController.php +++ b/src/API/CustomerController.php @@ -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(); diff --git a/src/API/ProjectController.php b/src/API/ProjectController.php index cd0f7458..1533862f 100644 --- a/src/API/ProjectController.php +++ b/src/API/ProjectController.php @@ -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(); diff --git a/src/API/StatusController.php b/src/API/StatusController.php index 0ae04f0e..0da69f86 100644 --- a/src/API/StatusController.php +++ b/src/API/StatusController.php @@ -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 = []; diff --git a/src/API/TagController.php b/src/API/TagController.php index 70599e63..92b25676 100644 --- a/src/API/TagController.php +++ b/src/API/TagController.php @@ -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 { diff --git a/src/API/TeamController.php b/src/API/TeamController.php index 51597648..a066a5a4 100644 --- a/src/API/TeamController.php +++ b/src/API/TeamController.php @@ -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 { diff --git a/src/API/TimesheetController.php b/src/API/TimesheetController.php index 7f39e473..168de584 100644 --- a/src/API/TimesheetController.php +++ b/src/API/TimesheetController.php @@ -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 diff --git a/src/API/UserController.php b/src/API/UserController.php index a1474386..90a3c499 100644 --- a/src/API/UserController.php +++ b/src/API/UserController.php @@ -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); + } } diff --git a/src/Command/ResetTestCommand.php b/src/Command/ResetTestCommand.php index 47ab06c3..558b09ce 100644 --- a/src/Command/ResetTestCommand.php +++ b/src/Command/ResetTestCommand.php @@ -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; } diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php index 05cc9351..f4b1a8b2 100644 --- a/src/Controller/ProfileController.php +++ b/src/Controller/ProfileController.php @@ -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')] diff --git a/src/DataFixtures/UserFixtures.php b/src/DataFixtures/UserFixtures.php index 2e8e3688..cdf80f43 100644 --- a/src/DataFixtures/UserFixtures.php +++ b/src/DataFixtures/UserFixtures.php @@ -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', ], ]; } diff --git a/src/Entity/AccessToken.php b/src/Entity/AccessToken.php new file mode 100644 index 00000000..2eb01e98 --- /dev/null +++ b/src/Entity/AccessToken.php @@ -0,0 +1,106 @@ +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; + } + } +} diff --git a/src/EventSubscriber/ProfileSubscriber.php b/src/EventSubscriber/ProfileSubscriber.php index 86c4dd7f..92adb35c 100644 --- a/src/EventSubscriber/ProfileSubscriber.php +++ b/src/EventSubscriber/ProfileSubscriber.php @@ -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); diff --git a/src/Form/AccessTokenForm.php b/src/Form/AccessTokenForm.php new file mode 100644 index 00000000..1e9697dd --- /dev/null +++ b/src/Form/AccessTokenForm.php @@ -0,0 +1,46 @@ +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' + ], + ]); + } +} diff --git a/src/Form/UserApiTokenType.php b/src/Form/UserApiPasswordType.php similarity index 87% rename from src/Form/UserApiTokenType.php rename to src/Form/UserApiPasswordType.php index a800e75c..209a27a7 100644 --- a/src/Form/UserApiTokenType.php +++ b/src/Form/UserApiPasswordType.php @@ -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 */ -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', ]); } } diff --git a/src/Repository/AccessTokenRepository.php b/src/Repository/AccessTokenRepository.php new file mode 100644 index 00000000..ab5ee61d --- /dev/null +++ b/src/Repository/AccessTokenRepository.php @@ -0,0 +1,47 @@ + + */ +class AccessTokenRepository extends EntityRepository +{ + public function findByToken(string $token): ?AccessToken + { + return $this->findOneBy(['token' => $token]); + } + + /** + * @return array + */ + 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(); + } +} diff --git a/templates/bundles/NelmioApiDocBundle/SwaggerUi/index.html.twig b/templates/bundles/NelmioApiDocBundle/SwaggerUi/index.html.twig index 13cc61c6..54cd1504 100644 --- a/templates/bundles/NelmioApiDocBundle/SwaggerUi/index.html.twig +++ b/templates/bundles/NelmioApiDocBundle/SwaggerUi/index.html.twig @@ -5,9 +5,18 @@ {% endblock %} \ No newline at end of file diff --git a/templates/user/access-token.html.twig b/templates/user/access-token.html.twig new file mode 100644 index 00000000..4d3f6f9c --- /dev/null +++ b/templates/user/access-token.html.twig @@ -0,0 +1,19 @@ +{% extends kimai_context.modalRequest ? 'form.html.twig' : 'base.html.twig' %} +{% import "macros/widgets.html.twig" as widgets %} + +{% block main %} + {% set formEditTemplate = kimai_context.modalRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig' %} + {% set formOptions = { + 'title': (access_token.id is null ? 'create'|trans : 'edit'|trans({}, 'actions')), + 'form': form, + 'back': path('user_profile_access_token', {'username': user.userIdentifier}) + } %} + {% embed formEditTemplate with formOptions %} + {% form_theme form 'form/horizontal.html.twig' %} + {% block form_body %} + {{ form_row(form.name) }} + {{ form_row(form.expiresAt) }} + {{ form_rest(form) }} + {% endblock %} + {% endembed %} +{% endblock %} diff --git a/templates/user/api-token.html.twig b/templates/user/api-token.html.twig index b6a53856..90ade444 100644 --- a/templates/user/api-token.html.twig +++ b/templates/user/api-token.html.twig @@ -1,31 +1,103 @@ {% extends 'user/form.html.twig' %} {% import "macros/widgets.html.twig" as widgets %} -{% block form_pre_content %} +{% block form_body %} +
- {% if user.apiToken is empty %}
-
- {{ widgets.alert('warning', 'api_password.missing_description'|trans, 'api_password.missing_title'|trans, 'warning', false) }} -
-
- {% endif %} +
+

+ {{ 'api_password.intro'|trans }} +

+

+ URL: {{ url('api.swagger_ui', {}, false)|replace({'/doc': ''}) }} +

+
-
-
-
-

{{ 'api_password.intro'|trans }}

-
    -
  • {{ 'username'|trans }}: {{ user.userIdentifier }}
  • -
  • URL: {{ url('api.swagger_ui', {}, false)|replace({'/doc': ''}) }}
  • -
-
- - +
+ + {% if created_token is not null %} +
+
{{ 'status.new'|trans }}
+
+

{{ created_token.name }}

+

+

{{ 'api_token_hidden'|trans }}

+
+
+ +
+
{{ created_token.token }}
+
+
+
+ {% endif %} + + {% if access_tokens|length > 0 %} + + + + + + + + + + + {% for token in access_tokens %} + + + + + + + {% endfor %} + +
{{ 'name'|trans }}{{ 'last_usage'|trans }}{{ 'expires'|trans }}
{{ token.name }} + {% if token.lastUsage is not null %} + {{ token.lastUsage|date }} + {% endif %} + + {% if token.expiresAt is not null %} + {{ token.expiresAt|date }} + {% endif %} + + {{ widgets.action_button('trash', {'url': path('delete_api_token', {id: token.id}), class: 'api-link', 'attr': { + 'data-question': 'confirm.delete', + 'data-event': 'kimai.accessToken', + 'data-method': 'DELETE', + 'data-question': 'confirm.delete', + 'data-msg-error': 'action.delete.error', + 'data-msg-success': 'action.delete.success' + }}) }} +
+ {% endif %} + + {% embed '@theme/embeds/collapsible.html.twig' with {id: 'activity_invoice_settings'} %} + {% import "macros/widgets.html.twig" as widgets %} + {% block title %}{{ 'password'|trans }}{% endblock %} + {% block body %} + {{ widgets.alert('danger', 'api_password_deprecated'|trans) }} + {{ form_start(form) }} + {{ form_widget(form) }} + + {{ form_end(form) }} + {% endblock %} + {% endembed %} +
- +{% endblock %} + +{% block javascripts %} + {{ parent() }} + {% endblock %} diff --git a/tests/API/APIControllerBaseTest.php b/tests/API/APIControllerBaseTest.php index 84fd70df..304e90c0 100644 --- a/tests/API/APIControllerBaseTest.php +++ b/tests/API/APIControllerBaseTest.php @@ -22,42 +22,25 @@ use Symfony\Component\HttpKernel\HttpKernelBrowser; */ abstract class APIControllerBaseTest extends ControllerBaseTest { + /** + * @return array + */ + private function getAuthHeader(string $username, string $password): array + { + return [ + 'HTTP_AUTHORIZATION' => 'Bearer ' . $password, + ]; + } + protected function getClientForAuthenticatedUser(string $role = User::ROLE_USER): HttpKernelBrowser { - switch ($role) { - case User::ROLE_SUPER_ADMIN: - $client = self::createClient([], [ - 'HTTP_X_AUTH_USER' => UserFixtures::USERNAME_SUPER_ADMIN, - 'HTTP_X_AUTH_TOKEN' => UserFixtures::DEFAULT_API_TOKEN, - ]); - break; - - case User::ROLE_ADMIN: - $client = self::createClient([], [ - 'HTTP_X_AUTH_USER' => UserFixtures::USERNAME_ADMIN, - 'HTTP_X_AUTH_TOKEN' => UserFixtures::DEFAULT_API_TOKEN, - ]); - break; - - case User::ROLE_TEAMLEAD: - $client = self::createClient([], [ - 'HTTP_X_AUTH_USER' => UserFixtures::USERNAME_TEAMLEAD, - 'HTTP_X_AUTH_TOKEN' => UserFixtures::DEFAULT_API_TOKEN, - ]); - break; - - case User::ROLE_USER: - $client = self::createClient([], [ - 'HTTP_X_AUTH_USER' => UserFixtures::USERNAME_USER, - 'HTTP_X_AUTH_TOKEN' => UserFixtures::DEFAULT_API_TOKEN, - ]); - break; - - default: - throw new \Exception(sprintf('Unknown role "%s"', $role)); - } - - return $client; + return match ($role) { + User::ROLE_SUPER_ADMIN => self::createClient([], $this->getAuthHeader(UserFixtures::USERNAME_SUPER_ADMIN, UserFixtures::DEFAULT_API_TOKEN . '_super')), + User::ROLE_ADMIN => self::createClient([], $this->getAuthHeader(UserFixtures::USERNAME_ADMIN, UserFixtures::DEFAULT_API_TOKEN . '_admin')), + User::ROLE_TEAMLEAD => self::createClient([], $this->getAuthHeader(UserFixtures::USERNAME_TEAMLEAD, UserFixtures::DEFAULT_API_TOKEN . '_teamlead')), + User::ROLE_USER => self::createClient([], $this->getAuthHeader(UserFixtures::USERNAME_USER, UserFixtures::DEFAULT_API_TOKEN . '_user')), + default => throw new \Exception(sprintf('Unknown role "%s"', $role)), + }; } protected function createUrl(string $url): string @@ -81,16 +64,12 @@ abstract class APIControllerBaseTest extends ControllerBaseTest protected function assertRequestIsSecured(HttpKernelBrowser $client, string $url, string $method = 'GET'): void { $this->request($client, $url, $method); - $this->assertResponseIsSecured($client->getResponse(), $url); - } + $response = $client->getResponse(); - /** - * @param Response $response - * @param string $url - */ - protected function assertResponseIsSecured(Response $response, string $url): void - { - $data = ['message' => 'Authentication required, missing user header: X-AUTH-USER']; + $data = [ + 'message' => 'Unauthorized', + 'code' => 401 + ]; self::assertEquals( $data, @@ -99,17 +78,12 @@ abstract class APIControllerBaseTest extends ControllerBaseTest ); self::assertEquals( - Response::HTTP_FORBIDDEN, + Response::HTTP_UNAUTHORIZED, $response->getStatusCode(), sprintf('The secure URL %s has the wrong status code %s.', $url, $response->getStatusCode()) ); } - /** - * @param string $role - * @param string $url - * @param string $method - */ protected function assertUrlIsSecuredForRole(string $role, string $url, string $method = 'GET'): void { $client = $this->getClientForAuthenticatedUser($role); diff --git a/tests/API/ApiDocControllerTest.php b/tests/API/ApiDocControllerTest.php index 3362f18d..6bcbfffb 100644 --- a/tests/API/ApiDocControllerTest.php +++ b/tests/API/ApiDocControllerTest.php @@ -97,20 +97,20 @@ class ApiDocControllerTest extends ControllerBaseTest '/api/users', '/api/users/{id}', '/api/users/me', + '/api/users/api-token/{id}', ]; $this->assertArrayHasKey('openapi', $json); $this->assertEquals('3.0.0', $json['openapi']); $this->assertArrayHasKey('info', $json); $this->assertEquals('Kimai - API Docs', $json['info']['title']); - $this->assertEquals('0.7', $json['info']['version']); + $this->assertEquals('1.0', $json['info']['version']); $this->assertArrayHasKey('paths', $json); $this->assertEquals($paths, array_keys($json['paths'])); $this->assertArrayHasKey('security', $json); - $this->assertArrayHasKey('X-AUTH-USER', $json['security'][0]); - $this->assertArrayHasKey('X-AUTH-TOKEN', $json['security'][0]); + $this->assertEquals(['bearer' => []], $json['security'][0]); $this->assertArrayHasKey('components', $json); $this->assertArrayHasKey('schemas', $json['components']); diff --git a/tests/API/Authentication/AccessTokenHandlerTest.php b/tests/API/Authentication/AccessTokenHandlerTest.php new file mode 100644 index 00000000..e066d06a --- /dev/null +++ b/tests/API/Authentication/AccessTokenHandlerTest.php @@ -0,0 +1,65 @@ +createMock(AccessTokenRepository::class); + $userProvider->method('findByToken')->willReturn($accessToken); + + return new AccessTokenHandler($userProvider); + } + + public function testUnknownToken(): void + { + $this->expectException(BadCredentialsException::class); + $this->expectExceptionMessage('Invalid credentials.'); + $sut = $this->getSut(); + $sut->getUserBadgeFrom('foo'); + } + + public function testInvalidToken(): void + { + $user = new User(); + $user->setUserIdentifier('foo'); + $accessToken = new AccessToken($user, 'Test'); + $accessToken->setExpiresAt(new \DateTimeImmutable('-1 day')); + + $this->expectException(BadCredentialsException::class); + $this->expectExceptionMessage('Invalid token.'); + $sut = $this->getSut($accessToken); + $sut->getUserBadgeFrom('foo'); + } + + public function testValidTokenSetsLastUsage(): void + { + $user = new User(); + $user->setUserIdentifier('foo-bar'); + $accessToken = new AccessToken($user, 'Test'); + $this->assertNull($accessToken->getLastUsage()); + $sut = $this->getSut($accessToken); + + $badge = $sut->getUserBadgeFrom('foo'); + $this->assertNotNull($accessToken->getLastUsage()); + $this->assertSame('foo-bar', $badge->getUserIdentifier()); + } +} diff --git a/tests/API/Authentication/SessionAuthenticatorTest.php b/tests/API/Authentication/SessionAuthenticatorTest.php deleted file mode 100644 index e0a320d4..00000000 --- a/tests/API/Authentication/SessionAuthenticatorTest.php +++ /dev/null @@ -1,178 +0,0 @@ -createMock(ApiUserRepository::class); - $passwordHasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); - $passwordHasher = $this->createMock(PasswordHasherInterface::class); - $passwordHasher->method('verify')->willReturn($verify); - $passwordHasherFactory->method('getPasswordHasher')->willReturn($passwordHasher); - - $token = new TokenAuthenticator($userProvider, $passwordHasherFactory); - - return new SessionAuthenticator($token); - } - - public function testSupports(): void - { - $sut = $this->getSut(); - - // not supporting because /api path is not the beginning of the URL - $request = new Request([], [], [], [], [], ['REQUEST_URI' => 'dfghj/api/doc/dfghj']); - self::assertFalse($sut->supports($request)); - - $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo']); - self::assertTrue($sut->supports($request)); - - $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/doc']); - self::assertFalse($sut->supports($request)); - - $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-SESSION' => true]); - self::assertFalse($sut->supports($request)); - - $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo', 'HTTP_X-AUTH-TOKEN' => 'bar']); - self::assertTrue($sut->supports($request)); - - $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo', 'HTTP_X-AUTH-TOKEN' => 'bar', 'HTTP_X-AUTH-SESSION' => true]); - self::assertFalse($sut->supports($request)); - } - - public function testAuthenticateWithMissingAuthHeader(): void - { - $this->expectException(CustomUserMessageAuthenticationException::class); - $this->expectExceptionMessage('Authentication required, missing user header: X-AUTH-USER'); - - $sut = $this->getSut(); - - $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo']); - $sut->authenticate($request); - } - - public function testAuthenticateWithMissingToken(): void - { - $this->expectException(CustomUserMessageAuthenticationException::class); - $this->expectExceptionMessage('Authentication required, missing token header: X-AUTH-TOKEN'); - - $sut = $this->getSut(); - - $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo']); - $sut->authenticate($request); - } - - public function testAuthenticateWithEmptyToken(): void - { - $this->expectException(CustomUserMessageAuthenticationException::class); - $this->expectExceptionMessage('Authentication required, missing token header: X-AUTH-TOKEN'); - - $sut = $this->getSut(); - - $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo', 'HTTP_X-AUTH-TOKEN' => '']); - $sut->authenticate($request); - } - - public function testAuthenticateWithMissingUser(): void - { - $this->expectException(CustomUserMessageAuthenticationException::class); - $this->expectExceptionMessage('Authentication required, missing user header: X-AUTH-USER'); - - $sut = $this->getSut(); - - $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-TOKEN' => 'bar']); - $sut->authenticate($request); - } - - public function testAuthenticateWithEmptyUser(): void - { - $this->expectException(CustomUserMessageAuthenticationException::class); - $this->expectExceptionMessage('Authentication required, missing user header: X-AUTH-USER'); - - $sut = $this->getSut(); - - $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => '', 'HTTP_X-AUTH-TOKEN' => 'bar']); - $sut->authenticate($request); - } - - public function testAuthenticate(): void - { - $sut = $this->getSut(); - - $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo2', 'HTTP_X-AUTH-TOKEN' => 'bar']); - $passport = $sut->authenticate($request); - self::assertInstanceOf(Passport::class, $passport); - $badge = $passport->getBadge(UserBadge::class); - self::assertInstanceOf(UserBadge::class, $badge); - self::assertEquals('foo2', $badge->getUserIdentifier()); - - $user = new User(); - $user->setApiToken('bar2'); - - $badge = $passport->getBadge(CustomCredentials::class); - self::assertInstanceOf(CustomCredentials::class, $badge); - self::assertFalse($badge->isResolved()); - $badge->executeCustomChecker($user); - self::assertTrue($badge->isResolved()); - } - - public function testAuthenticateFailsOnMissingApiTokenForUser(): void - { - $this->expectException(BadCredentialsException::class); - $this->expectExceptionMessage('The user has no activated API account.'); - - $sut = $this->getSut(); - - $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo2', 'HTTP_X-AUTH-TOKEN' => 'bar']); - $passport = $sut->authenticate($request); - - $user = new User(); - - /** @var CustomCredentials $badge */ - $badge = $passport->getBadge(CustomCredentials::class); - $badge->executeCustomChecker($user); - } - - public function testAuthenticateFailsOnWrongPassword(): void - { - $this->expectException(BadCredentialsException::class); - $this->expectExceptionMessage('The presented password is invalid.'); - - $sut = $this->getSut(false); - - $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo2', 'HTTP_X-AUTH-TOKEN' => 'bar']); - $passport = $sut->authenticate($request); - - $user = new User(); - $user->setApiToken('bar'); - - /** @var CustomCredentials $badge */ - $badge = $passport->getBadge(CustomCredentials::class); - $badge->executeCustomChecker($user); - } -} diff --git a/tests/API/Authentication/TokenAuthenticatorTest.php b/tests/API/Authentication/TokenAuthenticatorTest.php index 520d7f58..56fc036d 100644 --- a/tests/API/Authentication/TokenAuthenticatorTest.php +++ b/tests/API/Authentication/TokenAuthenticatorTest.php @@ -24,6 +24,7 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Passport; /** * @covers \App\API\Authentication\TokenAuthenticator + * @group legacy */ class TokenAuthenticatorTest extends TestCase { @@ -47,19 +48,13 @@ class TokenAuthenticatorTest extends TestCase self::assertFalse($sut->supports($request)); $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo']); - self::assertTrue($sut->supports($request)); + self::assertFalse($sut->supports($request)); $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/doc']); self::assertFalse($sut->supports($request)); - $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-SESSION' => true]); - self::assertTrue($sut->supports($request)); - $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo', 'HTTP_X-AUTH-TOKEN' => 'bar']); self::assertTrue($sut->supports($request)); - - $request = new Request([], [], [], [], [], ['REQUEST_URI' => '/api/fooo', 'HTTP_X-AUTH-USER' => 'foo', 'HTTP_X-AUTH-TOKEN' => 'bar', 'HTTP_X-AUTH-SESSION' => true]); - self::assertTrue($sut->supports($request)); } public function testAuthenticateWithMissingAuthHeader(): void diff --git a/tests/API/AuthenticationTest.php b/tests/API/AuthenticationTest.php new file mode 100644 index 00000000..b0fde843 --- /dev/null +++ b/tests/API/AuthenticationTest.php @@ -0,0 +1,93 @@ +assertUrlIsSecured('/api/ping'); + } + + public function testPingWithAccessToken(): void + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_USER); + $this->assertAccessIsGranted($client, '/api/ping'); + $response = $client->getResponse()->getContent(); + $this->assertIsString($response); + $result = json_decode($response, true); + + $this->assertIsArray($result); + $this->assertEquals(['message' => 'pong'], $result); + } + + public function testPingWithAuthTokenAndUsername(): void + { + $client = self::createClient([], $this->getAuthHeader(UserFixtures::USERNAME_USER, UserFixtures::DEFAULT_API_TOKEN)); + + $this->assertAccessIsGranted($client, '/api/ping'); + $response = $client->getResponse()->getContent(); + $this->assertIsString($response); + $result = json_decode($response, true); + + $this->assertIsArray($result); + $this->assertEquals(['message' => 'pong'], $result); + } + + public function testPingWithInvalidAuthTokenAndUsername(): void + { + $client = self::createClient([], $this->getAuthHeader(UserFixtures::USERNAME_USER, 'xxxx')); + $url = '/api/ping'; + $method = 'GET'; + + $this->request($client, $url, $method); + $response = $client->getResponse(); + + $data = [ + 'message' => 'Invalid credentials', + ]; + + $this->assertIsString($response->getContent()); + + $this->assertEquals( + $data, + json_decode($response->getContent(), true), + sprintf('The secure URL %s is not protected.', $url) + ); + + $this->assertEquals( + Response::HTTP_FORBIDDEN, + $response->getStatusCode(), + sprintf('The secure URL %s has the wrong status code %s.', $url, $response->getStatusCode()) + ); + } + + /** + * @return array + */ + private function getAuthHeader(string $username, string $password): array + { + return [ + 'HTTP_X_AUTH_USER' => $username, + 'HTTP_X_AUTH_TOKEN' => $password, + ]; + } +} diff --git a/tests/Command/InvoiceCreateCommandTest.php b/tests/Command/InvoiceCreateCommandTest.php index ccd18bd9..f7bde859 100644 --- a/tests/Command/InvoiceCreateCommandTest.php +++ b/tests/Command/InvoiceCreateCommandTest.php @@ -84,7 +84,6 @@ class InvoiceCreateCommandTest extends KernelTestCase * Allowed option: exported * Allowed option: by-customer * Allowed option: by-project - * Allowed option: set-exported * Allowed option: template-meta * * @param array $options @@ -169,7 +168,7 @@ class InvoiceCreateCommandTest extends KernelTestCase $fixture = new InvoiceTemplateFixtures(); $this->importFixture($fixture); - $commandTester = $this->createInvoice(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--set-exported' => null, '--customer' => 1, '--template' => 'Invoice', '--start' => '2020-01-01', '--end' => '2020-03-01']); + $commandTester = $this->createInvoice(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--customer' => 1, '--template' => 'Invoice', '--start' => '2020-01-01', '--end' => '2020-03-01']); $output = $commandTester->getDisplay(); $this->assertStringContainsString('Created 1 invoice(s)', $output); diff --git a/tests/Controller/ProfileControllerTest.php b/tests/Controller/ProfileControllerTest.php index 3b046249..6d395bab 100644 --- a/tests/Controller/ProfileControllerTest.php +++ b/tests/Controller/ProfileControllerTest.php @@ -265,9 +265,9 @@ class ProfileControllerTest extends ControllerBaseTest $this->assertFalse($passwordEncoder->getPasswordHasher($user)->verify($user->getApiToken(), 'test1234')); $this->assertEquals(UserFixtures::USERNAME_USER, $user->getUserIdentifier()); - $form = $client->getCrawler()->filter('form[name=user_api_token]')->form(); + $form = $client->getCrawler()->filter('form[name=user_api_password]')->form(); $client->submit($form, [ - 'user_api_token' => [ + 'user_api_password' => [ 'plainApiToken' => [ 'first' => 'test1234', 'second' => 'test1234', @@ -292,16 +292,16 @@ class ProfileControllerTest extends ControllerBaseTest $this->assertFormHasValidationError( User::ROLE_USER, '/profile/' . UserFixtures::USERNAME_USER . '/api-token', - 'form[name=user_api_token]', + 'form[name=user_api_password]', [ - 'user_api_token' => [ + 'user_api_password' => [ 'plainApiToken' => [ 'first' => 'abcdef1', 'second' => 'abcdef1', ] ] ], - ['#user_api_token_plainApiToken_first'] + ['#user_api_password_plainApiToken_first'] ); } diff --git a/tests/Entity/AccessTokenTest.php b/tests/Entity/AccessTokenTest.php new file mode 100644 index 00000000..e226856c --- /dev/null +++ b/tests/Entity/AccessTokenTest.php @@ -0,0 +1,52 @@ +assertNull($sut->getId()); + $this->assertNull($sut->getName()); + $this->assertNull($sut->getExpiresAt()); + $this->assertNull($sut->getLastUsage()); + $this->assertSame('foo', $sut->getToken()); + $this->assertSame($user, $sut->getUser()); + $this->assertTrue($sut->isValid()); + + $sut->setName('bar'); + $this->assertSame('bar', $sut->getName()); + + $dateTime = new \DateTimeImmutable('-1 year'); + $sut->setLastUsage($dateTime); + $this->assertSame($dateTime, $sut->getLastUsage()); + + $dateTime = new \DateTimeImmutable('-1 month'); + $sut->setExpiresAt($dateTime); + $this->assertSame($dateTime, $sut->getExpiresAt()); + } + + public function testIsValid(): void + { + $user = new User(); + $sut = new AccessToken($user, 'foo'); + $sut->setExpiresAt(new \DateTimeImmutable('-1 day')); + $this->assertFalse($sut->isValid()); + } +} diff --git a/translations/messages.ar.xlf b/translations/messages.ar.xlf index 98daeba9..2bac4284 100644 --- a/translations/messages.ar.xlf +++ b/translations/messages.ar.xlf @@ -1381,10 +1381,6 @@ help.invoiceTemplate_customer يتم إنشاء فواتير هذا الزبون بشكل افتراضي باستخدام هذا النموذج. يمكن تغييره أثناء إنشاء الفاتورة إذا لزم الأمر. - - api_password.missing_description - لم تقم بعد بإدخال كلمة مرور API. لأسباب أمنية ، لا يمكنك استخدام واجهة برمجة التطبيقات حتى تقوم بحفظ كلمة مرور. - remove_filter إعادة تعيين عوامل تصفية البحث diff --git a/translations/messages.cs.xlf b/translations/messages.cs.xlf index fc9d4f19..a80b6a17 100644 --- a/translations/messages.cs.xlf +++ b/translations/messages.cs.xlf @@ -1459,10 +1459,6 @@ Deactivated Deaktivováno - - api_password.missing_description - Dosud jste nezadali heslo API. Z bezpečnostních důvodů nemůžete rozhraní API používat, dokud heslo neuložíte. - Activated Aktivováno diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index 99aa68cd..7a71b6b4 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -1514,16 +1514,12 @@ api_password.intro - Das API Passwort dient zur Kommunikation zwischen Kimai und von Ihnen genutzten Apps + Hier verwalten Sie Ihre API-Tokens, welche zur Authentifizierung Ihrer Anwendung gegenüber Kimai dienen. api_password.missing_title API kann nicht genutzt werden - - api_password.missing_description - Sie haben noch kein API Passwort hinterlegt. Aus Sicherheitsgründen ist die Nutzung der API bis zur Hinterlegung eines Passworts für Sie nicht nutzbar. - search.no_results Kein Ergebnis gefunden für "%input%" @@ -1752,6 +1748,22 @@ absence_comment_mandatory Abwesenheit: Kommentar ist Pflichtfeld + + Expiry date + Ablaufdatum + + + Last usage + Letzte Nutzung + + + api_token_hidden + Dies ist das einzige Mal, dass der Token angezeigt wird! Bewahren Sie ihn gut auf und vergewissern Sie sich, dass Sie ihn notiert haben, bevor Sie dieses Fenster schließen. + + + api_password_deprecated + API Passwörter sind veraltet: bitte nutzen Sie stattdessen API-Tokens. + diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index eba1a7d3..183eb552 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -1514,16 +1514,12 @@ api_password.intro - The API password is used for communication between Kimai and your apps + Here you can manage your API tokens, which are used to authenticate your application to Kimai. api_password.missing_title API cannot be used - - api_password.missing_description - You have not yet entered an API password. For security reasons, you cannot use the API until you have saved a password. - search.no_results No results found for "%input%" @@ -1752,6 +1748,22 @@ absence_comment_mandatory Absence: Comment is a mandatory field + + Expiry date + Expiry date + + + Last usage + Last usage + + + api_token_hidden + This is the only time this key will ever be displayed! So keep it safe and make sure you've copied it down before closing this window. + + + api_password_deprecated + API passwords are outdated: please use API tokens instead. + diff --git a/translations/messages.es.xlf b/translations/messages.es.xlf index 5d0d70ca..d802551d 100644 --- a/translations/messages.es.xlf +++ b/translations/messages.es.xlf @@ -1407,10 +1407,6 @@ api_password.missing_title No se puede usar la aplicación - - api_password.missing_description - Aún no ha ingresado una contraseña para la aplicación. Por razones de seguridad, no puede usar la aplicación hasta que haya guardado una contraseña. - search.no_results No se han encontrado resultados para "%input%" diff --git a/translations/messages.fa.xlf b/translations/messages.fa.xlf index 0ee108f7..2acf9e23 100644 --- a/translations/messages.fa.xlf +++ b/translations/messages.fa.xlf @@ -1184,10 +1184,6 @@ help.globalActivity اگر پروژه ای را انتخاب نکنید، این فعالیت جهانی می شود و می توان آن را با هر پروژه ای ترکیب کرد. اگر پروژه ای را انتخاب کنید، فعالیت فقط با آن پروژه قابل استفاده است. تنظیم را نمی توان بعداً تغییر داد. - - api_password.missing_description - شما هنوز رمز عبور API را وارد نکرده اید. به دلایل امنیتی، تا زمانی که رمز عبور را ذخیره نکرده باشید، نمی توانید از API استفاده کنید. - remove_filter فیلتر جستجو را بازنشانی کنید diff --git a/translations/messages.fi.xlf b/translations/messages.fi.xlf index 53c1d5ab..fdf3ef0c 100644 --- a/translations/messages.fi.xlf +++ b/translations/messages.fi.xlf @@ -1355,10 +1355,6 @@ skin Design - - api_password.missing_description - Et ole vielä syöttänyt API salasanaa. Turvallisuussyistä et voi käyttää API:a ennen salasanan tallentamista. - modal.columns.profile Profiili diff --git a/translations/messages.fr.xlf b/translations/messages.fr.xlf index 6eafeb0f..a49acf70 100644 --- a/translations/messages.fr.xlf +++ b/translations/messages.fr.xlf @@ -1371,10 +1371,6 @@ api_password.missing_title L'API ne peut pas être utilisée - - api_password.missing_description - Vous n'avez pas encore saisi de mot de passe pour l'API. Pour des raisons de sécurité, vous ne pouvez pas utiliser l'API tant que vous n'avez pas enregistré un mot de passe. - search.no_results Aucun résultat trouvé pour "%input%" diff --git a/translations/messages.he.xlf b/translations/messages.he.xlf index 6967ef5c..6a3b930a 100644 --- a/translations/messages.he.xlf +++ b/translations/messages.he.xlf @@ -1298,10 +1298,6 @@ skin.dark כהה - - api_password.missing_description - טרם מילאת סיסמה ל־API. מטעמי אבטחה, אי אפשר להשתמש ב־API עד לשמירת סיסמה. - please_choose נא לבחור diff --git a/translations/messages.hr.xlf b/translations/messages.hr.xlf index 1af43a0f..dde00a7b 100644 --- a/translations/messages.hr.xlf +++ b/translations/messages.hr.xlf @@ -1343,10 +1343,6 @@ skin.dark Tamno - - api_password.missing_description - Još nisi upisao/la API lozinku. Iz sigurnosnih razloga ne možeš koristiti API dok ne spremiš lozinku. - api_password.missing_title API se ne može koristiti diff --git a/translations/messages.it.xlf b/translations/messages.it.xlf index b00abf65..e0623eae 100644 --- a/translations/messages.it.xlf +++ b/translations/messages.it.xlf @@ -1422,10 +1422,6 @@ api_password.missing_title Non è possibile usare l'API - - api_password.missing_description - Non hai ancora inserito una password API. Per motivi di sicurezza, non puoi usare l'API finché non hai salvato una password. - please_choose Fai la tua scelta diff --git a/translations/messages.nb_NO.xlf b/translations/messages.nb_NO.xlf index 494cf0a0..c708dbab 100644 --- a/translations/messages.nb_NO.xlf +++ b/translations/messages.nb_NO.xlf @@ -1393,10 +1393,6 @@ Activated Aktivert - - api_password.missing_description - Du har ikke skrevet inn et API-passord enda. Av sikkerhetshensyn kan du ikke bruke API-et til du har lagret et passord. - search.no_results Fant ingen resultater for «%input%» diff --git a/translations/messages.nl.xlf b/translations/messages.nl.xlf index bf2b54d0..9e20b3d1 100644 --- a/translations/messages.nl.xlf +++ b/translations/messages.nl.xlf @@ -1415,10 +1415,6 @@ api_password.intro Het API-wachtwoord wordt gebruikt voor communicatie tussen Kimai en uw applicaties - - api_password.missing_description - U heeft nog geen API-wachtwoord ingevoerd. Vanwege veiligheidsredenen kunt u de API niet gebruiken tot u een wachtwoord heeft opgeslagen. - search.no_results Geen resultaten gevonden voor "%input%" diff --git a/translations/messages.pl.xlf b/translations/messages.pl.xlf index c2ee63a4..83c34983 100644 --- a/translations/messages.pl.xlf +++ b/translations/messages.pl.xlf @@ -1447,10 +1447,6 @@ help.globalActivities W przypadku wyłączenia tego ustawienia, czas może być rejestrowany jedynie przy działaniach specyficznych dla projektu. - - api_password.missing_description - Nie wprowadzono jeszcze hasła do API. Ze względów bezpieczeństwa nie będziesz mógł korzystać z API, dopóki nie wprowadzisz hasła. - help.invoiceLabel Ten tekst zastępuje nazwę przedmiotu, umożliwiając bardziej szczegółowe oznaczenie dla przedmiotów faktury. diff --git a/translations/messages.pt.xlf b/translations/messages.pt.xlf index d5d9126b..6fd50aa3 100644 --- a/translations/messages.pt.xlf +++ b/translations/messages.pt.xlf @@ -1383,10 +1383,6 @@ api_password.missing_title A API não pode ser usada - - api_password.missing_description - Ainda não inseriu uma palavra-passe para a API. Por questões de segurança,não pode usar a API até que uma palavra-passe seja salva. - search.no_results Nenhum resultado foi encontrado para "%input%" diff --git a/translations/messages.pt_BR.xlf b/translations/messages.pt_BR.xlf index 473d7bce..9d9df23f 100644 --- a/translations/messages.pt_BR.xlf +++ b/translations/messages.pt_BR.xlf @@ -1403,10 +1403,6 @@ api_password.missing_title A API não pode ser usada - - api_password.missing_description - Você ainda não inseriu uma senha para a API. Por questões de segurança, você não pode usar a API até que uma senha seja salva. - search.no_results Nenhum resultado foi encontrado para "%input%" diff --git a/translations/messages.sk.xlf b/translations/messages.sk.xlf index 325ea153..bba00df9 100644 --- a/translations/messages.sk.xlf +++ b/translations/messages.sk.xlf @@ -1194,10 +1194,6 @@ api_password.missing_title API sa nedá použiť - - api_password.missing_description - Nezadali ste API heslo. Kvôli bezpečnostným dôvodom nemôžete používať API kým nenastavíte heslo. - send_to Poslať %name% diff --git a/translations/messages.sv.xlf b/translations/messages.sv.xlf index 0fc7341c..0e686f48 100644 --- a/translations/messages.sv.xlf +++ b/translations/messages.sv.xlf @@ -1255,10 +1255,6 @@ help.globalActivity Om du inte väljer ett projekt blir denna aktivitet global och den kan kombineras med vilket projekt som helst. Om du väljer ett projekt kan aktiviteten endast användas med det projektet. Inställningen kan inte ändras senare. - - api_password.missing_description - Du har ännu inte angett ett API-lösenord. Av säkerhetsskäl kan du inte använda API förrän du har sparat ett lösenord. - stats.userDurationWeek Mina arbetstider denna vecka diff --git a/translations/messages.tr.xlf b/translations/messages.tr.xlf index dfe47cfd..066fb51c 100644 --- a/translations/messages.tr.xlf +++ b/translations/messages.tr.xlf @@ -1423,10 +1423,6 @@ extended_settings Genişletilmiş ayarlar - - api_password.missing_description - Henüz bir API parolası girmediniz. Güvenlik nedeniyle, bir parola kaydedinceye kadar API'yi kullanamazsınız. - please_choose Lütfen seçin diff --git a/translations/messages.uk.xlf b/translations/messages.uk.xlf index 5f7f09aa..c384f213 100644 --- a/translations/messages.uk.xlf +++ b/translations/messages.uk.xlf @@ -1362,10 +1362,6 @@ Activated Активовано - - api_password.missing_description - Ви ще не внесли пароль API. З огляду на безпеку, користуватися API без збереженого пароля Ви не можете. - about.title Про Kimai diff --git a/translations/messages.zh_CN.xlf b/translations/messages.zh_CN.xlf index 59694453..a61df4f6 100644 --- a/translations/messages.zh_CN.xlf +++ b/translations/messages.zh_CN.xlf @@ -1403,10 +1403,6 @@ api_password.intro API 密码用于 Kimai 和您的应用程序之间的通信 - - api_password.missing_description - 您尚未输入 API 密码。出于安全原因,您必须先保存密码才能使用 API。 - search.no_results 找不到 "%input%" 的结果 diff --git a/translations/messages.zh_Hant.xlf b/translations/messages.zh_Hant.xlf index 4bd27430..57d57533 100644 --- a/translations/messages.zh_Hant.xlf +++ b/translations/messages.zh_Hant.xlf @@ -1130,10 +1130,6 @@ skin 設計 - - api_password.missing_description - 您尚未輸入 API 密碼。基於安全考量,您必須先儲存密碼才能使用 API。 - modal.columns.profile 資料