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

1
.gitignore vendored
View File

@@ -31,6 +31,7 @@
###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.result.cache
###< phpunit/phpunit ###
###> friendsofphp/php-cs-fixer ###

View File

@@ -10,8 +10,11 @@
"ext-pdo_sqlite": "*",
"beberlei/DoctrineExtensions": "^1.0",
"erusev/parsedown": "^1.6",
"friendsofsymfony/rest-bundle": "^2.3",
"friendsofsymfony/user-bundle": "~2.0",
"jms/serializer-bundle": "^2.4",
"kevinpapst/adminlte-bundle": "^1.2",
"nelmio/api-doc-bundle": "^3.2",
"ocramius/proxy-manager": "2.1.1",
"sensio/framework-extra-bundle": "^5.1",
"symfony/asset": "^4.0",
@@ -92,7 +95,7 @@
"extra": {
"symfony": {
"id": "01C3FWRDJJEX9K6Y3A4XDFXPBR",
"allow-contrib": false
"allow-contrib": true
}
},
"repositories": [

1211
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,4 +19,7 @@ return [
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
KevinPapst\AdminLTEBundle\AdminLTEBundle::class => ['all' => true],
FOS\UserBundle\FOSUserBundle::class => ['all' => true],
FOS\RestBundle\FOSRestBundle::class => ['all' => true],
Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true],
JMS\SerializerBundle\JMSSerializerBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,7 @@
jms_serializer:
visitors:
json:
options:
- JSON_PRETTY_PRINT
- JSON_UNESCAPED_SLASHES
- JSON_PRESERVE_ZERO_FRACTION

View File

@@ -0,0 +1,107 @@
fos_rest:
# disable_csrf_role: ROLE_API
# access_denied_listener:
# enabled: false
# service: null
# formats:
# name: ~
# unauthorized_challenge: null
# param_fetcher_listener:
# enabled: false
# force: false
# service: null
# cache_dir: '%kernel.cache_dir%/fos_rest'
# allowed_methods_listener:
# enabled: false
# service: null
routing_loader:
default_format: json
include_format: true
# prefix_methods: true
# body_converter:
# enabled: false
# validate: false
# validation_errors_argument: validationErrors
# service:
# router: router
# templating: templating
# serializer: null
# view_handler: fos_rest.view_handler.default
# inflector: fos_rest.inflector.doctrine
# validator: validator
# serializer:
# version: null
# groups: []
# serialize_null: false
view:
default_engine: twig
# force_redirects:
# name: ~
# mime_types:
# enabled: false
# service: null
# formats:
# name: ~
# formats:
# name: ~
# templating_formats:
# name: ~
# view_response_listener:
# enabled: true
# force: true
# service: null
failed_validation: 400
# empty_content: 204
# serialize_null: false
# jsonp_handler:
# callback_param: callback
# mime_type: application/javascript+jsonp
exception:
enabled: true
exception_controller: 'fos_rest.exception.controller:showAction'
# service: null
codes:
# 'Symfony\Component\Routing\Exception\ResourceNotFoundException': 404
# 'Doctrine\ORM\OptimisticLockException': HTTP_CONFLICT
# 'Symfony\Component\HttpKernel\Exception\NotFoundHttpException': 400
messages:
'Symfony\Component\Routing\Exception\ResourceNotFoundException': true
'Symfony\Component\HttpKernel\Exception\NotFoundHttpException': true
# body_listener:
# enabled: true
# service: null
# default_format: null
# throw_exception_on_unsupported_content_type: false
# decoders:
# name: ~
# array_normalizer:
# service: null
# forms: false
format_listener:
enabled: true
# service: null
rules:
- { path: ^/api, prefer_extension: true, fallback_format: json, priorities: [ json, xml ] }
# host: null # URL host name
# methods: null # Method for URL
# stop: false
# attributes: []
# versioning:
# enabled: false
# default_version: ~
# resolvers:
# query:
# enabled: true
# parameter_name: version
# custom_header:
# enabled: true
# header_name: X-Accept-Version
# media_type:
# enabled: true
# regex: /(v|version)=(?P<version>[0-9\.]+)/
# guessing_order:
# - query
# - custom_header
# - media_type
zone:
- { path: ^/api/* }

View File

@@ -0,0 +1,12 @@
jms_serializer:
visitors:
xml:
format_output: '%kernel.debug%'
metadata:
directories:
FOSUB:
namespace_prefix: "FOS\\UserBundle"
path: "%kernel.root_dir%/../config/serializer/FOS/UserBundle"
App:
namespace_prefix: "App"
path: "%kernel.root_dir%/../config/serializer/App"

View File

@@ -0,0 +1,27 @@
nelmio_api_doc:
models:
use_jms: true
areas:
path_patterns:
- ^/api(?!/doc)
documentation:
#host: '{{hostname}}'
schemes: [http, https]
info:
title: Kimai 2 - API Docs
description: REST API description for the Kimai 2 time-tracking software
version: 0.1
securityDefinitions:
X-AUTH-USER:
type: apiKey
description: 'Value: {Username}'
name: X-AUTH-USER
in: header
X-AUTH-TOKEN:
type: apiKey
description: 'Value: {API Token}'
name: X-AUTH-TOKEN
in: header
security:
- X-AUTH-USER: []
- X-AUTH-TOKEN: []

View File

@@ -0,0 +1,6 @@
jms_serializer:
visitors:
json:
options:
- JSON_UNESCAPED_SLASHES
- JSON_PRESERVE_ZERO_FRACTION

View File

@@ -2,7 +2,6 @@ security:
encoders:
App\Entity\User: bcrypt
# https://symfony.com/doc/current/book/security.html#where-do-users-come-from-user-providers
providers:
fos_userbundle:
id: fos_user.user_provider.username_email
@@ -13,39 +12,28 @@ security:
security: false
secured_area:
# this firewall applies to all URLs
pattern: ^/
# make sure only allowed users have access to their time-tracking
user_checker: App\Security\UserChecker
anonymous: true
stateless: false
# but the firewall does not require login on every page
# denying access is done in access_control or in your controllers
anonymous: ~
guard:
authenticators:
- App\Security\TokenAuthenticator
remember_me:
secret: '%kernel.secret%'
lifetime: 604800
path: /
# This allows the user to login by submitting a username and password
# Reference: http://symfony.com/doc/current/cookbook/security/form_login_setup.html
form_login:
# fos user bundle handles the form login
provider: fos_userbundle
# The route name that the login form submits to
check_path: fos_user_security_check
# The name of the route where the login form lives
# When the user tries to access a protected page, they are redirected here
login_path: fos_user_security_login
# Secure the login form against CSRF
# Reference: http://symfony.com/doc/current/cookbook/security/csrf_in_login_form.html
csrf_token_generator: security.csrf.token_manager
logout:
# The route name the user can go to in order to logout
path: fos_user_security_logout
# The name of the route to redirect to after logging out
target: homepage
role_hierarchy:
@@ -60,4 +48,4 @@ security:
- { path: '^/(%app_locales%)/register', role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: '^/(%app_locales%)/resetting', role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: '^/(%app_locales%)/admin', roles: ROLE_ADMIN }
- { path: '^/api', roles: IS_AUTHENTICATED_REMEMBERED }

View File

@@ -11,15 +11,3 @@ homepage:
_controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::redirectAction
route: timesheet
permanent: true
fos_user_security:
prefix: /{_locale}
resource: "@FOSUserBundle/Resources/config/routing/security.xml"
fos_user_registration:
prefix: /{_locale}/register
resource: "@FOSUserBundle/Resources/config/routing/registration.xml"
fos_user_resetting:
prefix: /{_locale}/resetting
resource: "@FOSUserBundle/Resources/config/routing/resetting.xml"

View File

@@ -5,4 +5,9 @@ controllers:
requirements:
_locale: '%app_locales%'
defaults:
_locale: '%locale%'
_locale: '%locale%'
app.api:
resource: '../src/API/'
type: rest
prefix: /api

View File

@@ -0,0 +1,14 @@
# Expose security related features like login and logout
fos_user_security:
prefix: /{_locale}
resource: "@FOSUserBundle/Resources/config/routing/security.xml"
# Expose the user registration feature
fos_user_registration:
prefix: /{_locale}/register
resource: "@FOSUserBundle/Resources/config/routing/registration.xml"
# Expose the users password-reset feature
fos_user_resetting:
prefix: /{_locale}/resetting
resource: "@FOSUserBundle/Resources/config/routing/resetting.xml"

View File

@@ -0,0 +1,11 @@
# Expose the Kimai API documentation as HTML
app.swagger_ui:
path: /api/doc
methods: GET
defaults: { _controller: nelmio_api_doc.controller.swagger_ui }
# Expose the Kimai API documentation as swagger compliant JSON
app.swagger:
path: /api/doc.json
methods: GET
defaults: { _controller: nelmio_api_doc.controller.swagger }

View File

@@ -0,0 +1,18 @@
App\Entity\Activity:
exclusion_policy: All
custom_accessor_order: [id, name, comment, visible, project_id]
properties:
id:
include: true
name:
include: true
comment:
include: true
visible:
include: true
virtual_properties:
getProjectId:
name: project_id
serialized_name: project_id
exp: "object.getProject() === null ? null : object.getProject().getId()"
type: integer

View File

@@ -0,0 +1,35 @@
App\Entity\Customer:
exclusion_policy: All
properties:
id:
include: true
name:
include: true
number:
include: true
comment:
include: true
visible:
include: true
company:
include: true
contact:
include: true
address:
include: true
country:
include: true
currency:
include: true
phone:
include: true
fax:
include: true
mobile:
include: true
mail:
include: true
homepage:
include: true
timezone:
include: true

View File

@@ -0,0 +1,22 @@
App\Entity\Project:
exclusion_policy: All
custom_accessor_order: [id, name, comment, visible, budget, orderNumber, customer_id]
properties:
id:
include: true
name:
include: true
comment:
include: true
visible:
include: true
budget:
include: true
orderNumber:
include: true
virtual_properties:
getCustomerId:
name: customer_id
serialized_name: customer_id
exp: "object.getCustomer() === null ? null : object.getCustomer().getId()"
type: integer

View File

@@ -0,0 +1,11 @@
App\Entity\User:
exclusion_policy: All
properties:
id:
include: true
alias:
include: true
title:
include: true
avatar:
include: true

View File

@@ -0,0 +1,23 @@
FOS\UserBundle\Model\User:
exclusion_policy: NONE
properties:
email:
exclude: true
emailCanonical:
exclude: true
usernameCanonical:
exclude: true
password:
exclude: true
plainPassword:
exclude: true
registeredAt:
exclude: true
lastLogin:
exclude: true
confirmationToken:
exclude: true
passwordRequestedAt:
exclude: true
salt:
exclude: true

View File

@@ -104,3 +104,27 @@ services:
App\Timesheet\Calculator\RateCalculator:
arguments: ["%kimai.timesheet.rates%"]
# ================================================================================
# REPOSITORIES
# ================================================================================
App\Repository\UserRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\User']
App\Repository\ActivityRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Activity']
App\Repository\ProjectRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Project']
App\Repository\CustomerRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Customer']

View File

@@ -2,8 +2,4 @@ services:
_defaults:
public: true
# If you need to access services in a test, create an alias
# and then fetch that alias from the container. As a convention,
# aliases are prefixed with test. For example:
#
# test.App\Service\MyService: '@App\Service\MyService'
test.PasswordEncoder: "@security.encoder_factory"

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:

View File

@@ -110,6 +110,9 @@
"erusev/parsedown": {
"version": "1.6.4"
},
"exsyst/swagger": {
"version": "v0.4.1"
},
"friendsofphp/php-cs-fixer": {
"version": "2.2",
"recipe": {
@@ -119,6 +122,15 @@
"ref": "bb31a3bbec00a8fc8aa1c9fbf9b0ef9fc492f93d"
}
},
"friendsofsymfony/rest-bundle": {
"version": "2.2",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "master",
"version": "2.2",
"ref": "258300d52be6ad59b32a888d5ddafbf9638540ff"
}
},
"friendsofsymfony/user-bundle": {
"version": "v2.1.2"
},
@@ -128,6 +140,24 @@
"jdorn/sql-formatter": {
"version": "v1.2.17"
},
"jms/metadata": {
"version": "1.6.0"
},
"jms/parser-lib": {
"version": "1.0.0"
},
"jms/serializer": {
"version": "1.13.0"
},
"jms/serializer-bundle": {
"version": "2.0",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "master",
"version": "2.0",
"ref": "fe60ce509ef04a3f40da96e3979bc8d9b13b2372"
}
},
"kevinpapst/adminlte-bundle": {
"version": "0.3"
},
@@ -137,6 +167,15 @@
"myclabs/deep-copy": {
"version": "1.7.0"
},
"nelmio/api-doc-bundle": {
"version": "3.0",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "master",
"version": "3.0",
"ref": "c8e0c38e1a280ab9e37587a8fa32b251d5bc1c94"
}
},
"nikic/php-parser": {
"version": "v4.0.2"
},
@@ -161,6 +200,9 @@
"php-cs-fixer/diff": {
"version": "v1.2.0"
},
"phpcollection/phpcollection": {
"version": "0.5.0"
},
"phpdocumentor/reflection-common": {
"version": "1.0.1"
},
@@ -170,6 +212,9 @@
"phpdocumentor/type-resolver": {
"version": "0.4.0"
},
"phpoption/phpoption": {
"version": "1.5.0"
},
"phpspec/prophecy": {
"version": "1.7.3"
},
@@ -413,6 +458,9 @@
"symfony/property-access": {
"version": "v4.0.3"
},
"symfony/property-info": {
"version": "v4.1.3"
},
"symfony/routing": {
"version": "4.0",
"recipe": {
@@ -536,10 +584,19 @@
"white-october/pagerfanta-bundle": {
"version": "v1.1.2"
},
"willdurand/jsonp-callback-validator": {
"version": "v1.1.0"
},
"willdurand/negotiation": {
"version": "v2.3.1"
},
"zendframework/zend-code": {
"version": "3.3.0"
},
"zendframework/zend-eventmanager": {
"version": "3.2.0"
},
"zircote/swagger-php": {
"version": "2.0.13"
}
}

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{ swagger_data.spec.info.title }}</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans:400,700|Source+Code+Pro:300,600|Titillium+Web:400,600,700">
<link rel="stylesheet" href="{{ asset('bundles/nelmioapidoc/swagger-ui/swagger-ui.css') }}">
<link rel="stylesheet" href="{{ asset('bundles/nelmioapidoc/style.css') }}">
{# json_encode(65) is for JSON_UNESCAPED_SLASHES|JSON_HEX_TAG to avoid JS XSS #}
<script id="swagger-data" type="application/json">{{ swagger_data|json_encode(65)|raw }}</script>
</head>
<body style="margin-top:0;">
<div id="swagger-ui" class="api-platform"></div>
<script src="{{ asset('bundles/nelmioapidoc/swagger-ui/swagger-ui-bundle.js') }}"></script>
<script src="{{ asset('bundles/nelmioapidoc/swagger-ui/swagger-ui-standalone-preset.js') }}"></script>
<script src="{{ asset('bundles/nelmioapidoc/init-swagger-ui.js') }}"></script>
</body>
</html>

View File

@@ -0,0 +1,169 @@
<?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\Tests\API;
use App\DataFixtures\UserFixtures;
use App\Entity\User;
use App\Tests\Controller\ControllerBaseTest;
use Symfony\Bundle\FrameworkBundle\Client;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpFoundation\Response;
/**
* Adds some useful functions for writing API integration tests.
*/
abstract class APIControllerBaseTest extends ControllerBaseTest
{
/**
* @param string $role
* @return Client
*/
protected function getClientForAuthenticatedUser(string $role = User::ROLE_USER)
{
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:
$client = null;
break;
}
return $client;
}
/**
* @param string $url
* @param bool $json
* @return string
*/
protected function createUrl($url, $json = true)
{
return '/' . ltrim($url, '/') . ($json ? '.json' : '');
}
/**
* @param Client $client
* @param string $url
* @param string $method
*/
protected function assertRequestIsSecured(Client $client, string $url, $method = 'GET')
{
$this->request($client, $url, $method);
$this->assertResponseIsSecured($client->getResponse(), $url);
}
/**
* @param Response $response
* @param string $url
*/
protected function assertResponseIsSecured(Response $response, string $url)
{
$data = ['message' => 'Authentication required, missing headers: X-AUTH-USER, X-AUTH-TOKEN'];
$this->assertEquals(
$data,
json_decode($response->getContent(), true),
sprintf('The secure URL %s is not protected.', $url)
);
$this->assertEquals(
Response::HTTP_FORBIDDEN, // TODO that should actually be 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')
{
$client = $this->getClientForAuthenticatedUser($role);
$client->request($method, $this->createUrl($url));
$this->assertFalse(
$client->getResponse()->isSuccessful(),
sprintf('The secure URL %s is not protected for role %s', $url, $role)
);
$expected = [
'code' => 403,
'message' => 'Access denied.'
];
$this->assertEquals(403, $client->getResponse()->getStatusCode());
$this->assertEquals(
$expected,
json_decode($client->getResponse()->getContent(), true)
);
}
/**
* @param Client $client
* @param string $url
* @param string $method
* @return Crawler
*/
protected function request(Client $client, string $url, $method = 'GET')
{
return $client->request($method, $this->createUrl($url), [], [], ['HTTP_CONTENT_TYPE' => 'application/json']);
}
/**
* @param string $role
* @param string $url
*/
protected function assertEntityNotFound(string $role, string $url)
{
$client = $this->getClientForAuthenticatedUser($role);
$this->request($client, $url);
$expected = [
'code' => 404,
'message' => 'Not found'
];
$this->assertEquals(404, $client->getResponse()->getStatusCode());
$this->assertEquals(
$expected,
json_decode($client->getResponse()->getContent(), true)
);
}
}

View File

@@ -0,0 +1,64 @@
<?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\Tests\API;
use App\Entity\User;
/**
* @coversDefaultClass \App\API\ActivityController
* @group integration
* @group legacy
*/
class ActivityControllerTest extends APIControllerBaseTest
{
public function testIsSecure()
{
$this->assertUrlIsSecured('/api/activities');
}
public function testGetCollection()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/activities');
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertInternalType('array', $result);
$this->assertNotEmpty($result);
$this->assertEquals(1, count($result));
$this->assertStructure($result[0]);
}
public function testGetEntity()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/activities/1');
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertInternalType('array', $result);
$this->assertStructure($result);
}
public function testNotFound()
{
$this->assertEntityNotFound(User::ROLE_USER, '/api/activities/2');
}
protected function assertStructure(array $result)
{
$expectedKeys = [
'id', 'name', 'comment', 'visible', 'project_id'
];
$actual = array_keys($result);
$this->assertEquals(count($expectedKeys), count($actual), 'Activity entity has different amount of keys');
$this->assertEquals($expectedKeys, $actual, 'Activity structure does not match');
}
}

View File

@@ -0,0 +1,51 @@
<?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\Tests\API;
use App\Entity\User;
use App\Tests\Controller\ControllerBaseTest;
/**
* @group integration
* @group legacy
*/
class ApiDocControllerTest extends ControllerBaseTest
{
public function testIsSecure()
{
$this->assertUrlIsSecured('/api/doc');
}
public function testGetDocs()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/doc');
$this->assertContains('<title>Kimai 2 - API Docs</title>', $client->getResponse()->getContent());
}
public function testGetJsonDocs()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/doc.json');
$this->assertContains('"title":"Kimai 2 - API Docs"', $client->getResponse()->getContent());
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertInternalType('array', $result);
$this->assertNotEmpty($result);
}
/**
* @param string $url
* @return string
*/
protected function createUrl($url)
{
return '/' . ltrim($url, '/');
}
}

View File

@@ -0,0 +1,65 @@
<?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\Tests\API;
use App\Entity\User;
/**
* @coversDefaultClass \App\API\CustomerController
* @group integration
* @group legacy
*/
class CustomerControllerTest extends APIControllerBaseTest
{
public function testIsSecure()
{
$this->assertUrlIsSecured('/api/customers');
}
public function testGetCollection()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/customers');
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertInternalType('array', $result);
$this->assertNotEmpty($result);
$this->assertEquals(1, count($result));
$this->assertStructure($result[0]);
}
public function testGetEntity()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/customers/1');
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertInternalType('array', $result);
$this->assertStructure($result);
}
public function testNotFound()
{
$this->assertEntityNotFound(User::ROLE_USER, '/api/customers/2');
}
protected function assertStructure(array $result)
{
$expectedKeys = [
'id', 'name', 'number', 'comment', 'visible', 'company', 'contact', 'address', 'country', 'currency',
'phone', 'fax', 'mobile', 'mail', 'timezone'
];
$actual = array_keys($result);
$this->assertEquals(count($expectedKeys), count($actual), 'Customer entity has different amount of keys');
$this->assertEquals($expectedKeys, $actual, 'Customer structure does not match');
}
}

View File

@@ -0,0 +1,35 @@
<?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\Tests\API;
use App\Entity\User;
/**
* @coversDefaultClass \App\API\HealthcheckController
* @group integration
* @group legacy
*/
class HealthcheckControllerTest extends APIControllerBaseTest
{
public function testIsSecure()
{
$this->assertUrlIsSecured('/api/ping');
}
public function testPing()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/ping');
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertInternalType('array', $result);
$this->assertEquals(['message' => 'pong'], $result);
}
}

View File

@@ -0,0 +1,64 @@
<?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\Tests\API;
use App\Entity\User;
/**
* @coversDefaultClass \App\API\ProjectController
* @group integration
* @group legacy
*/
class ProjectControllerTest extends APIControllerBaseTest
{
public function testIsSecure()
{
$this->assertUrlIsSecured('/api/projects');
}
public function testGetCollection()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/projects');
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertInternalType('array', $result);
$this->assertNotEmpty($result);
$this->assertEquals(1, count($result));
$this->assertStructure($result[0]);
}
public function testGetEntity()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/projects/1');
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertInternalType('array', $result);
$this->assertStructure($result);
}
public function testNotFound()
{
$this->assertEntityNotFound(User::ROLE_USER, '/api/projects/2');
}
protected function assertStructure(array $result)
{
$expectedKeys = [
'id', 'name', 'comment', 'visible', 'budget', 'order_number', 'customer_id'
];
$actual = array_keys($result);
$this->assertEquals(count($expectedKeys), count($actual), 'Project entity has different amount of keys');
$this->assertEquals($expectedKeys, $actual, 'Project structure does not match');
}
}

View File

@@ -0,0 +1,65 @@
<?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\Tests\API;
use App\Entity\User;
/**
* @coversDefaultClass \App\API\UserController
* @group integration
* @group legacy
*/
class UserControllerTest extends APIControllerBaseTest
{
public function testIsSecure()
{
$this->assertUrlIsSecured('/api/users');
$this->assertUrlIsSecuredForRole(User::ROLE_ADMIN, '/api/users');
}
public function testGetCollection()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/api/users');
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertInternalType('array', $result);
$this->assertNotEmpty($result);
$this->assertEquals(6, count($result));
$this->assertStructure($result[0]);
}
public function testGetEntity()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/api/users/1');
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertInternalType('array', $result);
$this->assertStructure($result);
}
public function testNotFound()
{
$this->assertEntityNotFound(User::ROLE_SUPER_ADMIN, '/api/users/99');
}
protected function assertStructure(array $result)
{
$expectedKeys = [
'id', 'username', 'enabled', 'roles', 'alias', 'title', 'avatar'
];
$actual = array_keys($result);
$this->assertEquals(count($expectedKeys), count($actual), 'User entity has different amount of keys');
$this->assertEquals($expectedKeys, $actual, 'User structure does not match');
}
}

View File

@@ -52,15 +52,13 @@ class UserControllerTest extends ControllerBaseTest
$this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode($username) . '/edit'));
$client->followRedirect();
$expectedTabs = ['#charts', '#settings', '#password', '#api-token', '#roles'];
$tabs = $client->getCrawler()->filter('div.nav-tabs-custom ul.nav-tabs li');
$this->assertEquals(4, $tabs->count());
$expectedTabs = ['#charts', '#settings', '#password', '#roles'];
$this->assertEquals(count($expectedTabs), $tabs->count());
$foundTabs = [];
foreach ($tabs->filter('a') as $tab) {
$name = $tab->getAttribute('href');
if (in_array($name, $expectedTabs)) {
$foundTabs[] = $name;
}
$foundTabs[] = $tab->getAttribute('href');
}
$this->assertEmpty(array_diff($expectedTabs, $foundTabs));

View File

@@ -72,5 +72,27 @@ class CalendarControllerTest extends ControllerBaseTest
$this->assertInternalType('array', $json);
$this->assertNotEmpty($json);
$this->assertEquals(10, count($json));
foreach ($json as $result) {
$this->assertInternalType('array', $result);
$this->assertCalendarStructure($result);
}
}
protected function assertCalendarStructure(array $result)
{
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('start', $result);
$this->assertArrayHasKey('title', $result);
$this->assertArrayHasKey('description', $result);
$this->assertArrayHasKey('customer', $result);
$this->assertArrayHasKey('project', $result);
$this->assertArrayHasKey('activity', $result);
$this->assertArrayHasKey('borderColor', $result);
$this->assertArrayHasKey('backgroundColor', $result);
if (isset($result['end'])) {
$this->assertNull($result['borderColor']);
$this->assertNull($result['backgroundColor']);
}
}
}

View File

@@ -96,18 +96,18 @@ abstract class ControllerBaseTest extends WebTestCase
*/
protected function assertRequestIsSecured(Client $client, string $url, $method = 'GET')
{
$client->request($method, $this->createUrl($url));
$this->request($client, $url, $method);
/* @var RedirectResponse $response */
$response = $client->getResponse();
$this->assertTrue(
$response->isRedirect(),
sprintf('The secure URL %s is not protected.', $url . $response->getContent())
sprintf('The secure URL %s is not protected.', $url)
);
$this->assertEquals(
'http://localhost' . $this->createUrl('/login'),
$this->assertStringEndsWith(
'/login',
$response->getTargetUrl(),
sprintf('The secure URL %s does not redirect to the login form.', $url)
);

View File

@@ -10,10 +10,13 @@
namespace App\Tests\Controller;
use App\DataFixtures\UserFixtures;
use App\Entity\User;
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
/**
* @coversDefaultClass \App\Controller\InvoiceController
* @group integration
* @group legacy
*/
class ProfileControllerTest extends ControllerBaseTest
{
@@ -28,15 +31,13 @@ class ProfileControllerTest extends ControllerBaseTest
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER);
$this->assertTrue($client->getResponse()->isSuccessful());
$expectedTabs = ['#charts', '#settings', '#password', '#api-token', '#preferences'];
$tabs = $client->getCrawler()->filter('div.nav-tabs-custom ul.nav-tabs li');
$this->assertEquals(4, $tabs->count());
$expectedTabs = ['#charts', '#settings', '#password', '#preferences'];
$this->assertEquals(count($expectedTabs), $tabs->count());
$foundTabs = [];
foreach ($tabs->filter('a') as $tab) {
$name = $tab->getAttribute('href');
if (in_array($name, $expectedTabs)) {
$foundTabs[] = $name;
}
$foundTabs[] = $tab->getAttribute('href');
}
$this->assertEmpty(array_diff($expectedTabs, $foundTabs));
}
@@ -47,4 +48,74 @@ class ProfileControllerTest extends ControllerBaseTest
$this->request($client, '/profile/' . UserFixtures::USERNAME_TEAMLEAD);
$this->assertFalse($client->getResponse()->isSuccessful());
}
public function testUpdateApiToken()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER);
/** @var User $user */
$user = $client->getContainer()->get('security.token_storage')->getToken()->getUser();
/** @var EncoderFactoryInterface $passwordEncoder */
$passwordEncoder = $client->getContainer()->get('test.PasswordEncoder');
$this->assertTrue($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), UserFixtures::DEFAULT_API_TOKEN, $user->getSalt()));
$this->assertFalse($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), 'test123', $user->getSalt()));
$this->assertEquals(UserFixtures::USERNAME_USER, $user->getUsername());
$form = $client->getCrawler()->filter('form[name=user_api_token]')->form();
$client->submit($form, [
'user_api_token' => [
'plainApiToken' => [
'first' => 'test123',
'second' => 'test123',
]
]
]);
$this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode(UserFixtures::USERNAME_USER)));
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasFlashSuccess($client);
$user = $client->getContainer()->get('security.token_storage')->getToken()->getUser();
$this->assertFalse($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), UserFixtures::DEFAULT_API_TOKEN, $user->getSalt()));
$this->assertTrue($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), 'test123', $user->getSalt()));
}
public function testUpdatePassword()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER);
/** @var User $user */
$user = $client->getContainer()->get('security.token_storage')->getToken()->getUser();
/** @var EncoderFactoryInterface $passwordEncoder */
$passwordEncoder = $client->getContainer()->get('test.PasswordEncoder');
$this->assertTrue($passwordEncoder->getEncoder($user)->isPasswordValid($user->getPassword(), UserFixtures::DEFAULT_PASSWORD, $user->getSalt()));
$this->assertFalse($passwordEncoder->getEncoder($user)->isPasswordValid($user->getPassword(), 'test123', $user->getSalt()));
$this->assertEquals(UserFixtures::USERNAME_USER, $user->getUsername());
$form = $client->getCrawler()->filter('form[name=user_password]')->form();
$client->submit($form, [
'user_password' => [
'plainPassword' => [
'first' => 'test123',
'second' => 'test123',
]
]
]);
$this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode(UserFixtures::USERNAME_USER)));
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasFlashSuccess($client);
$user = $client->getContainer()->get('security.token_storage')->getToken()->getUser();
$this->assertFalse($passwordEncoder->getEncoder($user)->isPasswordValid($user->getPassword(), UserFixtures::DEFAULT_PASSWORD, $user->getSalt()));
$this->assertTrue($passwordEncoder->getEncoder($user)->isPasswordValid($user->getPassword(), 'test123', $user->getSalt()));
}
}

View File

@@ -63,6 +63,14 @@
<source>label.user_profile</source>
<target>Mein Profil</target>
</trans-unit>
<trans-unit id="label.api_token">
<source>label.api_token</source>
<target>API Passwort</target>
</trans-unit>
<trans-unit id="label.api_token_repeat">
<source>label.api_token_repeat</source>
<target>API Passwort wiederholen</target>
</trans-unit>
<!--
Menu / Navbar items
@@ -343,6 +351,10 @@
<source>profile.password</source>
<target>Passwort</target>
</trans-unit>
<trans-unit id="profile.api-token">
<source>profile.api-token</source>
<target>API</target>
</trans-unit>
<trans-unit id="profile.roles">
<source>profile.roles</source>
<target>Rollen</target>

View File

@@ -63,6 +63,14 @@
<source>label.user_profile</source>
<target>My profile</target>
</trans-unit>
<trans-unit id="label.api_token">
<source>label.api_token</source>
<target>API password</target>
</trans-unit>
<trans-unit id="label.api_token_repeat">
<source>label.api_token_repeat</source>
<target>Repeat API password</target>
</trans-unit>
<!--
Menu / Navbar items
@@ -351,6 +359,10 @@
<source>profile.password</source>
<target>Password</target>
</trans-unit>
<trans-unit id="profile.api-token">
<source>profile.api-token</source>
<target>API</target>
</trans-unit>
<trans-unit id="profile.roles">
<source>profile.roles</source>
<target>Roles</target>

Binary file not shown.

View File

@@ -13,12 +13,16 @@ For the most part Kimai usage should be self-explanatory, so we will only cover
- [Timesheets](timesheet.md) - information about timesheets
## Developers and Administrators
## Developer & Administrator
- [Kimai configurations](configurations.md) - application configs, which can only be changed in config files
- [Developer docu](developers.md) - how to extend Kimai's feature set
- [User and Security](users.md) - docu for user and security topics, like authentication, registration and roles
- [FAQ](faq.md) - some answers to frequently asked questions
- [Emails](emails.md) - transport configuration and handling of emails
- [API](developers_api.md) - how to use the JSON API
## Installation
- [Installation](installation.md) - instructions about several installation methods
- [Importing data](migration_v1.md) - how to import your timesheets from Kimai v1
- [Emails](emails.md) - transport configuration and handling of emails

View File

@@ -0,0 +1,23 @@
# REST API
Read the Swagger documentation of the Kimai 2 API in your Kimai installation at `/api/doc`.
Or you can export the JSON collection by visiting `/api/doc.json`. Store the result in a file, which can be imported with Postman.
## Authentication
When calling the API you have to submit two additional header with every call for authentication:
- `X-AUTH-USER` - holds the username or email
- `X-AUTH-TOKEN` - holds the users API password, which he can set in his profile
Please make sure to ONLY call the Kimai 2 API via `https` to protect the users data!
## Calling the API with Javascript
If you develop your own extension and need to use the API for logged-in user, then you have to set the header `X-AUTH-SESSION`
which will allow Kimai to use the current user session and not look for the default token based API authentication.
### Next step
Back to the [developer documentation](developers.md).

View File

@@ -10,3 +10,4 @@ Internal documentation for project maintainers
- Push a release branch and add it as last PR merge into master
- Edit the release-draft and add the "Full changelog" link + everything from the "Merged pull requests" section from CHANGELOG.md
- Create the release
- Post a new issue at [YunoHost tracker for Kimai 2](https://github.com/YunoHost-Apps/kimai2_ynh)