Skip to content

Commit

Permalink
added API tokens, deprecate API passwords (#4637)
Browse files Browse the repository at this point in the history
  • Loading branch information
kevinpapst committed Apr 5, 2024
1 parent dd51c8d commit afe0656
Show file tree
Hide file tree
Showing 60 changed files with 886 additions and 621 deletions.
20 changes: 7 additions & 13 deletions config/packages/nelmio_api_doc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
3 changes: 2 additions & 1 deletion config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,7 +37,6 @@ security:
entry_point: form_login

custom_authenticators:
- App\API\Authentication\SessionAuthenticator
- App\Saml\SamlAuthenticator

remember_me:
Expand Down
5 changes: 5 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
56 changes: 56 additions & 0 deletions migrations/Version20240214061246.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace DoctrineMigrations;

use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;

/**
* @version 2.14
*/
final class Version20240214061246 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds the table for API access tokens';
}

public function up(Schema $schema): void
{
$accessTokens = $schema->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;
}
}
9 changes: 0 additions & 9 deletions src/API/ActionsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
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;
Expand Down Expand Up @@ -73,8 +72,6 @@ private function convertEvent(PageActionsEvent $event, string $locale): array
#[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);
Expand All @@ -93,8 +90,6 @@ public function getTimesheetActions(Timesheet $timesheet, string $view, string $
#[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);
Expand All @@ -113,8 +108,6 @@ public function getActivityActions(Activity $activity, string $view, string $loc
#[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);
Expand All @@ -133,8 +126,6 @@ public function getProjectActions(Project $project, string $view, string $locale
#[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);
Expand Down
17 changes: 0 additions & 17 deletions src/API/ActivityController.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
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;
Expand Down Expand Up @@ -56,8 +55,6 @@ public function __construct(
*/
#[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')]
Expand Down Expand Up @@ -126,8 +123,6 @@ public function cgetAction(ParamFetcherInterface $paramFetcher, ProjectRepositor
#[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
{
Expand All @@ -143,8 +138,6 @@ public function getAction(Activity $activity): Response
#[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')) {
Expand Down Expand Up @@ -186,8 +179,6 @@ public function postAction(Request $request): Response
#[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);
Expand Down Expand Up @@ -223,8 +214,6 @@ public function patchAction(Request $request, Activity $activity): Response
#[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
Expand Down Expand Up @@ -256,8 +245,6 @@ public function metaAction(Activity $activity, ParamFetcherInterface $paramFetch
#[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);
Expand All @@ -275,8 +262,6 @@ public function getRatesAction(Activity $activity): Response
#[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
{
Expand All @@ -299,8 +284,6 @@ public function deleteRateAction(Activity $activity, #[MapEntity(mapping: ['rate
#[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();
Expand Down
46 changes: 46 additions & 0 deletions src/API/Authentication/AccessTokenHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace App\API\Authentication;

use App\Repository\AccessTokenRepository;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;

final class AccessTokenHandler implements AccessTokenHandlerInterface
{
public function __construct(
private readonly AccessTokenRepository $accessTokenRepository
)
{
}

public function getUserBadgeFrom(string $accessToken): UserBadge
{
$accessToken = $this->accessTokenRepository->findByToken($accessToken);

if (null === $accessToken) {
throw new BadCredentialsException('Invalid credentials.');
}

if (!$accessToken->isValid()) {
throw new BadCredentialsException('Invalid token.');
}

$now = new \DateTimeImmutable();
// record last usage only if this is the first time OR once every minute
if ($accessToken->getLastUsage() === null || $now->getTimestamp() > $accessToken->getLastUsage()->getTimestamp() + 60) {
$accessToken->setLastUsage($now);
$this->accessTokenRepository->saveAccessToken($accessToken);
}

return new UserBadge($accessToken->getUser()->getUserIdentifier(), fn (string $userIdentifier) => $accessToken->getUser());
}
}
23 changes: 18 additions & 5 deletions src/API/Authentication/ApiRequestMatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
69 changes: 0 additions & 69 deletions src/API/Authentication/SessionAuthenticator.php

This file was deleted.

0 comments on commit afe0656

Please sign in to comment.