Added basic API endpoints (#258)

This commit is contained in:
Kevin Papst
2018-08-08 23:10:45 +02:00
committed by GitHub
parent a7780dac9e
commit 2f84f3751a
59 changed files with 3207 additions and 297 deletions

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/*
* 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;
use App\Entity\Activity;
use App\Repository\ActivityRepository;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation as API;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
/**
* @RouteResource("Activity")
*
* @Security("is_granted('ROLE_USER')")
*/
class ActivityController extends Controller
{
/**
* @var ActivityRepository
*/
protected $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
/**
* @param ViewHandlerInterface $viewHandler
* @param ActivityRepository $repository
*/
public function __construct(ViewHandlerInterface $viewHandler, ActivityRepository $repository)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
}
/**
* @SWG\Response(
* response=200,
* description="Returns the collection of all existing activities",
* @SWG\Schema(ref=@API\Model(type=Activity::class)),
* )
*
* @return Response
*/
public function cgetAction()
{
$data = $this->repository->findAll();
$view = new View($data, 200);
return $this->viewHandler->handle($view);
}
/**
* @SWG\Response(
* response=200,
* description="Returns one activity entity",
* @SWG\Schema(ref=@API\Model(type=Activity::class)),
* )
*
* @param int $id
* @return Response
*/
public function getAction($id)
{
$data = $this->repository->find($id);
if (null === $data) {
throw new NotFoundException();
}
$view = new View($data, 200);
return $this->viewHandler->handle($view);
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/*
* 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;
use App\Entity\Customer;
use App\Repository\CustomerRepository;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
/**
* @RouteResource("Customer")
*
* @Security("is_granted('ROLE_USER')")
*/
class CustomerController extends Controller
{
/**
* @var CustomerRepository
*/
protected $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
/**
* @param ViewHandlerInterface $viewHandler
* @param CustomerRepository $repository
*/
public function __construct(ViewHandlerInterface $viewHandler, CustomerRepository $repository)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
}
/**
* @SWG\Response(
* response=200,
* description="Returns the collection of all existing customer",
* @SWG\Schema(ref=@Model(type=Customer::class)),
* )
*
* @return Response
*/
public function cgetAction()
{
$data = $this->repository->findAll();
$view = new View($data, 200);
return $this->viewHandler->handle($view);
}
/**
* @SWG\Response(
* response=200,
* description="Returns one customer entity",
* @SWG\Schema(ref=@Model(type=Customer::class)),
* )
*
* @param int $id
* @return Response
*/
public function getAction($id)
{
$data = $this->repository->find($id);
if (null === $data) {
throw new NotFoundException();
}
$view = new View($data, 200);
return $this->viewHandler->handle($view);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/*
* 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;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Swagger\Annotations as SWG;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
class HealthcheckController extends Controller
{
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
/**
* @param ViewHandlerInterface $viewHandler
*/
public function __construct(ViewHandlerInterface $viewHandler)
{
$this->viewHandler = $viewHandler;
}
/**
* @SWG\Response(
* response=200,
* description="A simple route that returns a 'pong', which you can use for testing the API",
* )
*
* @Rest\Get(path="/ping")
*/
public function pingAction()
{
$view = new View(['message' => 'pong'], 200);
return $this->viewHandler->handle($view);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/*
* 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;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class NotFoundException extends NotFoundHttpException
{
public function __construct(string $message = 'Not found', \Exception $previous = null, int $code = 0, array $headers = [])
{
parent::__construct($message, $previous, $code, $headers);
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/*
* 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;
use App\Entity\Project;
use App\Repository\ProjectRepository;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
/**
* @RouteResource("Project")
*
* @Security("is_granted('ROLE_USER')")
*/
class ProjectController extends Controller
{
/**
* @var ProjectRepository
*/
protected $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
/**
* @param ViewHandlerInterface $viewHandler
* @param ProjectRepository $repository
*/
public function __construct(ViewHandlerInterface $viewHandler, ProjectRepository $repository)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
}
/**
* @SWG\Response(
* response=200,
* description="Returns the collection of all existing projects",
* @SWG\Schema(ref=@Model(type=Project::class)),
* )
*
* @return Response
*/
public function cgetAction()
{
$data = $this->repository->findAll();
$view = new View($data, 200);
return $this->viewHandler->handle($view);
}
/**
* @SWG\Response(
* response=200,
* description="Returns one project entity",
* @SWG\Schema(ref=@Model(type=Project::class)),
* )
*
* @param int $id
* @return Response
*/
public function getAction($id)
{
$data = $this->repository->find($id);
if (null === $data) {
throw new NotFoundException();
}
$view = new View($data, 200);
return $this->viewHandler->handle($view);
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
/*
* 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;
use App\Entity\User;
use App\Repository\UserRepository;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
/**
* @RouteResource("User")
*
* @Security("is_granted('ROLE_SUPER_ADMIN')")
* @Security("is_granted('IS_AUTHENTICATED_FULLY')")
*/
class UserController extends Controller
{
/**
* @var UserRepository
*/
protected $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
/**
* @param ViewHandlerInterface $viewHandler
* @param UserRepository $repository
*/
public function __construct(ViewHandlerInterface $viewHandler, UserRepository $repository)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
}
/**
* @SWG\Response(
* response=200,
* description="Returns the collection of all registered users",
* @SWG\Schema(ref=@Model(type=User::class)),
* )
*
* @return Response
*/
public function cgetAction()
{
$data = $this->repository->findAll();
$view = new View($data, 200);
return $this->viewHandler->handle($view);
}
/**
* @SWG\Response(
* response=200,
* description="Return one user entity",
* @SWG\Schema(ref=@Model(type=User::class)),
* )
*
* @param int $id
* @return Response
*/
public function getAction($id)
{
$data = $this->repository->find($id);
if (null === $data) {
throw new NotFoundException();
}
$view = new View($data, 200);
return $this->viewHandler->handle($view);
}
}

View File

@@ -0,0 +1,268 @@
<?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\Calendar;
use App\Entity\Timesheet;
class TimesheetEntity
{
/**
* @var int
*/
protected $id;
/**
* @var \DateTime
*/
protected $start;
/**
* @var \DateTime|null
*/
protected $end;
/**
* @var string
*/
protected $title;
/**
* @var string
*/
protected $description;
/**
* @var string
*/
protected $customer;
/**
* @var string
*/
protected $project;
/**
* @var string
*/
protected $activity;
/**
* @var string|null
*/
protected $borderColor;
/**
* @var string|null
*/
protected $backgroundColor;
/**
* @param Timesheet $entry
*/
public function __construct(Timesheet $entry)
{
$this->id = $entry->getId();
$this->start = $entry->getBegin();
$this->title = $entry->getActivity()->getName();
$this->description = $entry->getDescription();
$this->customer = $entry->getActivity()->getProject()->getCustomer()->getName();
$this->project = $entry->getActivity()->getProject()->getName();
$this->activity = $entry->getActivity()->getName();
if (null === $entry->getEnd()) {
// TODO move these colors to the controller
$this->borderColor = '#f39c12';
$this->backgroundColor = '#f39c12';
} else {
$this->end = $entry->getEnd();
}
}
/**
* @return int
*/
public function getId(): int
{
return $this->id;
}
/**
* @param int $id
* @return TimesheetEntity
*/
public function setId(int $id)
{
$this->id = $id;
return $this;
}
/**
* @return \DateTime
*/
public function getStart(): \DateTime
{
return $this->start;
}
/**
* @param \DateTime $start
* @return TimesheetEntity
*/
public function setStart(\DateTime $start)
{
$this->start = $start;
return $this;
}
/**
* @return \DateTime|null
*/
public function getEnd(): ?\DateTime
{
return $this->end;
}
/**
* @param \DateTime|null $end
* @return TimesheetEntity
*/
public function setEnd(?\DateTime $end)
{
$this->end = $end;
return $this;
}
/**
* @return string
*/
public function getTitle(): string
{
return $this->title;
}
/**
* @param string $title
* @return TimesheetEntity
*/
public function setTitle(string $title)
{
$this->title = $title;
return $this;
}
/**
* @return string
*/
public function getDescription(): string
{
return $this->description;
}
/**
* @param string $description
* @return TimesheetEntity
*/
public function setDescription(string $description)
{
$this->description = $description;
return $this;
}
/**
* @return string
*/
public function getCustomer(): string
{
return $this->customer;
}
/**
* @param string $customer
* @return TimesheetEntity
*/
public function setCustomer(string $customer)
{
$this->customer = $customer;
return $this;
}
/**
* @return string
*/
public function getProject(): string
{
return $this->project;
}
/**
* @param string $project
* @return TimesheetEntity
*/
public function setProject(string $project)
{
$this->project = $project;
return $this;
}
/**
* @return string
*/
public function getActivity(): string
{
return $this->activity;
}
/**
* @param string $activity
* @return TimesheetEntity
*/
public function setActivity(string $activity)
{
$this->activity = $activity;
return $this;
}
/**
* @return null|string
*/
public function getBorderColor(): ?string
{
return $this->borderColor;
}
/**
* @param null|string $borderColor
* @return TimesheetEntity
*/
public function setBorderColor(?string $borderColor)
{
$this->borderColor = $borderColor;
return $this;
}
/**
* @return null|string
*/
public function getBackgroundColor(): ?string
{
return $this->backgroundColor;
}
/**
* @param null|string $backgroundColor
* @return TimesheetEntity
*/
public function setBackgroundColor(?string $backgroundColor)
{
$this->backgroundColor = $backgroundColor;
return $this;
}
}

View File

@@ -10,6 +10,7 @@
namespace App\Controller;
use App\Calendar\Service;
use App\Calendar\TimesheetEntity;
use App\Entity\Timesheet;
use App\Repository\Query\TimesheetQuery;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
@@ -96,35 +97,9 @@ class CalendarController extends AbstractController
$result = [];
foreach ($entries as $entry) {
$result[] = $this->getTimesheetEntryForCalendar($entry);
$result[] = new TimesheetEntity($entry);
}
return $this->json($result);
}
/**
* @param Timesheet $entry
* @return array
*/
protected function getTimesheetEntryForCalendar(Timesheet $entry)
{
$result = [
'id' => $entry->getId(),
'start' => $entry->getBegin(),
'title' => $entry->getActivity()->getName(),
'description' => $entry->getDescription(),
'customer' => $entry->getActivity()->getProject()->getCustomer()->getName(),
'project' => $entry->getActivity()->getProject()->getName(),
'activity' => $entry->getActivity()->getName(),
];
if (null === $entry->getEnd()) {
$result['borderColor'] = '#f39c12';
$result['backgroundColor'] = '#f39c12';
} else {
$result['end'] = $entry->getEnd() ?? new \DateTime();
}
return $result;
}
}

View File

@@ -11,6 +11,7 @@ namespace App\Controller;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Form\UserApiTokenType;
use App\Form\UserEditType;
use App\Form\UserPasswordType;
use App\Form\UserPreferencesForm;
@@ -104,6 +105,32 @@ class ProfileController extends AbstractController
return $this->getProfileView($profile, 'password', null, $form);
}
/**
* @Route("/{username}/api-token", name="user_profile_api_token")
* @Method({"GET", "POST"})
* @Security("is_granted('api-token', profile)")
*/
public function apiTokenAction(User $profile, Request $request)
{
$form = $this->createApiTokenForm($profile);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$password = $this->encoder->encodePassword($profile, $profile->getPlainApiToken());
$profile->setApiToken($password);
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($profile);
$entityManager->flush();
$this->flashSuccess('action.updated_successfully');
return $this->redirectToRoute('user_profile', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, 'api-token', null, null, null, null, $form);
}
/**
* @Route("/{username}/roles", name="user_profile_roles")
* @Method({"GET", "POST"})
@@ -179,6 +206,7 @@ class ProfileController extends AbstractController
* @param Form|null $pwdForm
* @param Form|null $rolesForm
* @param Form|null $prefsForm
* @param Form|null $apiTokenForm
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Doctrine\ORM\NonUniqueResultException
*/
@@ -188,7 +216,8 @@ class ProfileController extends AbstractController
Form $editForm = null,
Form $pwdForm = null,
Form $rolesForm = null,
Form $prefsForm = null
Form $prefsForm = null,
Form $apiTokenForm = null
) {
/* @var $timesheetRepo TimesheetRepository */
$timesheetRepo = $this->getDoctrine()->getRepository(Timesheet::class);
@@ -211,6 +240,10 @@ class ProfileController extends AbstractController
$pwdForm = $pwdForm ?: $this->createPasswordForm($user);
$viewVars['forms']['password'] = $pwdForm->createView();
}
if ($this->isGranted(UserVoter::API_TOKEN, $user)) {
$apiTokenForm = $apiTokenForm ?: $this->createApiTokenForm($user);
$viewVars['forms']['api-token'] = $apiTokenForm->createView();
}
if ($this->isGranted(UserVoter::ROLES, $user)) {
$rolesForm = $rolesForm ?: $this->createRolesForm($user);
$viewVars['forms']['roles'] = $rolesForm->createView();
@@ -287,4 +320,21 @@ class ProfileController extends AbstractController
]
);
}
/**
* @param User $user
* @return \Symfony\Component\Form\FormInterface
*/
private function createApiTokenForm(User $user)
{
return $this->createForm(
UserApiTokenType::class,
$user,
[
'validation_groups' => ['apiTokenUpdate'],
'action' => $this->generateUrl('user_profile_api_token', ['username' => $user->getUsername()]),
'method' => 'POST'
]
);
}
}

View File

@@ -26,6 +26,7 @@ use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
class UserFixtures extends Fixture
{
public const DEFAULT_PASSWORD = 'kitten';
public const DEFAULT_API_TOKEN = 'api_kitten';
public const DEFAULT_AVATAR = 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y';
public const USERNAME_USER = 'john_user';
@@ -84,6 +85,7 @@ class UserFixtures extends Fixture
->setAvatar($userData[5])
->setEnabled($userData[6])
->setPassword($passwordEncoder->encodePassword($user, self::DEFAULT_PASSWORD))
->setApiToken($passwordEncoder->encodePassword($user, self::DEFAULT_API_TOKEN))
->setPreferences([$this->getUserPreference($user)])
;
@@ -109,7 +111,7 @@ class UserFixtures extends Fixture
}
/**
* Generate randomized test users
* Generate randomized test users, which don't have API access.
*
* @param ObjectManager $manager
*/

View File

@@ -11,6 +11,7 @@ namespace App\Doctrine;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\LoadClassMetadataEventArgs;
use Doctrine\ORM\Events;
/**
* Adds a prefix to every doctrine entity AKA database table
@@ -19,16 +20,27 @@ class TablePrefixSubscriber implements EventSubscriber
{
protected $prefix = '';
/**
* @param string $prefix
*/
public function __construct($prefix)
{
$this->prefix = (string) $prefix;
}
/**
* @return array|string[]
*/
public function getSubscribedEvents()
{
return ['loadClassMetadata'];
return [
Events::loadClassMetadata,
];
}
/**
* @param LoadClassMetadataEventArgs $args
*/
public function loadClassMetadata(LoadClassMetadataEventArgs $args)
{
$classMetadata = $args->getClassMetadata();

View File

@@ -78,6 +78,18 @@ class User extends BaseUser implements UserInterface
*/
private $avatar;
/**
* @var string
*
* @ORM\Column(name="api_token", type="string", length=255, nullable=true)
*/
protected $apiToken;
/**
* @var string
*/
protected $plainApiToken;
/**
* @var UserPreference[]|Collection
*
@@ -179,6 +191,44 @@ class User extends BaseUser implements UserInterface
return $this;
}
/**
* @return string
*/
public function getApiToken()
{
return $this->apiToken;
}
/**
* @param string $apiToken
* @return User
*/
public function setApiToken($apiToken)
{
$this->apiToken = $apiToken;
return $this;
}
/**
* @return string
*/
public function getPlainApiToken(): ?string
{
return $this->plainApiToken;
}
/**
* @param string $plainApiToken
* @return User
*/
public function setPlainApiToken(string $plainApiToken)
{
$this->plainApiToken = $plainApiToken;
return $this;
}
/**
* @return UserPreference[]|Collection
*/

View File

@@ -0,0 +1,50 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Defines the form used to set the users API token.
*/
class UserApiTokenType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('plainApiToken', RepeatedType::class, [
'type' => PasswordType::class,
'first_options' => ['label' => 'label.api_token'],
'second_options' => ['label' => 'label.api_token_repeat'],
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => User::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'edit_user_api_token',
]);
}
}

View File

@@ -17,7 +17,7 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Defines the form used to create and manipulate Users.
* Defines the form used to set the users password.
*/
class UserPasswordType extends AbstractType
{

View File

@@ -16,7 +16,7 @@ use Doctrine\DBAL\Schema\Schema;
/**
* Initial database structure of Kimai 2.
* This file is only required for testing the migrations!
* This file is mainly required for testing the migrations.
*/
final class Version20180701120000 extends AbstractMigration
{

View File

@@ -16,7 +16,9 @@ use Doctrine\DBAL\Schema\Index;
use Doctrine\DBAL\Schema\Schema;
/**
* Add constraints for the "delete user" feature.
* Migrations fot the "delete user" feature.
*
* Adds constraints to the timesheet table, so all timesheet entries. will be deleted when a user is deleted.
*/
final class Version20180730044139 extends AbstractMigration
{

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/*
* 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;
/**
* Added "API-token" to users table.
*/
final class Version20180805183527 extends AbstractMigration
{
/**
* @param Schema $schema
* @throws \Doctrine\DBAL\DBALException
* @throws \Doctrine\DBAL\Migrations\AbortMigrationException
*/
public function up(Schema $schema): void
{
$platform = $this->getPlatform();
if (!in_array($platform, ['sqlite', 'mysql'])) {
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
}
$user = $this->getTableName('users');
if ($platform === 'sqlite') {
$this->addSql('ALTER TABLE ' . $user . ' ADD COLUMN api_token VARCHAR(255) DEFAULT NULL');
} else {
$this->addSql('ALTER TABLE ' . $user . ' ADD api_token VARCHAR(255) DEFAULT NULL');
}
}
/**
* @param Schema $schema
* @throws \Doctrine\DBAL\DBALException
* @throws \Doctrine\DBAL\Migrations\AbortMigrationException
*/
public function down(Schema $schema): void
{
$platform = $this->getPlatform();
if (!in_array($platform, ['sqlite', 'mysql'])) {
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
}
$user = $this->getTableName('user');
if ($platform === 'sqlite') {
$this->addSql('DROP INDEX UNIQ_B9AC5BCE92FC23A8');
$this->addSql('DROP INDEX UNIQ_B9AC5BCEA0D96FBF');
$this->addSql('DROP INDEX UNIQ_B9AC5BCEC05FB297');
$this->addSql('DROP INDEX UNIQ_B9AC5BCEF85E0677');
$this->addSql('DROP INDEX UNIQ_B9AC5BCEE7927C74');
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $user . ' AS SELECT id, username, username_canonical, email, email_canonical, enabled, salt, password, last_login, confirmation_token, password_requested_at, roles, alias, registration_date, title, avatar FROM ' . $user);
$this->addSql('DROP TABLE ' . $user);
$this->addSql('CREATE TABLE ' . $user . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, username VARCHAR(180) NOT NULL, username_canonical VARCHAR(180) NOT NULL, email VARCHAR(180) NOT NULL, email_canonical VARCHAR(180) NOT NULL, enabled BOOLEAN NOT NULL, salt VARCHAR(255) DEFAULT NULL, password VARCHAR(255) NOT NULL, last_login DATETIME DEFAULT NULL, confirmation_token VARCHAR(180) DEFAULT NULL, password_requested_at DATETIME DEFAULT NULL, roles CLOB NOT NULL, alias VARCHAR(60) DEFAULT NULL, registration_date DATETIME DEFAULT NULL, title VARCHAR(50) DEFAULT NULL, avatar VARCHAR(255) DEFAULT NULL)');
$this->addSql('INSERT INTO ' . $user . ' (id, username, username_canonical, email, email_canonical, enabled, salt, password, last_login, confirmation_token, password_requested_at, roles, alias, registration_date, title, avatar) SELECT id, username, username_canonical, email, email_canonical, enabled, salt, password, last_login, confirmation_token, password_requested_at, roles, alias, registration_date, title, avatar FROM __temp__' . $user);
$this->addSql('DROP TABLE __temp__' . $user);
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCE92FC23A8 ON ' . $user . ' (username_canonical)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCEA0D96FBF ON ' . $user . ' (email_canonical)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCEC05FB297 ON ' . $user . ' (confirmation_token)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCEF85E0677 ON ' . $user . ' (username)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCEE7927C74 ON ' . $user . ' (email)');
} else {
$this->addSql('ALTER TABLE ' . $user . ' DROP api_token');
}
}
}

View File

@@ -0,0 +1,164 @@
<?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\Security;
use App\Entity\User;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;
class TokenAuthenticator extends AbstractGuardAuthenticator
{
public const HEADER_USERNAME = 'X-AUTH-USER';
public const HEADER_TOKEN = 'X-AUTH-TOKEN';
public const HEADER_JAVASCRIPT = 'X-AUTH-SESSION';
/**
* @var EncoderFactoryInterface
*/
protected $encoderFactory;
/**
* @param EncoderFactoryInterface $encoderFactory
*/
public function __construct(EncoderFactoryInterface $encoderFactory)
{
$this->encoderFactory = $encoderFactory;
}
/**
* @param Request $request
* @return bool
*/
public function supports(Request $request)
{
if (strpos($request->getRequestUri(), '/api/doc') === 0) {
return false;
}
if (strpos($request->getRequestUri(), '/api/') === 0) {
// javascript requests can set a header to disable this authenticator and use the existing session
return !$request->headers->has(self::HEADER_JAVASCRIPT);
}
return false;
}
/**
* @param Request $request
* @return array|bool
*/
public function getCredentials(Request $request)
{
return [
'user' => $request->headers->get(self::HEADER_USERNAME),
'token' => $request->headers->get(self::HEADER_TOKEN),
];
}
/**
* @param array $credentials
* @param UserProviderInterface $userProvider
* @return null|UserInterface
*/
public function getUser($credentials, UserProviderInterface $userProvider)
{
$token = $credentials['token'] ?? null;
$user = $credentials['user'] ?? null;
if (empty($token) || empty($user)) {
return null;
}
return $userProvider->loadUserByUsername($user);
}
/**
* @param array $credentials
* @param UserInterface $user
* @return bool
*/
public function checkCredentials($credentials, UserInterface $user)
{
$token = $credentials['token'];
if (!empty($token) && $user instanceof User && !empty($user->getApiToken())) {
$encoder = $this->encoderFactory->getEncoder($user);
return $encoder->isPasswordValid($user->getApiToken(), $token, $user->getSalt());
}
return false;
}
/**
* @param Request $request
* @param TokenInterface $token
* @param string $providerKey
* @return null|Response
*/
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
return null;
}
/**
* @param Request $request
* @param AuthenticationException $exception
* @return null|JsonResponse|Response
*/
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
if (!$request->headers->has(self::HEADER_USERNAME) || !$request->headers->has(self::HEADER_TOKEN)) {
return new JsonResponse(
['message' => 'Authentication required, missing headers: ' . self::HEADER_USERNAME . ', ' . self::HEADER_TOKEN],
Response::HTTP_FORBIDDEN
);
}
$data = [
'message' => 'Invalid credentials'
// security measure: do not leak real reason (unknown user, invalid credentials ...)
// you can uncomment this for debugging
// 'message' => strtr($exception->getMessageKey(), $exception->getMessageData())
];
return new JsonResponse($data, Response::HTTP_FORBIDDEN);
}
/**
* @param Request $request
* @param AuthenticationException|null $authException
* @return JsonResponse|Response
*/
public function start(Request $request, AuthenticationException $authException = null)
{
$data = [
'message' => 'Authentication required, missing headers: ' . self::HEADER_USERNAME . ', ' . self::HEADER_TOKEN
];
return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
}
/**
* @return bool
*/
public function supportsRememberMe()
{
return false;
}
}

View File

@@ -24,6 +24,7 @@ class UserVoter extends AbstractVoter
public const PASSWORD = 'password';
public const ROLES = 'roles';
public const PREFERENCES = 'preferences';
public const API_TOKEN = 'api-token';
public const ALLOWED_ATTRIBUTES = [
self::VIEW,
@@ -32,7 +33,8 @@ class UserVoter extends AbstractVoter
self::ROLES,
self::PASSWORD,
self::DELETE,
self::PREFERENCES
self::PREFERENCES,
self::API_TOKEN,
];
/**
@@ -71,6 +73,7 @@ class UserVoter extends AbstractVoter
case self::VIEW:
return $this->canView($subject, $user, $token);
case self::EDIT:
case self::API_TOKEN:
case self::PASSWORD:
return $this->canEdit($subject, $user, $token);
case self::DELETE: