Merged in feature_users (pull request #1)

Users and authentication
This commit is contained in:
Alexey Eschenko 2018-06-27 22:03:24 +00:00
commit c0f500e79b
45 changed files with 1904 additions and 185 deletions

View File

@ -13,5 +13,6 @@ APP_SECRET=xxx
# Format described at http://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# For an SQLite database, use: "sqlite:///%kernel.project_dir%/var/data.db"
# Configure your db driver and server_version in config/packages/doctrine.yaml
DATABASE_URL=sqlite:////home/magnetico/.local/share/magneticod/database.sqlite3
APP_DATABASE_URL=postgres://$PGUSER:$PGPASSWORD@127.0.0.1:5436/test?application_name=magnetico_web
MAGNETICOD_DATABASE_URL=sqlite:///%kernel.project_dir%/tests/database/database.sqlite3
###< doctrine/doctrine-bundle ###

View File

@ -18,13 +18,16 @@
"sensio/framework-extra-bundle": "^5.1",
"symfony/console": "^4.1",
"symfony/flex": "^1.0",
"symfony/form": "^4.1",
"symfony/framework-bundle": "^4.1",
"symfony/lts": "^4@dev",
"symfony/monolog-bundle": "^3.3",
"symfony/orm-pack": "^1.0",
"symfony/security-bundle": "^4.1",
"symfony/serializer-pack": "^1.0",
"symfony/translation": "^4.1",
"symfony/twig-bundle": "^4.1",
"symfony/validator": "^4.1",
"symfony/web-server-bundle": "^4.1",
"symfony/yaml": "^4.1",
"white-october/pagerfanta-bundle": "^1.2"

754
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -11,4 +11,5 @@ return [
WhiteOctober\PagerfantaBundle\WhiteOctoberPagerfantaBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
];

View File

@ -3,21 +3,42 @@ parameters:
# This allows you to run cache:warmup even if your
# environment variables are not available yet.
# You should not need to change this value.
env(DATABASE_URL): ''
env(APP_DATABASE_URL): ''
env(MAGNETICOD_DATABASE_URL): ''
doctrine:
dbal:
# configure these for your database server
driver: 'pdo_sqlite'
url: '%env(resolve:DATABASE_URL)%'
default_connection: default
connections:
default:
driver: 'pdo_pgsql'
url: '%env(resolve:APP_DATABASE_URL)%'
#server_version: 9.6
magneticod:
driver: 'pdo_sqlite'
url: '%env(resolve:MAGNETICOD_DATABASE_URL)%'
orm:
auto_generate_proxy_classes: '%kernel.debug%'
naming_strategy: doctrine.orm.naming_strategy.underscore
auto_mapping: true
mappings:
App:
is_bundle: false
type: annotation
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
default_entity_manager: default
entity_managers:
default:
connection: default
naming_strategy: doctrine.orm.naming_strategy.underscore
auto_mapping: true
mappings:
App:
is_bundle: false
type: annotation
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
magneticod:
connection: magneticod
mappings:
Magnetico:
is_bundle: false
type: annotation
dir: '%kernel.project_dir%/src/Magnetico/Entity'
prefix: 'App\Magnetico'
alias: Magnetico

View File

@ -0,0 +1,52 @@
security:
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
providers:
default_provider:
entity:
class: App\Entity\User
property: username
manager_name: default
api_token_provider:
id: App\Security\ApiTokenUserProvider
encoders:
App\Entity\User:
algorithm: 'argon2i'
memory_cost: 16384
time_cost: 2
threads: 4
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api:
pattern: ^/api/
anonymous: ~
stateless: true
simple_preauth:
authenticator: App\Security\ApiTokenAuthenticator
provider: api_token_provider
main:
pattern: ^/
anonymous: ~
provider: default_provider
form_login:
login_path: user_login
check_path: user_login
logout:
path: user_logout
target: /
remember_me:
secret: '%kernel.secret%'
lifetime: 604800
path: /
always_remember_me: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/api/v1/login$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/api/, roles: ROLE_USER }
- { path: ^/$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: /login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/, roles: ROLE_USER }

View File

@ -2,3 +2,4 @@ twig:
paths: ['%kernel.project_dir%/templates']
debug: '%kernel.debug%'
strict_variables: '%kernel.debug%'
form_themes: ['bootstrap_4_layout.html.twig']

View File

@ -4,7 +4,7 @@ index:
controller: App\Controller\MainController::index
torrents_search:
path: /torrents/search
path: /torrents
controller: App\Controller\TorrentController::searchTorrent
requirements:
method: GET
@ -16,7 +16,39 @@ torrents_show:
method: GET
id: '\d+'
user_register:
path: /register/{inviteCode}
controller: App\Controller\UserController::register
requirements:
method: GET
inviteCode: \w{32}
user_login:
path: /login
controller: App\Controller\SecurityController::login
user_logout:
path: /logout
# API
api_v1_login:
path: /api/v1/login
controller: App\Api\V1\Controller\SecurityController::login
defaults:
_format: json
requirements:
method: POST
_format: json
api_v1_logout:
path: /api/v1/logout
controller: App\Api\V1\Controller\SecurityController::logout
defaults:
_format: json
requirements:
method: GET
_format: json
api_v1_torrents:
path: /api/v1/torrents
controller: App\Api\V1\Controller\TorrentController::search

View File

@ -17,12 +17,20 @@ services:
App\:
resource: '../src/*'
exclude: '../src/{Entity,Migrations,Tests,Kernel.php}'
exclude: '../src/{Api/V1/{DTO},Magnetico/{Entity,Migrations},Entity,FormRequest,Migrations,Tests,Kernel.php}'
# Use array in exclude config from Symfony 4.2
#- '../src/Api/V1/{DTO}'
#- '../src/Magnetico/{Entity,Migrations}'
#- '../src/{Entity,FormRequest,Migrations,Tests,Kernel.php}'
App\Controller\:
resource: '../src/Controller'
tags: ['controller.service_arguments']
App\Api\V1\Controller\:
resource: '../src/Api/V1/Controller'
tags: ['controller.service_arguments']
# Fast normalizer for Symfony Serializer
get_set_method_normalizer:
class: Symfony\Component\Serializer\Normalizer\GetSetMethodNormalizer

View File

@ -0,0 +1,19 @@
<?php
namespace App\Api\V1\Controller;
use App\Api\V1\DTO\ApiResponse;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\{JsonResponse, Response};
abstract class AbstractApiController extends Controller
{
protected const DEFAULT_SERIALIZER_GROUPS = ['api'];
protected function createJsonResponse($data, array $groups = [], int $code = Response::HTTP_OK, string $message = null, string $status = ''): JsonResponse
{
return $this->json(new ApiResponse($data, $code, $message, $status), $code, [], [
'groups' => array_merge(self::DEFAULT_SERIALIZER_GROUPS,$groups),
]);
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Api\V1\Controller;
use App\Entity\{ApiToken, User};
use App\Repository\{ApiTokenRepository, UserRepository};
use App\Security\Token\AuthenticatedApiToken;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\{JsonResponse, Request};
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
class SecurityController extends AbstractApiController
{
public function login(
Request $request,
EntityManagerInterface $em,
UserRepository $userRepo,
ApiTokenRepository $tokenRepo,
UserPasswordEncoderInterface $passwordEncoder
): JsonResponse {
$username = $request->request->get('username');
$password = $request->request->get('password');
/** @var User $user */
if (null === $user = $userRepo->findOneBy(['username' => $username])) {
return $this->createJsonResponse(null, [], JsonResponse::HTTP_UNAUTHORIZED, 'User not found');
}
if (!$passwordEncoder->isPasswordValid($user, $password)) {
return $this->createJsonResponse(null, [], JsonResponse::HTTP_UNAUTHORIZED, 'Invalid password');
}
$apiToken = new ApiToken($user);
$tokenRepo->add($apiToken);
try {
$em->flush();
} catch (\Exception $ex) {
return $this->createJsonResponse(null, [], JsonResponse::HTTP_INTERNAL_SERVER_ERROR, 'Token persisting error');
}
return $this->createJsonResponse($apiToken->getKey());
}
public function logout(TokenStorageInterface $tokenStorage, ApiTokenRepository $apiTokenRepo, EntityManagerInterface $em): JsonResponse
{
$token = $tokenStorage->getToken();
if (!$token instanceof AuthenticatedApiToken) {
return $this->createJsonResponse(null,[],JsonResponse::HTTP_INTERNAL_SERVER_ERROR, 'Invalid session token type retrieved.');
}
if (null === $apiTokenKey = $token->getTokenKey()) {
return $this->createJsonResponse(null,[],JsonResponse::HTTP_INTERNAL_SERVER_ERROR, 'Can\'t retrieve token key from session.');
}
if (null === $apiToken = $apiTokenRepo->findOneBy(['key' => $apiTokenKey])) {
return $this->createJsonResponse(null,[],JsonResponse::HTTP_INTERNAL_SERVER_ERROR, 'API token with such key not found in the database.');
}
$em->remove($apiToken);
try {
$em->flush();
} catch (\Exception $ex) {
return $this->createJsonResponse(null,[],JsonResponse::HTTP_INTERNAL_SERVER_ERROR, 'API token deauthentication failure.');
}
return $this->createJsonResponse(null,[],JsonResponse::HTTP_OK, 'Successfully logged out.');
}
}

View File

@ -2,22 +2,18 @@
namespace App\Api\V1\Controller;
use App\Api\V1\DTO\ApiResponse;
use App\Api\V1\DTO\ListPage;
use App\Entity\Torrent;
use App\Repository\TorrentRepository;
use App\Magnetico\Entity\Torrent;
use App\Magnetico\Repository\TorrentRepository;
use Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\{Request, Response};
use Symfony\Component\HttpFoundation\{JsonResponse, Request};
class TorrentController extends Controller
class TorrentController extends AbstractApiController
{
private const DEFAULT_SERIALIZER_GROUPS = ['api_v1'];
private const PER_PAGE = 20;
public function search(Request $request, TorrentRepository $repo): Response
public function search(Request $request, TorrentRepository $repo): JsonResponse
{
$query = $request->query->get('query', '');
$page = (int) $request->query->get('page', '1');
@ -29,16 +25,12 @@ class TorrentController extends Controller
->setMaxPerPage(self::PER_PAGE)
;
return $this->json(new ApiResponse(ListPage::createFromPager($pager)),Response::HTTP_OK, [], [
'groups' => array_merge(self::DEFAULT_SERIALIZER_GROUPS,['api_v1_search']),
]);
return $this->createJsonResponse(ListPage::createFromPager($pager), ['api_v1_search']);
}
public function show(Torrent $torrent): Response
public function show(Torrent $torrent): JsonResponse
{
return $this->json(new ApiResponse($torrent), Response::HTTP_OK, [], [
'groups' => array_merge(self::DEFAULT_SERIALIZER_GROUPS,['api_v1_show']),
]);
return $this->createJsonResponse($torrent, ['api_v1_show'], JsonResponse::HTTP_OK,null, '');
}

View File

@ -15,28 +15,28 @@ class ApiResponse
/**
* @var int HTTP response status code
*
* @Groups({"api_v1"})
* @Groups({"api"})
*/
private $code;
/**
* @var string Status text: 'success' (1xx-3xx), 'error' (4xx), 'fail' (5xx) or 'unknown'
*
* @Groups({"api_v1"})
* @Groups({"api"})
*/
private $status;
/**
* @var string|null Used for 'fail' and 'error'
*
* @Groups({"api_v1"})
* @Groups({"api"})
*/
private $message;
/**
* @var string|\object|array|null Response body. In case of 'error' or 'fail' contains cause or exception name.
*
* @Groups({"api_v1"})
* @Groups({"api"})
*/
private $data;

View File

@ -10,35 +10,35 @@ class ListPage
/**
* @var int
*
* @Serializer\Groups({"api_v1"})
* @Serializer\Groups({"api"})
*/
private $numberOfPages;
/**
* @var int
*
* @Serializer\Groups({"api_v1"})
* @Serializer\Groups({"api"})
*/
private $currentPage;
/**
* @var int
*
* @Serializer\Groups({"api_v1"})
* @Serializer\Groups({"api"})
*/
private $numberOfResults;
/**
* @var int
*
* @Serializer\Groups({"api_v1"})
* @Serializer\Groups({"api"})
*/
private $maxPerPage;
/**
* @var \Traversable
*
* @Serializer\Groups({"api_v1"})
* @Serializer\Groups({"api"})
*/
protected $items;

View File

@ -0,0 +1,98 @@
<?php
namespace App\Command;
use App\Entity\Invite;
use App\Repository\{InviteRepository, UserRepository};
use App\User\UserManager;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\{InputArgument, InputInterface, InputOption};
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
class AddUserCommand extends Command
{
/** @var EntityManagerInterface */
private $em;
/** @var UserManager */
private $userManager;
/** @var UserRepository */
private $userRepo;
/** @var InviteRepository */
private $inviteRepo;
public function __construct(EntityManagerInterface $em, UserManager $userManager, UserRepository $userRepo, InviteRepository $inviterepo)
{
parent::__construct();
$this->em = $em;
$this->userManager = $userManager;
$this->userRepo = $userRepo;
$this->inviteRepo = $inviterepo;
}
protected function configure()
{
$this
->setName('user:add')
->addArgument('username', InputArgument::REQUIRED, 'Username')
->addArgument('email', InputArgument::REQUIRED, 'Email')
->addArgument('password', InputArgument::OPTIONAL, 'Password', null)
->addOption('invites', 'i', InputOption::VALUE_OPTIONAL, 'Number of invites for user', 0)
->addOption('role', 'r', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, 'Role to add to the user')
;
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$username = $input->getArgument('username');
$email = $input->getArgument('email');
$password = $input->getArgument('password');
$invites = (int) $input->getOption('invites');
$roles = (array) $input->getOption('role');
if (!$password) {
/** @var QuestionHelper $questionHelper */
$questionHelper = $this->getHelper('question');
$question = new Question('Enter new user\'s password: ');
$question->setHidden(true);
$question->setHiddenFallback(false);
$password = $questionHelper->ask($input, $output, $question);
}
if (!$password) {
$output->writeln('User password cannot be empty.');
return 1;
}
if ($roles) {
$user = $this->userManager->createUser($username, $password, $email, $roles);
} else {
$user = $this->userManager->createUser($username, $password, $email);
}
$this->userRepo->add($user);
if ($invites) {
for ($i = 0; $i < $invites; $i++) {
$invite = new Invite($user);
$this->inviteRepo->add($invite);
}
}
$this->em->flush();
$output->writeln(sprintf('User \'%s\' registered, %d invites added.', $user->getUsername(), $invites));
return 0;
}
}

View File

@ -2,16 +2,29 @@
namespace App\Controller;
use App\Repository\TorrentRepository;
use App\Form\LoginType;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Response;
class MainController extends Controller
{
public function index(TorrentRepository $repo): Response
public function index(): Response
{
return $this->render('index.html.twig', [
'torrentsCount' => $repo->getTorrentsTotalCount(),
'loginForm' => $this->createLoginForm('')->createView(),
]);
}
private function createLoginForm(string $username): FormInterface
{
$form = $this->createForm(LoginType::class, null, [
'action' => $this->generateUrl('user_login'),
]);
$form->get('_username')->setData($username);
$form->add('submit', SubmitType::class);
return $form;
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Controller;
use App\Form\LoginType;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\{FormError, FormInterface};
use Symfony\Component\HttpFoundation\{Request, Response};
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Component\Translation\TranslatorInterface;
class SecurityController extends Controller
{
public function login(Request $request, AuthenticationUtils $authenticationUtils, TranslatorInterface $translator): Response
{
$lastError = $authenticationUtils->getLastAuthenticationError() ? $authenticationUtils->getLastAuthenticationError()->getMessage() : '';
$lastUsername = $authenticationUtils->getLastUsername();
$form = $this->createLoginForm($lastUsername);
$form->handleRequest($request);
if ($lastError) {
$form->addError(new FormError($lastError));
}
return $this->render('Security/login.html.twig', ['form' => $form->createView()]);
}
private function createLoginForm(string $username): FormInterface
{
$form = $this->createForm(LoginType::class, null, [
'action' => $this->generateUrl('user_login'),
]);
$form->get('_username')->setData($username);
$form->add('submit', SubmitType::class);
return $form;
}
}

View File

@ -2,8 +2,8 @@
namespace App\Controller;
use App\Entity\Torrent;
use App\Repository\TorrentRepository;
use App\Magnetico\Entity\Torrent;
use App\Magnetico\Repository\TorrentRepository;
use Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

View File

@ -0,0 +1,65 @@
<?php
namespace App\Controller;
use App\Form\{CreateUserRequestType};
use App\FormRequest\CreateUserRequest;
use App\Repository\{UserRepository};
use App\User\Exception\InvalidInviteException;
use App\User\UserManager;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\{FormError, FormInterface};
use Symfony\Component\HttpFoundation\{Request, Response};
class UserController extends Controller
{
public function register(
string $inviteCode,
Request $request,
EntityManagerInterface $em,
UserManager $userManager,
UserRepository $userRepository
): Response {
$createUserRequest = new CreateUserRequest();
$createUserRequest->inviteCode = $inviteCode;
$form = $this->createRegisterForm($createUserRequest, $inviteCode);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$user = $userManager->createUserByInviteCode(
$createUserRequest->username,
$createUserRequest->password,
$createUserRequest->email,
$createUserRequest->inviteCode
);
} catch (InvalidInviteException $ex) {
// @FIXME refactor InvalidInviteException to proper validator
$form->get('inviteCode')->addError(new FormError('Invalid invite code'));
return $this->render('User/register.html.twig', ['form' => $form->createView()]);
}
$userRepository->add($user);
$em->flush();
return $this->redirectToRoute('index');
}
return $this->render('User/register.html.twig', ['form' => $form->createView()]);
}
private function createRegisterForm(CreateUserRequest $createUserRequest, string $inviteCode): FormInterface
{
$form = $this->createForm(CreateUserRequestType::class, $createUserRequest, [
'action' => $this->generateUrl('user_register', ['inviteCode' => $inviteCode]),
]);
$form->add('submit', SubmitType::class);
return $form;
}
}

60
src/Entity/ApiToken.php Normal file
View File

@ -0,0 +1,60 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
/**
* @ORM\Table(name="api_tokens", schema="users")
* @ORM\Entity(repositoryClass="App\Repository\ApiTokenRepository", readOnly=true)
*/
class ApiToken
{
/**
* @var User
*
* @ORM\ManyToOne(targetEntity="App\Entity\User")
* @ORM\JoinColumn(name="user_id", nullable=false)
*/
private $user;
/**
* @var string
*
* @Serializer\Groups({"api", "api_v1_login"})
*
* @ORM\Id()
* @ORM\Column(name="key", type="string", length=32)
*/
private $key;
/**
* @var \DateTime
*
* @ORM\Column(name="created_at", type="datetime")
*/
private $createdAt;
public function __construct(User $user)
{
$this->user = $user;
$this->key = md5(random_bytes(100));
$this->createdAt = new \DateTime();
}
public function getUser(): User
{
return $this->user;
}
public function getKey(): string
{
return $this->key;
}
public function getCreatedAt(): \DateTime
{
return $this->createdAt;
}
}

84
src/Entity/Invite.php Normal file
View File

@ -0,0 +1,84 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Table(name="invites", schema="users")
* @ORM\Entity(repositoryClass="App\Repository\InviteRepository")
*/
class Invite
{
/**
* @var int
*
* @ORM\Id()
* @ORM\GeneratedValue(strategy="AUTO")
* @ORM\Column(name="id", type="integer")
*/
private $id;
/**
* @var User
*
* @ORM\ManyToOne(targetEntity="App\Entity\User")
* @ORM\JoinColumn(name="user_id", nullable=false)
*/
private $user;
/**
* @var string
*
* @ORM\Column(name="code", type="string", length=32, unique=true)
*/
private $code;
/**
* @var User|null
*
* @ORM\ManyToOne(targetEntity="App\Entity\User")
* @ORM\JoinColumn(name="used_by_id", nullable=true)
*/
private $usedBy;
public function __construct(User $forUser)
{
$this->user = $forUser;
$this->code = md5(random_bytes(100));
}
public function getId(): int
{
return $this->id;
}
public function getUser(): User
{
return $this->user;
}
public function getCode(): string
{
return $this->code;
}
public function getUsedBy(): ?User
{
return $this->usedBy;
}
public function use(User $user): void
{
if ($this->usedBy) {
throw new \RuntimeException(sprintf(
'Invite #%d is already used by User#%d and can\'t be used by User#%d',
$this->id,
$this->usedBy->getId(),
$user->getId()
));
}
$this->usedBy = $user;
}
}

152
src/Entity/User.php Normal file
View File

@ -0,0 +1,152 @@
<?php
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @ORM\Table(name="users", schema="users")
* @ORM\Entity(repositoryClass="App\Repository\UserRepository")
*/
class User implements UserInterface, \Serializable
{
/**
* @var int
*
* @ORM\Id
* @ORM\Column(name="id", type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
/**
* @var string
*
* @ORM\Column(name="username", type="string", length=25, unique=true)
*/
private $username;
/**
* @var string
*
* @ORM\Column(name="password", type="text")
*/
private $password;
/**
* @var string
*
* @ORM\Column(name="email", type="string", length=254, unique=true)
*/
private $email;
/**
* @var string[]
*
* @ORM\Column(name="roles", type="json")
*/
private $roles = [];
/**
* @var \DateTime
*
* @ORM\Column(name="created_at", type="datetime")
*/
private $createdAt;
/**
* @var Invite[]|ArrayCollection
*
* @ORM\OneToMany(targetEntity="App\Entity\Invite", mappedBy="user", fetch="EXTRA_LAZY")
*/
private $invites;
public function __construct(string $username, string $password, string $email, array $roles = [])
{
$this->username = $username;
$this->password = $password;
$this->email = $email;
$this->roles = $roles ?: ['ROLE_USER'];
$this->createdAt = new \DateTime();
}
public function getId(): int
{
return $this->id;
}
public function getUsername()
{
return $this->username;
}
public function getPassword()
{
return $this->password;
}
public function updatePassword(string $password): void
{
$this->password = $password;
}
public function getSalt()
{
// Salt is not needed when using Argon2i
// @see https://symfony.com/doc/current/reference/configuration/security.html#using-the-argon2i-password-encoder
return null;
}
public function getEmail(): string
{
return $this->email;
}
public function getRoles(): array
{
return $this->roles;
}
public function addRole(string $role): void
{
$this->roles[] = $role;
}
public function getCreatedAt(): \DateTime
{
return $this->createdAt;
}
public function eraseCredentials()
{
}
/** @return Invite[]|ArrayCollection */
public function getInvites(): \Traversable
{
return $this->invites;
}
/** @see \Serializable::serialize() */
public function serialize()
{
return serialize([
$this->id,
$this->username,
$this->password,
]);
}
/** @see \Serializable::unserialize() */
public function unserialize($serialized)
{
list(
$this->id,
$this->username,
$this->password
) = unserialize($serialized, ['allowed_classes' => false]);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Form;
use App\FormRequest\CreateUserRequest;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\{EmailType, PasswordType, TextType};
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CreateUserRequestType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('username', TextType::class)
->add('password', PasswordType::class)
->add('email', EmailType::class)
->add('inviteCode', TextType::class)
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => CreateUserRequest::class,
]);
}
}

26
src/Form/LoginType.php Normal file
View File

@ -0,0 +1,26 @@
<?php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\{PasswordType, TextType};
use Symfony\Component\Form\FormBuilderInterface;
class LoginType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('_username', TextType::class, ['mapped' => false])
->add('_password', PasswordType::class, ['mapped' => false])
;
}
public function getBlockPrefix()
{
// Empty prefix for default UsernamePasswordFrormAuthenticationListener
return '';
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\FormRequest;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @todo implement UniqueEntity constraint for DTO and use it here
*/
class CreateUserRequest
{
/**
* @var string
*
* @Assert\NotBlank()
* @Assert\Length(min="2", max="25")
*/
public $username;
/**
* @var string
*
* @Assert\NotBlank()
* @Assert\Length(min="8", max="4096")
*/
public $password;
/**
* @var string
*
* @Assert\Email()
*/
public $email;
/**
* @var string
*
* @Assert\NotBlank()
* @Assert\Length(min="32", max="32")
*/
public $inviteCode;
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Entity;
namespace App\Magnetico\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
@ -24,7 +24,7 @@ class File
/**
* @var Torrent
*
* @ORM\ManyToOne(targetEntity="App\Entity\Torrent", inversedBy="files")
* @ORM\ManyToOne(targetEntity="App\Magnetico\Entity\Torrent", inversedBy="files")
* @ORM\JoinColumn(name="torrent_id")
*/
private $torrent;

View File

@ -1,6 +1,6 @@
<?php
namespace App\Entity;
namespace App\Magnetico\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
@ -11,7 +11,7 @@ use Symfony\Component\Serializer\Annotation as Serializer;
* @ORM\Index(name="discovered_on_index", columns={"discovered_on"}),
* @ORM\Index(name="info_hash_index", columns={"info_hash"})
* })
* @ORM\Entity(readOnly=true, repositoryClass="App\Repository\TorrentRepository")
* @ORM\Entity(readOnly=true, repositoryClass="App\Magnetico\Repository\TorrentRepository")
*/
class Torrent
{
@ -71,7 +71,7 @@ class Torrent
*
* @Serializer\Groups({"api_v1_show"})
*
* @ORM\OneToMany(targetEntity="App\Entity\File", fetch="EXTRA_LAZY", mappedBy="torrent")
* @ORM\OneToMany(targetEntity="App\Magnetico\Entity\File", fetch="EXTRA_LAZY", mappedBy="torrent")
*/
private $files;

View File

@ -1,6 +1,6 @@
<?php
namespace App\Repository;
namespace App\Magnetico\Repository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
@ -10,7 +10,7 @@ class TorrentRepository extends ServiceEntityRepository
{
public function __construct(RegistryInterface $registry)
{
parent::__construct($registry, \App\Entity\Torrent::class);
parent::__construct($registry, \App\Magnetico\Entity\Torrent::class);
}
public function getTorrentsTotalCount(): int
@ -19,7 +19,11 @@ class TorrentRepository extends ServiceEntityRepository
->select('COUNT(t.id)')
;
return (int) $qb->getQuery()->getSingleScalarResult();
try {
return (int) $qb->getQuery()->getSingleScalarResult();
} catch (\Exception $ex) {
return 0;
}
}
public function createFindLikeQueryBuilder(string $query): QueryBuilder

View File

@ -0,0 +1,50 @@
<?php declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Creates users schema with basic set of tables.
*/
final class Version20180626223216 extends AbstractMigration
{
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('CREATE SCHEMA users');
$this->addSql('CREATE SEQUENCE users.users_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE SEQUENCE users.invites_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE users.users (id INT NOT NULL, username VARCHAR(25) NOT NULL, password TEXT NOT NULL, email VARCHAR(254) NOT NULL, roles JSON NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_338ADFC4F85E0677 ON users.users (username)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_338ADFC4E7927C74 ON users.users (email)');
$this->addSql('CREATE TABLE users.invites (id INT NOT NULL, user_id INT NOT NULL, used_by_id INT DEFAULT NULL, code VARCHAR(32) NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_93848FA877153098 ON users.invites (code)');
$this->addSql('CREATE INDEX IDX_93848FA8A76ED395 ON users.invites (user_id)');
$this->addSql('CREATE INDEX IDX_93848FA84C2B72A8 ON users.invites (used_by_id)');
$this->addSql('CREATE TABLE users.api_tokens (key VARCHAR(32) NOT NULL, user_id INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(key))');
$this->addSql('CREATE INDEX IDX_C46A74A9A76ED395 ON users.api_tokens (user_id)');
$this->addSql('ALTER TABLE users.invites ADD CONSTRAINT FK_93848FA8A76ED395 FOREIGN KEY (user_id) REFERENCES users.users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE users.invites ADD CONSTRAINT FK_93848FA84C2B72A8 FOREIGN KEY (used_by_id) REFERENCES users.users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE users.api_tokens ADD CONSTRAINT FK_C46A74A9A76ED395 FOREIGN KEY (user_id) REFERENCES users.users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema) : void
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('ALTER TABLE users.invites DROP CONSTRAINT FK_93848FA8A76ED395');
$this->addSql('ALTER TABLE users.invites DROP CONSTRAINT FK_93848FA84C2B72A8');
$this->addSql('ALTER TABLE users.api_tokens DROP CONSTRAINT FK_C46A74A9A76ED395');
$this->addSql('DROP SEQUENCE users.users_id_seq CASCADE');
$this->addSql('DROP SEQUENCE users.invites_id_seq CASCADE');
$this->addSql('DROP TABLE users.users');
$this->addSql('DROP TABLE users.invites');
$this->addSql('DROP TABLE users.api_tokens');
$this->addSql('DROP SCHEMA users');
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Repository;
use App\Entity\{ApiToken, User};
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query\Expr\Join;
use Symfony\Bridge\Doctrine\RegistryInterface;
class ApiTokenRepository extends ServiceEntityRepository
{
public function __construct(RegistryInterface $registry)
{
parent::__construct($registry, ApiToken::class);
}
public function add(ApiToken $token): void
{
$this->getEntityManager()->persist($token);
}
public function findUserByTokenKey(string $tokenKey): ?User
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->select('u')
->from(User::class, 'u')
->innerJoin(ApiToken::class, 'at', Join::WITH, 'at.user = u')
->where('at.key = :tokenKey')
->setParameter('tokenKey', $tokenKey)
;
return $qb->getQuery()->getOneOrNullResult();
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Repository;
use App\Entity\Invite;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Symfony\Bridge\Doctrine\RegistryInterface;
class InviteRepository extends ServiceEntityRepository
{
public function __construct(RegistryInterface $registry)
{
parent::__construct($registry, Invite::class);
}
public function add(Invite $invite): void
{
$this->getEntityManager()->persist($invite);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Symfony\Bridge\Doctrine\RegistryInterface;
class UserRepository extends ServiceEntityRepository
{
public function __construct(RegistryInterface $registry)
{
parent::__construct($registry, User::class);
}
public function add(User $user): void
{
$this->getEntityManager()->persist($user);
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Security;
use App\Api\V1\DTO\ApiResponse;
use App\Security\Token\AuthenticatedApiToken;
use Symfony\Component\HttpFoundation\{JsonResponse, Request};
use Symfony\Component\Security\Core\Authentication\Token\{PreAuthenticatedToken, TokenInterface};
use Symfony\Component\Security\Core\Exception\{AuthenticationException, BadCredentialsException, CustomUserMessageAuthenticationException};
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Authentication\{AuthenticationFailureHandlerInterface, SimplePreAuthenticatorInterface};
use Symfony\Component\Serializer\SerializerInterface;
class ApiTokenAuthenticator implements SimplePreAuthenticatorInterface, AuthenticationFailureHandlerInterface
{
public const TOKEN_HEADER = 'api-token';
/** @var SerializerInterface */
private $serializer;
public function __construct(SerializerInterface $serializer)
{
$this->serializer = $serializer;
}
/** Takes request data and creates token which will be ready to auth check */
public function createToken(Request $request, $providerKey)
{
if (!($tokenKey = $request->headers->get(self::TOKEN_HEADER))) {
// Throwing exception here will break anonymous authentication for login method
//throw new BadCredentialsException(sprintf('\'%s\' is invalid or not defined', self::TOKEN_HEADER));
return null;
}
return new PreAuthenticatedToken(
'anon.',
$tokenKey,
$providerKey
);
}
public function authenticateToken(TokenInterface $token, UserProviderInterface $userProvider, $providerKey)
{
if (!$userProvider instanceof ApiTokenUserProvider) {
throw new \InvalidArgumentException(sprintf(
'The user provider for providerKey = \'%s\' must be an instance of %s, %s given.',
$providerKey,
ApiTokenUserProvider::class,
get_class($userProvider)
));
}
$apiTokenKey = $token->getCredentials();
$user = $userProvider->loadUserByUsername($apiTokenKey);
if (!$user) {
throw new CustomUserMessageAuthenticationException(sprintf(
'API token \'%s\' does not exist.', $apiTokenKey
));
}
return new AuthenticatedApiToken(
$user,
$apiTokenKey,
$providerKey,
$user->getRoles()
);
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): JsonResponse
{
// @todo Decouple with App\Api\V1\DTO
$json = $this->serializer->serialize(
new ApiResponse(null, JsonResponse::HTTP_UNAUTHORIZED, $exception->getMessage()),
'json',
['groups' => ['api_v1']]
);
return new JsonResponse($json, JsonResponse::HTTP_UNAUTHORIZED,[], true);
}
public function supportsToken(TokenInterface $token, $providerKey)
{
return $token instanceof PreAuthenticatedToken && $token->getProviderKey() === $providerKey;
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Security;
use App\Entity\User;
use App\Repository\ApiTokenRepository;
use Symfony\Component\Security\Core\Exception\{UnsupportedUserException, UsernameNotFoundException};
use Symfony\Component\Security\Core\User\{UserInterface, UserProviderInterface};
class ApiTokenUserProvider implements UserProviderInterface
{
/** @var ApiTokenRepository */
private $userRepo;
public function __construct(ApiTokenRepository $userRepo)
{
$this->userRepo = $userRepo;
}
public function loadUserByUsername($username): User
{
if (null === $user = $this->userRepo->findUserByTokenKey($username)) {
throw new UsernameNotFoundException(sprintf('Token \'%s\' is not found.', $username));
}
return $user;
}
public function refreshUser(UserInterface $user)
{
throw new UnsupportedUserException();
}
public function supportsClass($class): bool
{
return User::class === $class;
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Security\Token;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken;
/** This token stores ApiToken key even after eraseCredentials() called */
class AuthenticatedApiToken extends PreAuthenticatedToken
{
/** @var string|null This token is stored only for this request and will not be erased by eraseCredentials() or serialized */
private $tokenKey;
public function __construct(User $user, string $credentials, string $providerKey, array $roles = [])
{
parent::__construct($user, $credentials, $providerKey, $roles);
// @todo probably separate constructor argument needed
$this->tokenKey = $credentials;
}
public function getTokenKey(): ?string
{
return $this->tokenKey;
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\User\Exception;
class InvalidInviteException extends \Exception
{
protected $message = 'Invalid invite';
}

57
src/User/UserManager.php Normal file
View File

@ -0,0 +1,57 @@
<?php
namespace App\User;
use App\Entity\{Invite, User};
use App\Repository\{InviteRepository, UserRepository};
use App\User\Exception\InvalidInviteException;
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
class UserManager
{
private const DEFAULT_ROLES = ['ROLE_USER'];
/** @var UserRepository */
private $userRepo;
/** @var InviteRepository */
private $inviteRepo;
/** @var EncoderFactoryInterface */
private $encoderFactory;
public function __construct(EncoderFactoryInterface $encoderFactory, UserRepository $userRepo, InviteRepository $inviteRepo)
{
$this->userRepo = $userRepo;
$this->inviteRepo = $inviteRepo;
$this->encoderFactory = $encoderFactory;
}
public function createUser(string $username, string $password, string $email, array $roles = self::DEFAULT_ROLES): User
{
$encodedPassword = $this->encoderFactory->getEncoder(User::class)->encodePassword($password, null);
$user = new User(
$username,
$encodedPassword,
$email,
$roles
);
return $user;
}
public function createUserByInviteCode(string $username, string $password, string $email, string $inviteCode, array $roles = self::DEFAULT_ROLES): User
{
/** @var Invite $invite */
if (null === $invite = $this->inviteRepo->findOneBy(['code' => $inviteCode, 'usedBy' => null])) {
throw new InvalidInviteException();
}
$user = $this->createUser($username, $password, $email,$roles);
$invite->use($user);
return $user;
}
}

View File

@ -146,6 +146,9 @@
"ref": "e921bdbfe20cdefa3b82f379d1cd36df1bc8d115"
}
},
"symfony/form": {
"version": "v4.1.0"
},
"symfony/framework-bundle": {
"version": "3.3",
"recipe": {
@ -164,6 +167,9 @@
"symfony/inflector": {
"version": "v4.1.0"
},
"symfony/intl": {
"version": "v4.1.0"
},
"symfony/lts": {
"version": "4-dev"
},
@ -179,9 +185,15 @@
"ref": "371d1a2b69984710646b09a1182ef1d4308c904f"
}
},
"symfony/options-resolver": {
"version": "v4.1.0"
},
"symfony/orm-pack": {
"version": "v1.0.5"
},
"symfony/polyfill-intl-icu": {
"version": "v1.8.0"
},
"symfony/polyfill-mbstring": {
"version": "v1.8.0"
},
@ -206,6 +218,18 @@
"ref": "cda8b550123383d25827705d05a42acf6819fe4e"
}
},
"symfony/security": {
"version": "v4.1.0"
},
"symfony/security-bundle": {
"version": "3.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "master",
"version": "3.3",
"ref": "f8a63faa0d9521526499c0a8f403c9964ecb0527"
}
},
"symfony/serializer": {
"version": "v4.1.0"
},
@ -233,6 +257,9 @@
"ref": "f75ac166398e107796ca94cc57fa1edaa06ec47f"
}
},
"symfony/validator": {
"version": "v4.1.0"
},
"symfony/var-dumper": {
"version": "v4.1.0"
},

View File

@ -0,0 +1,7 @@
{% extends 'base.html.twig' %}
{% block content %}
<div id="form-login">
{{ form(form) }}
</div>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends 'base.html.twig' %}
{% block content %}
<div id="form-register">
{{ form(form) }}
</div>
{% endblock %}

View File

@ -26,16 +26,25 @@
</button>
<div class="collapse navbar-collapse" id="navbarsExampleDefault">
<ul class="navbar-nav mr-auto">
<!--<li class="nav-item">
<a class="nav-link" href="#">Item</a>
</li>-->
</ul>
{% if is_granted('IS_AUTHENTICATED_REMEMBERED') %}
<ul class="navbar-nav mr-auto">
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" role="button" data-toggle="dropdown">
{{ app.user.username }}
</a>
<div class="dropdown-menu">
<a href="{{ path('user_logout') }}" class="dropdown-item">Logout</a>
</div>
</li>
</ul>
{% endif %}
{% if is_granted('ROLE_USER') %}
<form class="form-inline my-2 my-lg-0" action="{{ path('torrents_search') }}" method="get">
<input name="query" class="form-control mr-sm-2" type="text" placeholder="Search" aria-label="Search"
value="{% if searchQuery is defined %}{{ searchQuery }}{% endif %}">
<button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button>
</form>
{% endif %}
</div>
</nav>

View File

@ -2,6 +2,8 @@
{% block content %}
<div class="well">
<p>Torrents indexed: {{ torrentsCount }}</p>
{% if not is_granted('ROLE_USER') %}
<a href="{{ path('user_login') }}" class="btn btn-lg btn-primary">Login</a>
{% endif %}
</div>
{% endblock %}

View File

@ -13,7 +13,7 @@
<th scope="col">Discovered</th>
</tr>
</thead>
{# @var torrent \App\Entity\Torrent #}
{# @var torrent \App\Magnetico\Entity\Torrent #}
{% for torrent in torrents %}
<tr>
<td><a href="{{ magnet(torrent.name, torrent.infoHash) }}">&#128279;</a></td>

View File

@ -1,7 +1,7 @@
{% extends 'base.html.twig' %}
{% block content %}
{# @var torrent \App\Entity\Torrent #}
{# @var torrent \App\Magnetico\Entity\Torrent #}
<table class="table">
<tr>
<td>Name</td>
@ -31,7 +31,7 @@
</tr>
</thead>
<tbody>
{# @var file \App\Entity\File #}
{# @var file \App\Magnetico\Entity\File #}
{% for file in torrent.files | sort %}
<tr>
<td>{{ file.path }}</td>

Binary file not shown.