From 1f9209e2153d6a48f6d4012d1bc44d758d385522 Mon Sep 17 00:00:00 2001 From: babak alizadeh Date: Thu, 26 Jan 2023 11:13:53 -0500 Subject: [PATCH] progress --- .env | 4 + .idea/hesabixCore.iml | 5 + .idea/php.xml | 7 ++ composer.json | 6 +- composer.lock | 112 +++++++++++++++++- config/bundles.php | 2 + config/packages/nelmio_cors.yaml | 10 ++ config/packages/security.yaml | 9 +- public/index.php | 2 +- src/Controller/GeneralController.php | 8 +- src/Controller/UserController.php | 135 ++++++++++++++++++++++ src/Entity/Business.php | 36 ++++++ src/Entity/User.php | 104 +++++++++++++++++ src/Entity/UserToken.php | 51 ++++++++ src/Repository/BusinessRepository.php | 66 +++++++++++ src/Repository/UserTokenRepository.php | 66 +++++++++++ src/Security/AccessDeniedHandler.php | 24 ++++ src/Security/ApiKeyAuthenticator.php | 81 +++++++++++++ src/Security/AuthenticationEntryPoint.php | 24 ++++ symfony.lock | 15 +++ 20 files changed, 760 insertions(+), 7 deletions(-) create mode 100644 config/packages/nelmio_cors.yaml create mode 100644 src/Controller/UserController.php create mode 100644 src/Entity/Business.php create mode 100644 src/Entity/UserToken.php create mode 100644 src/Repository/BusinessRepository.php create mode 100644 src/Repository/UserTokenRepository.php create mode 100644 src/Security/AccessDeniedHandler.php create mode 100644 src/Security/ApiKeyAuthenticator.php create mode 100644 src/Security/AuthenticationEntryPoint.php diff --git a/.env b/.env index 15be5ca..dbe739b 100644 --- a/.env +++ b/.env @@ -27,3 +27,7 @@ APP_SECRET=17902f251c557579ee832f20ed66776b DATABASE_URL="mysql://root:136431@127.0.0.1:3306/hsx?serverVersion=8&charset=utf8mb4" # DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=15&charset=utf8" ###< doctrine/doctrine-bundle ### + +###> nelmio/cors-bundle ### +CORS_ALLOW_ORIGIN='*' +###< nelmio/cors-bundle ### diff --git a/.idea/hesabixCore.iml b/.idea/hesabixCore.iml index 50c12ef..1e4a1d0 100644 --- a/.idea/hesabixCore.iml +++ b/.idea/hesabixCore.iml @@ -2,6 +2,9 @@ + + + @@ -31,6 +34,8 @@ + + diff --git a/.idea/php.xml b/.idea/php.xml index f7e5fb0..83872f4 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -61,9 +61,16 @@ + + + + + + + \ No newline at end of file diff --git a/composer.json b/composer.json index 7d5ab73..4911bd9 100644 --- a/composer.json +++ b/composer.json @@ -10,13 +10,15 @@ "doctrine/doctrine-bundle": "^2.8", "doctrine/doctrine-migrations-bundle": "^3.2", "doctrine/orm": "^2.14", + "nelmio/cors-bundle": "^2.2", "symfony/console": "6.2.*", "symfony/dotenv": "6.2.*", "symfony/flex": "^2", "symfony/framework-bundle": "6.2.*", "symfony/runtime": "6.2.*", "symfony/security-bundle": "6.2.*", - "symfony/yaml": "6.2.*" + "symfony/yaml": "6.2.*", + "symfonycasts/verify-email-bundle": "^1.13" }, "config": { "allow-plugins": { @@ -61,7 +63,7 @@ }, "extra": { "symfony": { - "allow-contrib": false, + "allow-contrib": true, "require": "6.2.*", "docker": true } diff --git a/composer.lock b/composer.lock index b537c06..6a50d4b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "954f3c9bd813741915237122a725f96e", + "content-hash": "b769efde9eff86f833f47b14e5c80c52", "packages": [ { "name": "doctrine/cache", @@ -1458,6 +1458,67 @@ ], "time": "2022-12-08T02:08:23+00:00" }, + { + "name": "nelmio/cors-bundle", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/nelmio/NelmioCorsBundle.git", + "reference": "0ee5ee30b0ee08ea122d431ae6e0ddeb87f035c0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nelmio/NelmioCorsBundle/zipball/0ee5ee30b0ee08ea122d431ae6e0ddeb87f035c0", + "reference": "0ee5ee30b0ee08ea122d431ae6e0ddeb87f035c0", + "shasum": "" + }, + "require": { + "symfony/framework-bundle": "^4.3 || ^5.0 || ^6.0" + }, + "require-dev": { + "mockery/mockery": "^1.2", + "symfony/phpunit-bridge": "^4.3 || ^5.0 || ^6.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Nelmio\\CorsBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nelmio", + "homepage": "http://nelm.io" + }, + { + "name": "Symfony Community", + "homepage": "https://github.com/nelmio/NelmioCorsBundle/contributors" + } + ], + "description": "Adds CORS (Cross-Origin Resource Sharing) headers support in your Symfony application", + "keywords": [ + "api", + "cors", + "crossdomain" + ], + "support": { + "issues": "https://github.com/nelmio/NelmioCorsBundle/issues", + "source": "https://github.com/nelmio/NelmioCorsBundle/tree/2.2.0" + }, + "time": "2021-12-01T09:34:27+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -4584,6 +4645,53 @@ } ], "time": "2022-12-14T16:11:27+00:00" + }, + { + "name": "symfonycasts/verify-email-bundle", + "version": "v1.13.0", + "source": { + "type": "git", + "url": "https://github.com/SymfonyCasts/verify-email-bundle.git", + "reference": "eb7bc997f36ad872a0d56bf209fe37fed148b0a7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SymfonyCasts/verify-email-bundle/zipball/eb7bc997f36ad872a0d56bf209fe37fed148b0a7", + "reference": "eb7bc997f36ad872a0d56bf209fe37fed148b0a7", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=7.2.5", + "symfony/config": "^5.4 | ^6.0", + "symfony/dependency-injection": "^5.4 | ^6.0", + "symfony/deprecation-contracts": "^2.2 | ^3.0", + "symfony/http-kernel": "^5.4 | ^6.0", + "symfony/routing": "^5.4 | ^6.0" + }, + "require-dev": { + "doctrine/orm": "^2.7", + "doctrine/persistence": "^2.0", + "symfony/framework-bundle": "^5.4 | ^6.0", + "symfony/phpunit-bridge": "^5.4 | ^6.0", + "vimeo/psalm": "^4.3" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "SymfonyCasts\\Bundle\\VerifyEmail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Simple, stylish Email Verification for Symfony", + "support": { + "issues": "https://github.com/SymfonyCasts/verify-email-bundle/issues", + "source": "https://github.com/SymfonyCasts/verify-email-bundle/tree/v1.13.0" + }, + "time": "2023-01-04T12:46:15+00:00" } ], "packages-dev": [ @@ -4748,5 +4856,5 @@ "ext-iconv": "*" }, "platform-dev": [], - "plugin-api-version": "2.2.0" + "plugin-api-version": "2.3.0" } diff --git a/config/bundles.php b/config/bundles.php index 55f0e7c..c86955c 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -6,4 +6,6 @@ return [ Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], + SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::class => ['all' => true], + Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], ]; diff --git a/config/packages/nelmio_cors.yaml b/config/packages/nelmio_cors.yaml new file mode 100644 index 0000000..c766508 --- /dev/null +++ b/config/packages/nelmio_cors.yaml @@ -0,0 +1,10 @@ +nelmio_cors: + defaults: + origin_regex: true + allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] + allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] + allow_headers: ['Content-Type', 'Authorization'] + expose_headers: ['Link'] + max_age: 3600 + paths: + '^/': null diff --git a/config/packages/security.yaml b/config/packages/security.yaml index fbfb8ed..5844f17 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -16,7 +16,14 @@ security: main: lazy: true provider: app_user_provider + json_login: + # api_login is a route we will create below + check_path: api_login + username_path: email + password_path: password + custom_authenticators: + - App\Security\ApiKeyAuthenticator # activate different ways to authenticate # https://symfony.com/doc/current/security.html#the-firewall @@ -27,7 +34,7 @@ security: # Note: Only the *first* access control that matches will be used access_control: # - { path: ^/admin, roles: ROLE_ADMIN } - # - { path: ^/profile, roles: ROLE_USER } + - { path: ^/api/acc/*, roles: ROLE_USER } when@test: security: diff --git a/public/index.php b/public/index.php index 9982c21..4c1ee8a 100644 --- a/public/index.php +++ b/public/index.php @@ -1,7 +1,7 @@ json([ + 'message' => 'Welcome to hesabix API.', + ]); + } + #[Route('api/acc/dd', name: 'acc_dd')] + public function acc_dd(): JsonResponse + { return $this->json([ 'message' => 'Welcome to hesabix API.', ]); diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php new file mode 100644 index 0000000..f443832 --- /dev/null +++ b/src/Controller/UserController.php @@ -0,0 +1,135 @@ +json([ + 'message' => 'missing credentials', + ], Response::HTTP_UNAUTHORIZED); + } + + $tokenString = $this->RandomString(254); // somehow create an API token for $user + $token = new UserToken(); + $token->setUser($user); + $token->setToken($tokenString); + $entityManager->persist($token); + $entityManager->flush(); + return $this->json([ + 'user' => $user->getUserIdentifier(), + 'token' => $tokenString, + ]); + } + + #[Route('/api/user/check/login', name: 'api_user_check_login')] + public function api_user_check_login(#[CurrentUser] ?User $user,EntityManagerInterface $entityManager): Response + { + if (null === $user) { + return $this->json([ + 'message' => 'missing credentials', + ], Response::HTTP_UNAUTHORIZED); + } + return $this->json( + ['result'=>true] + ); + } + + #[Route('/api/user/current/info', name: 'api_user_current_info')] + public function api_user_current_info(#[CurrentUser] ?User $user,EntityManagerInterface $entityManager): Response + { + return $this->json([ + 'email'=>$user->getEmail(), + 'fullname'=>$user->getFullName(), + 'businessCount'=>count($user->getBusinesses()) + ]); + } + + + #[Route('/api/user/logout', name: 'api_user_logout')] + public function api_user_logout(Security $security,EntityManagerInterface $entityManager,Request $request): Response + { + // logout the user in on the current firewall + $security->logout(false); + $apiToken = $request->headers->get('X-AUTH-TOKEN'); + + if (null == $apiToken) { + // The token header was empty, authentication fails with HTTP Status + // Code 401 "Unauthorized" + throw new CustomUserMessageAuthenticationException('No API token provided'); + } + $tk = $entityManager->getRepository(UserToken::class)->findByApiToken($apiToken); + if (! $tk) { + throw new UserNotFoundException(); + } + $entityManager->getRepository(UserToken::class)->remove($tk,true); + return $this->json(['result'=>true]); + } + + + #[Route('/api/user/update/info', name: 'api_user_update_info')] + public function api_user_update_info(#[CurrentUser] ?User $user,EntityManagerInterface $entityManager,Request $request): Response + { + $pameters = []; + if ($content = $request->getContent()) { + $pameters = json_decode($content, true); + } + $user->setFullName($pameters['fullname']); + $entityManager->persist($user); + $entityManager->flush(); + return $this->json(['result'=>true]); + } + + + #[Route('/api/user/register', name: 'api_user_register')] + public function api_user_register(Request $request, UserPasswordHasherInterface $userPasswordHasher, EntityManagerInterface $entityManager): Response + { + $user = new User(); + $user->setEmail('alizadeh.babak@gmail.com'); + $user->setRoles([]); + $user->setPassword( + $userPasswordHasher->hashPassword( + $user, + '123456' + ) + ); + $entityManager->persist($user); + $entityManager->flush(); + + return $this->json(['ok']); + } +} diff --git a/src/Entity/Business.php b/src/Entity/Business.php new file mode 100644 index 0000000..69655ea --- /dev/null +++ b/src/Entity/Business.php @@ -0,0 +1,36 @@ +id; + } + + public function getOwner(): ?User + { + return $this->owner; + } + + public function setOwner(?User $owner): self + { + $this->owner = $owner; + + return $this; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 5d4045b..966abd1 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -3,6 +3,8 @@ namespace App\Entity; use App\Repository\UserRepository; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; @@ -27,6 +29,24 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column] private ?string $password = null; + #[ORM\OneToMany(mappedBy: 'user', targetEntity: UserToken::class, orphanRemoval: true)] + private Collection $userTokens; + + #[ORM\Column(length: 255)] + private ?string $fullName = null; + + #[ORM\Column(length: 50)] + private ?string $dateRegister = null; + + #[ORM\OneToMany(mappedBy: 'owner', targetEntity: Business::class, orphanRemoval: true)] + private Collection $businesses; + + public function __construct() + { + $this->userTokens = new ArrayCollection(); + $this->businesses = new ArrayCollection(); + } + public function getId(): ?int { return $this->id; @@ -96,4 +116,88 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface // If you store any temporary, sensitive data on the user, clear it here // $this->plainPassword = null; } + + /** + * @return Collection + */ + public function getUserTokens(): Collection + { + return $this->userTokens; + } + + public function addUserToken(UserToken $userToken): self + { + if (!$this->userTokens->contains($userToken)) { + $this->userTokens->add($userToken); + $userToken->setUser($this); + } + + return $this; + } + + public function removeUserToken(UserToken $userToken): self + { + if ($this->userTokens->removeElement($userToken)) { + // set the owning side to null (unless already changed) + if ($userToken->getUser() === $this) { + $userToken->setUser(null); + } + } + + return $this; + } + + public function getFullName(): ?string + { + return $this->fullName; + } + + public function setFullName(string $fullName): self + { + $this->fullName = $fullName; + + return $this; + } + + public function getDateRegister(): ?string + { + return $this->dateRegister; + } + + public function setDateRegister(string $dateRegister): self + { + $this->dateRegister = $dateRegister; + + return $this; + } + + /** + * @return Collection + */ + public function getBusinesses(): Collection + { + return $this->businesses; + } + + public function addBusiness(Business $business): self + { + if (!$this->businesses->contains($business)) { + $this->businesses->add($business); + $business->setOwner($this); + } + + return $this; + } + + public function removeBusiness(Business $business): self + { + if ($this->businesses->removeElement($business)) { + // set the owning side to null (unless already changed) + if ($business->getOwner() === $this) { + $business->setOwner(null); + } + } + + return $this; + } } diff --git a/src/Entity/UserToken.php b/src/Entity/UserToken.php new file mode 100644 index 0000000..33b321c --- /dev/null +++ b/src/Entity/UserToken.php @@ -0,0 +1,51 @@ +id; + } + + public function getToken(): ?string + { + return $this->token; + } + + public function setToken(string $token): self + { + $this->token = $token; + + return $this; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): self + { + $this->user = $user; + + return $this; + } +} diff --git a/src/Repository/BusinessRepository.php b/src/Repository/BusinessRepository.php new file mode 100644 index 0000000..664e9de --- /dev/null +++ b/src/Repository/BusinessRepository.php @@ -0,0 +1,66 @@ + + * + * @method Business|null find($id, $lockMode = null, $lockVersion = null) + * @method Business|null findOneBy(array $criteria, array $orderBy = null) + * @method Business[] findAll() + * @method Business[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class BusinessRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Business::class); + } + + public function save(Business $entity, bool $flush = false): void + { + $this->getEntityManager()->persist($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(Business $entity, bool $flush = false): void + { + $this->getEntityManager()->remove($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + +// /** +// * @return Business[] Returns an array of Business objects +// */ +// public function findByExampleField($value): array +// { +// return $this->createQueryBuilder('b') +// ->andWhere('b.exampleField = :val') +// ->setParameter('val', $value) +// ->orderBy('b.id', 'ASC') +// ->setMaxResults(10) +// ->getQuery() +// ->getResult() +// ; +// } + +// public function findOneBySomeField($value): ?Business +// { +// return $this->createQueryBuilder('b') +// ->andWhere('b.exampleField = :val') +// ->setParameter('val', $value) +// ->getQuery() +// ->getOneOrNullResult() +// ; +// } +} diff --git a/src/Repository/UserTokenRepository.php b/src/Repository/UserTokenRepository.php new file mode 100644 index 0000000..80d8f2c --- /dev/null +++ b/src/Repository/UserTokenRepository.php @@ -0,0 +1,66 @@ + + * + * @method UserToken|null find($id, $lockMode = null, $lockVersion = null) + * @method UserToken|null findOneBy(array $criteria, array $orderBy = null) + * @method UserToken[] findAll() + * @method UserToken[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class UserTokenRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, UserToken::class); + } + + public function save(UserToken $entity, bool $flush = false): void + { + $this->getEntityManager()->persist($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(UserToken $entity, bool $flush = false): void + { + $this->getEntityManager()->remove($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + /** + * @throws \Doctrine\ORM\NonUniqueResultException + */ + public function findByApiToken($value): UserToken | null + { + return $this->createQueryBuilder('u') + ->andWhere('u.token = :val') + ->setParameter('val', $value) + ->orderBy('u.id', 'ASC') + ->setMaxResults(10) + ->getQuery() + ->getOneOrNullResult() + ; + } + +// public function findOneBySomeField($value): ?UserToken +// { +// return $this->createQueryBuilder('u') +// ->andWhere('u.exampleField = :val') +// ->setParameter('val', $value) +// ->getQuery() +// ->getOneOrNullResult() +// ; +// } +} diff --git a/src/Security/AccessDeniedHandler.php b/src/Security/AccessDeniedHandler.php new file mode 100644 index 0000000..6060321 --- /dev/null +++ b/src/Security/AccessDeniedHandler.php @@ -0,0 +1,24 @@ + [ + 'code' => $accessDeniedException->getCode(), + 'message' => $accessDeniedException->getMessage(), + ], + ], $accessDeniedException->getCode()); + } +} \ No newline at end of file diff --git a/src/Security/ApiKeyAuthenticator.php b/src/Security/ApiKeyAuthenticator.php new file mode 100644 index 0000000..1e51a7b --- /dev/null +++ b/src/Security/ApiKeyAuthenticator.php @@ -0,0 +1,81 @@ +userTokenRepository = $userRepository; + } + /** + * Called on every request to decide if this authenticator should be + * used for the request. Returning `false` will cause this authenticator + * to be skipped. + */ + public function supports(Request $request): ?bool + { + return $request->headers->has('X-AUTH-TOKEN'); + } + + public function authenticate(Request $request): Passport + { + $apiToken = $request->headers->get('X-AUTH-TOKEN'); + + if (null == $apiToken) { + // The token header was empty, authentication fails with HTTP Status + // Code 401 "Unauthorized" + //throw new CustomUserMessageAuthenticationException('No API token provided'); + } + + + return new SelfValidatingPassport( + new UserBadge($apiToken, function($apiToken) { + $tk = $this->userTokenRepository->findByApiToken($apiToken); + if (! $tk) { + throw new UserNotFoundException(); + } + return $tk->getUser(); + }) + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + // on success, let the request continue + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $data = [ + // you may want to customize or obfuscate the message first + 'message' => strtr($exception->getMessageKey(), $exception->getMessageData()) + + // or to translate this message + // $this->translator->trans($exception->getMessageKey(), $exception->getMessageData()) + ]; + + return new JsonResponse($data, Response::HTTP_UNAUTHORIZED); + } +} \ No newline at end of file diff --git a/src/Security/AuthenticationEntryPoint.php b/src/Security/AuthenticationEntryPoint.php new file mode 100644 index 0000000..f8f1f35 --- /dev/null +++ b/src/Security/AuthenticationEntryPoint.php @@ -0,0 +1,24 @@ + [ + 'code' => Response::HTTP_UNAUTHORIZED, + 'message' => $authException?->getMessage() ?? Response::$statusTexts[Response::HTTP_UNAUTHORIZED], + ], + ], Response::HTTP_UNAUTHORIZED); + } +} \ No newline at end of file diff --git a/symfony.lock b/symfony.lock index 9ccba15..ad9e6d8 100644 --- a/symfony.lock +++ b/symfony.lock @@ -26,6 +26,18 @@ "migrations/.gitignore" ] }, + "nelmio/cors-bundle": { + "version": "2.2", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.5", + "ref": "6bea22e6c564fba3a1391615cada1437d0bde39c" + }, + "files": [ + "config/packages/nelmio_cors.yaml" + ] + }, "symfony/console": { "version": "6.2", "recipe": { @@ -102,5 +114,8 @@ "files": [ "config/packages/security.yaml" ] + }, + "symfonycasts/verify-email-bundle": { + "version": "v1.13.0" } }