diff --git a/.env b/.env index 50f0129f..d1f78e83 100644 --- a/.env +++ b/.env @@ -54,3 +54,13 @@ MAILER_DELIVERY_ADDRESS="admin@example.org" # DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=14&charset=utf8" DATABASE_URL="mysql://mail:password@127.0.0.1:3306/mail?charset=utf8mb4" ###< doctrine/doctrine-bundle ### + +###> lexik/jwt-authentication-bundle ### +JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem +JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem +JWT_PASSPHRASE=857ce6bfb8d058ca8f51421d40864305d96be4be4a30d75e843b2d13958e6fde +###< lexik/jwt-authentication-bundle ### + +###> nelmio/cors-bundle ### +CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' +###< nelmio/cors-bundle ### diff --git a/.gitignore b/.gitignore index ebd16754..d8e13a54 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,7 @@ translations/ ###> phpstan/phpstan ### phpstan.neon ###< phpstan/phpstan ### + +###> lexik/jwt-authentication-bundle ### +/config/jwt/*.pem +###< lexik/jwt-authentication-bundle ### diff --git a/composer.json b/composer.json index cd991b86..6e660bf5 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "endroid/qr-code": "^5.0", "ircmaxell/password-compat": "~1.0.3", "knplabs/knp-menu-bundle": "^3.0", + "lexik/jwt-authentication-bundle": "^2.20", "mopa/bootstrap-bundle": "~3.0", "nelmio/security-bundle": "^3.0", "pear/crypt_gpg": "^1.6", diff --git a/composer.lock b/composer.lock index 6b2c7f77..1a383c16 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": "07e591e20b64786dbf717457f756a368", + "content-hash": "c9706f16061b30b51ae159d4e070268e", "packages": [ { "name": "bacon/bacon-qr-code", @@ -2102,6 +2102,262 @@ }, "time": "2023-11-01T09:25:40+00:00" }, + { + "name": "lcobucci/clock", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/clock.git", + "reference": "6f28b826ea01306b07980cb8320ab30b966cd715" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/clock/zipball/6f28b826ea01306b07980cb8320ab30b966cd715", + "reference": "6f28b826ea01306b07980cb8320ab30b966cd715", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "infection/infection": "^0.27", + "lcobucci/coding-standard": "^11.0.0", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^1.10.25", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.13", + "phpstan/phpstan-strict-rules": "^1.5.1", + "phpunit/phpunit": "^10.2.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\Clock\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com" + } + ], + "description": "Yet another clock abstraction", + "support": { + "issues": "https://github.com/lcobucci/clock/issues", + "source": "https://github.com/lcobucci/clock/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2023-11-17T17:00:27+00:00" + }, + { + "name": "lcobucci/jwt", + "version": "5.2.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "0ba88aed12c04bd2ed9924f500673f32b67a6211" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/0ba88aed12c04bd2ed9924f500673f32b67a6211", + "reference": "0ba88aed12c04bd2ed9924f500673f32b67a6211", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.27.0", + "lcobucci/clock": "^3.0", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2.9", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^10.2.6" + }, + "suggest": { + "lcobucci/clock": ">= 3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.2.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2023-11-20T21:17:42+00:00" + }, + { + "name": "lexik/jwt-authentication-bundle", + "version": "v2.20.3", + "source": { + "type": "git", + "url": "https://github.com/lexik/LexikJWTAuthenticationBundle.git", + "reference": "a196d68d07dd5486a523cc3415620badbb5d25c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lexik/LexikJWTAuthenticationBundle/zipball/a196d68d07dd5486a523cc3415620badbb5d25c2", + "reference": "a196d68d07dd5486a523cc3415620badbb5d25c2", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "lcobucci/clock": "^1.2|^2.0|^3.0", + "lcobucci/jwt": "^3.4|^4.1|^5.0", + "namshi/jose": "^7.2", + "php": ">=7.1", + "symfony/config": "^4.4|^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^4.4|^5.4|^6.0|^7.0", + "symfony/deprecation-contracts": "^2.4|^3.0", + "symfony/event-dispatcher": "^4.4|^5.4|^6.0|^7.0", + "symfony/http-foundation": "^4.4|^5.4|^6.0|^7.0", + "symfony/http-kernel": "^4.4|^5.4|^6.0|^7.0", + "symfony/property-access": "^4.4|^5.4|^6.0|^7.0", + "symfony/security-bundle": "^4.4|^5.4|^6.0|^7.0", + "symfony/translation-contracts": "^1.0|^2.0|^3.0" + }, + "conflict": { + "symfony/console": "<4.4" + }, + "require-dev": { + "symfony/browser-kit": "^5.4|^6.0|^7.0", + "symfony/console": "^4.4|^5.4|^6.0|^7.0", + "symfony/dom-crawler": "^5.4|^6.0|^7.0", + "symfony/filesystem": "^4.4|^5.4|^6.0|^7.0", + "symfony/framework-bundle": "^4.4|^5.4|^6.0|^7.0", + "symfony/phpunit-bridge": "^4.4|^5.4|^6.0|^7.0", + "symfony/security-guard": "^4.4|^5.4|^6.0|^7.0", + "symfony/var-dumper": "^4.4|^5.4|^6.0|^7.0", + "symfony/yaml": "^4.4|^5.4|^6.0|^7.0" + }, + "suggest": { + "gesdinet/jwt-refresh-token-bundle": "Implements a refresh token system over Json Web Tokens in Symfony", + "spomky-labs/lexik-jose-bridge": "Provides a JWT Token encoder with encryption support" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Lexik\\Bundle\\JWTAuthenticationBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeremy Barthe", + "email": "j.barthe@lexik.fr", + "homepage": "https://github.com/jeremyb" + }, + { + "name": "Nicolas Cabot", + "email": "n.cabot@lexik.fr", + "homepage": "https://github.com/slashfan" + }, + { + "name": "Cedric Girard", + "email": "c.girard@lexik.fr", + "homepage": "https://github.com/cedric-g" + }, + { + "name": "Dev Lexik", + "email": "dev@lexik.fr", + "homepage": "https://github.com/lexik" + }, + { + "name": "Robin Chalas", + "email": "robin.chalas@gmail.com", + "homepage": "https://github.com/chalasr" + }, + { + "name": "Lexik Community", + "homepage": "https://github.com/lexik/LexikJWTAuthenticationBundle/graphs/contributors" + } + ], + "description": "This bundle provides JWT authentication for your Symfony REST API", + "homepage": "https://github.com/lexik/LexikJWTAuthenticationBundle", + "keywords": [ + "Authentication", + "JWS", + "api", + "bundle", + "jwt", + "rest", + "symfony" + ], + "support": { + "issues": "https://github.com/lexik/LexikJWTAuthenticationBundle/issues", + "source": "https://github.com/lexik/LexikJWTAuthenticationBundle/tree/v2.20.3" + }, + "funding": [ + { + "url": "https://github.com/chalasr", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/lexik/jwt-authentication-bundle", + "type": "tidelift" + } + ], + "time": "2023-12-14T15:58:11+00:00" + }, { "name": "monolog/monolog", "version": "3.5.0", @@ -2335,6 +2591,73 @@ }, "time": "2022-12-29T11:23:59+00:00" }, + { + "name": "namshi/jose", + "version": "7.2.3", + "source": { + "type": "git", + "url": "https://github.com/namshi/jose.git", + "reference": "89a24d7eb3040e285dd5925fcad992378b82bcff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/namshi/jose/zipball/89a24d7eb3040e285dd5925fcad992378b82bcff", + "reference": "89a24d7eb3040e285dd5925fcad992378b82bcff", + "shasum": "" + }, + "require": { + "ext-date": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-pcre": "*", + "ext-spl": "*", + "php": ">=5.5", + "symfony/polyfill-php56": "^1.0" + }, + "require-dev": { + "phpseclib/phpseclib": "^2.0", + "phpunit/phpunit": "^4.5|^5.0", + "satooshi/php-coveralls": "^1.0" + }, + "suggest": { + "ext-openssl": "Allows to use OpenSSL as crypto engine.", + "phpseclib/phpseclib": "Allows to use Phpseclib as crypto engine, use version ^2.0." + }, + "type": "library", + "autoload": { + "psr-4": { + "Namshi\\JOSE\\": "src/Namshi/JOSE/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Nadalin", + "email": "alessandro.nadalin@gmail.com" + }, + { + "name": "Alessandro Cinelli (cirpo)", + "email": "alessandro.cinelli@gmail.com" + } + ], + "description": "JSON Object Signing and Encryption library for PHP.", + "keywords": [ + "JSON Web Signature", + "JSON Web Token", + "JWS", + "json", + "jwt", + "token" + ], + "support": { + "issues": "https://github.com/namshi/jose/issues", + "source": "https://github.com/namshi/jose/tree/master" + }, + "time": "2016-12-05T07:27:31+00:00" + }, { "name": "nelmio/security-bundle", "version": "v3.3.0", diff --git a/config/bundles.php b/config/bundles.php index bb507420..79937ed5 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -21,4 +21,5 @@ Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], FriendsOfBehat\SymfonyExtension\Bundle\FriendsOfBehatSymfonyExtensionBundle::class => ['test' => true], + Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], ]; diff --git a/config/packages/lexik_jwt_authentication.yaml b/config/packages/lexik_jwt_authentication.yaml new file mode 100644 index 00000000..66c59330 --- /dev/null +++ b/config/packages/lexik_jwt_authentication.yaml @@ -0,0 +1,14 @@ +lexik_jwt_authentication: + secret_key: "%env(resolve:JWT_SECRET_KEY)%" + public_key: "%env(resolve:JWT_PUBLIC_KEY)%" + pass_phrase: "%env(JWT_PASSPHRASE)%" + token_ttl: 3600 + token_extractors: + authorization_header: + enabled: true + prefix: Bearer + name: Authorization + +when@dev: + lexik_jwt_authentication: + token_ttl: 31536000 diff --git a/config/packages/security.yaml b/config/packages/security.yaml index c13f6f08..db6cab68 100755 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -104,6 +104,28 @@ security: dev: pattern: ^/(_(profiler|error|wdt)|css|images|js)/ security: false + api-login: + pattern: ^/api/user/login + stateless: false + json_login: + check_path: api_login + username_path: data.username + password_path: data.password + success_handler: App\Security\JWTAuthenticationSuccessHandler + failure_handler: lexik_jwt_authentication.handler.authentication_failure + two_factor: + check_path: api_login_2fa + prepare_on_login: true + prepare_on_access_denied: true + post_only: true + auth_code_parameter_name: data.totp + authentication_required_handler: App\Security\TwoFactorAuthenticationRequiredHandler + success_handler: App\Security\TwoFactorAuthenticationSuccessHandler + failure_handler: App\Security\TwoFactorAuthenticationFailureHandler + api: + pattern: ^/api/ + stateless: true + jwt: ~ main: pattern: ^/ provider: user @@ -132,16 +154,40 @@ security: # 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: "^/$", roles: PUBLIC_ACCESS } - - { path: "^/init", roles: PUBLIC_ACCESS } - - { path: "^/login", roles: PUBLIC_ACCESS } - - { path: "^/recovery", roles: PUBLIC_ACCESS } - - { path: "^/register", roles: PUBLIC_ACCESS } - - { path: "^/logout", roles: PUBLIC_ACCESS } - - { path: "^/2fa", roles: IS_AUTHENTICATED_2FA_IN_PROGRESS } - - { path: "^/start", roles: ROLE_USER } - - { path: "^/voucher", roles: ROLE_USER, allow_if: "!is_granted('ROLE_SUSPICIOUS')" } - - { path: "^/alias", roles: ROLE_USER, allow_if: "!is_granted('ROLE_SPAM')" } - - { path: "^/account", roles: ROLE_USER, allow_if: "!is_granted('ROLE_SPAM')" } - - { path: "^/openpgp", roles: ROLE_USER, allow_if: "!is_granted('ROLE_SPAM')" } - - { path: "^/admin", roles: ROLE_DOMAIN_ADMIN } + - { path: "^/$", roles: PUBLIC_ACCESS } + - { path: "^/init", roles: PUBLIC_ACCESS } + - { path: "^/login", roles: PUBLIC_ACCESS } + - { path: "^/recovery", roles: PUBLIC_ACCESS } + - { path: "^/register", roles: PUBLIC_ACCESS } + - { path: "^/logout", roles: PUBLIC_ACCESS } + - { path: "^/2fa", roles: IS_AUTHENTICATED_2FA_IN_PROGRESS } + - { path: "^/start", roles: ROLE_USER } + - { + path: "^/voucher", + roles: ROLE_USER, + allow_if: "!is_granted('ROLE_SUSPICIOUS')", + } + - { + path: "^/alias", + roles: ROLE_USER, + allow_if: "!is_granted('ROLE_SPAM')", + } + - { + path: "^/account", + roles: ROLE_USER, + allow_if: "!is_granted('ROLE_SPAM')", + } + - { + path: "^/openpgp", + roles: ROLE_USER, + allow_if: "!is_granted('ROLE_SPAM')", + } + - { path: "^/admin", roles: ROLE_DOMAIN_ADMIN } + - { path: "^/api/user/login", roles: PUBLIC_ACCESS } + - { + path: "^/api/user/login/2fa", + roles: IS_AUTHENTICATED_2FA_IN_PROGRESS, + } + - { path: "^/api/user/register", roles: PUBLIC_ACCESS } + - { path: "^/api/user/recovery", roles: PUBLIC_ACCESS } + - { path: "^/api/user/", roles: ROLE_USER } diff --git a/src/Controller/Api/AliasController.php b/src/Controller/Api/AliasController.php new file mode 100644 index 00000000..ab162056 --- /dev/null +++ b/src/Controller/Api/AliasController.php @@ -0,0 +1,115 @@ +aliasRepository = $manager->getRepository(Alias::class); + $this->validator = $validator; + } + + #[Route('/api/user/aliases', name: 'get_user_aliases', methods: ['GET'], stateless: true)] + public function getAliases( + #[CurrentUser] User $user + ): JsonResponse { + $customAliasData = []; + if ($customAliases = $this->aliasRepository->findByUser($user, false, false)) { + $customAliasData = array_map(function (Alias $alias) { + return [ + 'id' => $alias->getId(), + 'source' => $alias->getSource() + ]; + }, $customAliases); + } + $randomAliasData = []; + if ($randomAliases = $this->aliasRepository->findByUser($user, true, false)) { + $randomAliasData = array_map(function (Alias $alias) { + return [ + 'id' => $alias->getId(), + 'source' => $alias->getSource() + ]; + }, $randomAliases); + } + return $this->json( + [ + 'status' => 'success', + 'customAliases' => $customAliasData, + 'randomAliases' => $randomAliasData, + ], + 200 + ); + } + + /** + * Creates random alias if request->localpart === null; custom alias if string + * Request body must not be empty + */ + #[Route('/api/user/aliases', name: 'post_user_alias', methods: ['POST'], stateless: true)] + public function createRandomAlias( + #[CurrentUser] User $user, + #[MapRequestPayload] AliasDto $request, + ): JsonResponse { + try { + $alias = $this->aliasHandler->create($user, $request->localpart); + } catch (ValidationException $e) { + return $this->json([ + 'status' => 'error', + 'message' => 'email address is unavailable' + ], 400); + } + + return $this->json([ + 'status' => 'success', + 'alias' => [ + 'id' => $alias->getId(), + 'source' => $alias->getSource() + ] + ], 200); + } + + /** + * Delegates password validation to Password + */ + #[Route('/api/user/aliases/{id}', name: 'delete_user_alias', methods: ['DELETE'], stateless: true)] + public function getAlias( + #[MapRequestPayload] PasswordDto $request, + Alias $alias + ): JsonResponse { + $violations = $this->validator->validate($alias, new AliasDelete()); + if (count($violations) > 0) { + $message = ""; + foreach ($violations as $violation) { + $message .= $violation->getMessage() . '\n'; + } + return $this->json(['status' => 'error', 'message' => $message], 403); + } + + $this->deleteHandler->deleteAlias($alias); + return $this->json(['status' => 'success'], 200); + } +} diff --git a/src/Controller/Api/LoginController.php b/src/Controller/Api/LoginController.php new file mode 100644 index 00000000..f8024b58 --- /dev/null +++ b/src/Controller/Api/LoginController.php @@ -0,0 +1,29 @@ +createAccessDeniedException("User not in 2fa process"); + $jsonResponse = new Response(json_encode($error), Response::HTTP_BAD_REQUEST); + $jsonResponse->headers->set('Content-Type', 'application/json'); + return $jsonResponse; + } + } +} diff --git a/src/Controller/Api/RecoveryController.php b/src/Controller/Api/RecoveryController.php new file mode 100644 index 00000000..ace2425e --- /dev/null +++ b/src/Controller/Api/RecoveryController.php @@ -0,0 +1,163 @@ +setPlainPassword($request->getPassword()); + + // Check if user has a MailCrypt key + if ($user->hasMailCryptSecretBox()) { + // Decrypt the MailCrypt key + $user->setPlainMailCryptPrivateKey($this->mailCryptKeyHandler->decrypt($user, $request->getPassword())); + } else { + // Create a new MailCrypt key if none existed before + $this->mailCryptKeyHandler->create($user); + } + + // Generate a new recovery token and encrypt the MailCrypt key with it + $this->recoveryTokenHandler->create($user); + if (null === $recoveryToken = $user->getPlainRecoveryToken()) { + return $this->json([ + 'message' => 'error', + 'message' => 'unknown error occured when resetting token', + ], 500); + } + + // Clear sensitive plaintext data from User object + $user->eraseCredentials(); + + return $this->json([ + 'message' => 'success', + 'recoveryToken' => $recoveryToken + ], 200); + } + + #[Route('/api/recovery', name: 'recovery_get_status', methods: ['GET'], stateless: true)] + public function getPasswordRecovery( + #[MapRequestPayload] RecoveryDto $request, + ): JsonResponse { + + /** @var User $user */ + $user = $this->manager->getRepository(User::class)->findByEmail($request->email); + + $recoveryStartTime = $user->getRecoveryStartTime(); + $recoveryUnlockedTime = $recoveryStartTime->modify('+ 2 days'); + if (null === $recoveryStartTime || new DateTime($this::PROCESS_EXPIRE) >= $recoveryStartTime) { + return $this->json([ + 'status' => 'success', + 'recovery' => 'not started' + ], 200); + } elseif (new DateTime($this::PROCESS_DELAY) < $recoveryStartTime) { + return $this->json([ + 'status' => 'success', + 'recovery' => 'waitin-period', + 'wait-until' => $recoveryUnlockedTime + ], 200); + } else { + return $this->json([ + 'status' => 'success', + 'stage' => 'password-reset', + ], 200); + } + } + + /** + * TODO: refactor into smaller pieces + * TODO: proper response messages + */ + #[Route('/api/recovery', name: 'recovery_start', methods: ['POST'], stateless: true)] + public function postPasswordRecovery( + #[MapRequestPayload] RecoveryDto $request, + ): JsonResponse { + /** @var User $user */ + $user = $this->manager->getRepository(User::class)->findByEmail($request->email); + + $recoveryStartTime = $user->getRecoveryStartTime(); + + if (null === $recoveryStartTime || new DateTime($this::PROCESS_EXPIRE) >= $recoveryStartTime) { + // Recovery process gets started + $user->updateRecoveryStartTime(); + $this->manager->flush(); + $this->eventDispatcher->dispatch(new UserEvent($user), RecoveryProcessEvent::NAME); + + return $this->json([ + 'status' => 'success', + 'date' => $user->getRecoveryStartTime() + ], 200); + } elseif (new DateTime($this::PROCESS_DELAY) < $recoveryStartTime) { + return $this->json([ + 'status' => 'error', + 'stage' => 'waiting-period', + 'date' => $user->getRecoveryStartTime() + ], 403); + } else { + // Recovery process successful, go on with the form to reset password + $user->setPlainPassword($request->getNewPassword()); + $this->passwordUpdater->updatePassword($user); + + $mailCryptPrivateKey = $this->recoveryTokenHandler->decrypt($user, $request->lowerCaseRecoveryToken()); + + // Encrypt MailCrypt private key from recoverySecretBox with new password + $this->mailCryptKeyHandler->updateWithPrivateKey($user, $mailCryptPrivateKey); + + // Clear old token + $user->eraseRecoveryStartTime(); + $user->eraseRecoverySecretBox(); + + // Generate new token + $user->setPlainMailCryptPrivateKey($mailCryptPrivateKey); + $this->recoveryTokenHandler->create($user); + if (null === $newRecoveryToken = $user->getPlainRecoveryToken()) { + throw new Exception('PlainRecoveryToken should not be null'); + } + + // Clear sensitive plaintext data from User object + $user->eraseCredentials(); + sodium_memzero($mailCryptPrivateKey); + $this->manager->flush(); + + return $this->json([ + 'message' => 'success', + 'recoveryToken' => $newRecoveryToken + ], 200); + } + } +} diff --git a/src/Controller/Api/RegistrationController.php b/src/Controller/Api/RegistrationController.php new file mode 100644 index 00000000..9dfe8229 --- /dev/null +++ b/src/Controller/Api/RegistrationController.php @@ -0,0 +1,58 @@ +registrationHandler->isRegistrationOpen()) { + return $this->json([ + 'status' => 'error', + 'message' => 'registration closed' + ], 423); + } + + $registration = new Registration(); + $registration->setVoucher($request->voucher); + $registration->setPlainPassword($request->getNewPassword()); + $registration->setEmail($request->email); + + if (null === $user = $this->registrationHandler->handle($registration)) { + return $this->json([ + 'status' => 'error', + 'message' => 'unknown error when creating user' + ], 500); + } + + $recoveryToken = $user->getPlainRecoveryToken(); + $jwtToken = $this->jwtManager->create($user); + + $user->eraseCredentials(); + return $this->json([ + 'message' => 'success', + 'recoveryToken' => $recoveryToken, + 'token' => $jwtToken, + ], 200); + } +} diff --git a/src/Controller/Api/TwoFactorController.php b/src/Controller/Api/TwoFactorController.php new file mode 100644 index 00000000..5d520230 --- /dev/null +++ b/src/Controller/Api/TwoFactorController.php @@ -0,0 +1,110 @@ +json([ + 'status' => 'success', + 'totp_enabled' => $user->isTotpAuthenticationEnabled(), + ], 200); + } + + /** + * Delegates password validation to Password + */ + #[Route('/api/user/twofactor', name: 'enable_two_factor', methods: ['POST'], stateless: true)] + public function enableTwoFactor( + TotpAuthenticatorInterface $totpAuthenticator, + #[CurrentUser] User $user, + #[MapRequestPayload] PasswordDto $request + ): JsonResponse { + if (true === $user->isTotpAuthenticationEnabled()) { + return $this->json([ + 'status' => 'error', + 'message' => 'nothing to do: totp is enabled.' + ], 404); + } + + $user->setTotpSecret($totpAuthenticator->generateSecret()); + $user->generateBackupCodes(); + $this->manager->flush(); + + return $this->json([ + 'status' => 'success', + 'qrcode' => $totpAuthenticator->getQRContent($user) + ], 200); + } + + /** + * Delegates password validation to TwoFactorConfirm + */ + #[Route('/api/user/twofactor/confirm', name: 'confirm_two_factor', methods: ['POST'], stateless: true)] + public function confirmTwoFactor( + #[CurrentUser] User $user, + #[MapRequestPayload] TwoFactorDto $request + ): JsonResponse { + if (true === $user->isTotpAuthenticationEnabled()) { + return $this->json([ + 'status' => 'error', + 'message' => 'nothing to do: totp is enabled.' + ], 404); + } + + $user->setTotpConfirmed(true); + $this->manager->flush(); + + return $this->json([ + 'status' => 'success', + 'twoFactorBackupCodes' => $user->getBackupCodes() + ], 200); + + return $this->json(['status' => 'success'], 200); + } + + /** + * Delegates password validation to Password + */ + #[Route('/api/user/twofactor', name: 'delete_two_factor', methods: ['DELETE'], stateless: true)] + public function deleteTwoFactor( + #[CurrentUser] User $user, + #[MapRequestPayload] PasswordDto $request + ): JsonResponse { + if (false === $user->isTotpAuthenticationEnabled()) { + return $this->json([ + 'status' => 'error', + 'message' => 'nothing to do: totp is not enabled.' + ], 404); + } + + $user->setTotpConfirmed(false); + $user->setTotpSecret(null); + $this->manager->flush(); + + return $this->json(['status' => 'success'], 200); + } +} diff --git a/src/Controller/Api/UserController.php b/src/Controller/Api/UserController.php new file mode 100644 index 00000000..8ff73a38 --- /dev/null +++ b/src/Controller/Api/UserController.php @@ -0,0 +1,72 @@ +json([ + 'status' => 'success', + 'username' => $user->getEmail(), + ], 200); + } + + #[Route('/api/user', name: 'patch_user', methods: ['PATCH'], stateless: true)] + public function patchSelf( + #[MapRequestPayload] PasswordChangeDto $request, + #[CurrentUser] User $user + ): JsonResponse { + $user->setPlainPassword($request->getNewPassword()); + $this->passwordUpdater->updatePassword($user); + + // TODO/DISCUSS: move to event listener + // Reencrypt the MailCrypt key with new password + if ($user->hasMailCryptSecretBox()) { + $this->mailCryptKeyHandler->update($user, $request->getPassword()); + } + $this->manager->flush(); + + $user->eraseCredentials(); + return $this->json(['status' => 'success'], 200); + } + + /** + * TODO: invalidate JWT Token? + * Delegates password validation to Password + */ + #[Route('/api/user', name: 'delete_user', methods: ['DELETE'], stateless: true)] + public function deleteSelf( + #[MapRequestPayload] PasswordDto $request, + #[CurrentUser] User $user + ): JsonResponse { + $this->deleteHandler->deleteUser($user); + + return $this->json(['status' => 'success'], 200); + } +} diff --git a/src/Controller/Api/VoucherController.php b/src/Controller/Api/VoucherController.php new file mode 100644 index 00000000..2329a3e2 --- /dev/null +++ b/src/Controller/Api/VoucherController.php @@ -0,0 +1,63 @@ +voucherHandler->getVouchersByUser($user); + $data = []; + if ($vouchers) { + foreach ($vouchers as $voucher) { + array_push($data, $voucher->getCode()); + } + } + return $this->json([ + 'status' => 'success', + 'vouchers' => $data + ], 200); + } + + #[Route('/api/user/vouchers', name: 'post_user_voucher', methods: ['POST'], stateless: true)] + public function createVoucher( + #[CurrentUser] User $user + ): JsonResponse { + if (!$this->isGranted(Roles::MULTIPLIER)) { + return $this->json([ + 'status' => 'error', + 'message' => 'forbidden' + ], 403); + } + try { + $voucher = $this->voucherCreator->create($user); + } catch (ValidationException) { + return $this->json([ + 'status' => 'error', + 'message' => 'unknown error when creating voucher' + ], 500); + } + return $this->json([ + 'status' => 'success', + 'voucher' => $voucher->getCode() + ], 200); + } +} diff --git a/src/Controller/Api/WkdController.php b/src/Controller/Api/WkdController.php new file mode 100644 index 00000000..84b30332 --- /dev/null +++ b/src/Controller/Api/WkdController.php @@ -0,0 +1,172 @@ +repository = $manager->getRepository(OpenPgpKey::class); + $this->validator = $validator; + } + + #[Route('/api/user/wkd', methods: ['GET'], stateless: true)] + public function getAllOpenPgpKeys(#[CurrentUser] User $user): JsonResponse + { + $allowedUids = $this->getAllowedUserIdByUser($user); + + $keyData = []; + if (null != $openPgpKeys = $this->repository->findByEmailList($allowedUids)) { + $keyData = array_map(function (OpenPgpKey $openPgpKey) { + return $this->printOpenPgpKey($openPgpKey); + }, $openPgpKeys); + } + + return $this->json([ + 'status' => 'success', + 'allowedUids' => $allowedUids, + 'keyData' => $keyData + ], 200); + } + + #[Route('/api/user/wkd/{uid}', methods: ['GET'], stateless: true)] + public function getOpenPgpKey(string $uid): JsonResponse + { + if ($error = $this->validatePgpRequest($uid)) { + return $this->json(['status' => 'error', 'message' => $error], 403); + } + + if (null != $openPgpKey = $this->repository->findByEmail($uid)) { + $keyData = $this->printOpenPgpKey($openPgpKey); + + return $this->json([ + 'status' => 'success', + 'keyData' => $keyData + ], 200); + } + + return $this->json(['status' => 'error'], 404); + } + + #[Route('/api/user/wkd/{uid}', methods: ['PUT'], stateless: true)] + public function putOpenPgpKey( + #[MapRequestPayload] WkdDto $request, + #[CurrentUser] User $user, + string $uid + ): JsonResponse { + + if ($error = $this->validatePgpRequest($uid)) { + return $this->json(['status' => 'error', 'message' => $error], 403); + } + + try { + $openpgpkey = $this->wkdHandler->importKey($request->keydata, $uid, $user); + } catch (NoGpgDataException $e) { + return $this->json(['status' => 'error', 'message' => $e->getMessage()], 400); + } catch (NoGpgKeyForUserException $e) { + return $this->json(['status' => 'error', 'message' => $e->getMessage()], 403); + } catch (MultipleGpgKeysForUserException $e) { + return $this->json(['status' => 'error', 'message' => $e->getMessage()], 500); + } + $keyData = $this->printOpenPgpKey($openpgpkey); + + return $this->json([ + 'status' => 'success', + 'keyData' => $keyData + ], 200); + } + + #[Route('/api/user/wkd/{uid}', methods: ['DELETE'], stateless: true)] + public function deleteOpenPgpKey( + #[MapRequestPayload] PasswordDto $request, + string $uid + ): JsonResponse { + if ($error = $this->validatePgpRequest($uid)) { + return $this->json(['status' => 'error', 'message' => $error], 403); + } + + try { + $deleted = $this->wkdHandler->deleteKey($uid); + } catch (RuntimeException $e) { + return $this->json(['status' => 'error', 'message' => $e->getMessage()], 400); + } + + if (!$deleted) { + return $this->json(['status' => 'error', 'message' => 'ressource does not exists'], 404); + } + + return $this->json(['status' => 'success'], 200); + } + + /** + * get non-random mail handles owned by a user. + */ + public function getAllowedUserIdByUser(User $user): array + { + $aliasSources = []; + if ($aliases = $this->manager->getRepository(Alias::class)->findByUser($user, false, false)) { + $aliasSources = array_map(function (Alias $alias) { + return $alias->getSource(); + }, $aliases); + } + return array_merge($aliasSources, [$user->getEmail()]); + } + + /** + * check if current user is allowed to access uid. + */ + public function validatePgpRequest(string $uid): ?string + { + $violations = $this->validator->validate($uid, new WkdQuery()); + if (count($violations) > 0) { + $message = ""; + foreach ($violations as $violation) { + $message .= $violation->getMessage() . '\n'; + } + return $message; + } + return null; + } + + public function printOpenPgpKey(OpenPgpKey $openPgpKey): array + { + if ($uploadedby = $openPgpKey->getUser()) { + $uploadedby = $uploadedby->__toString(); + } else { + $uploadedby = null; + } + return [ + 'userId' => $openPgpKey->getEmail(), + 'keyId' => $openPgpKey->getKeyId(), + 'fingerprint' => $openPgpKey->getKeyFingerprint(), + 'expireTime' => $openPgpKey->getKeyExpireTime(), + 'uploadedBy' => $uploadedby + ]; + } +} diff --git a/src/Controller/RegistrationController.php b/src/Controller/RegistrationController.php index 1cbba3fe..42e1cc14 100644 --- a/src/Controller/RegistrationController.php +++ b/src/Controller/RegistrationController.php @@ -85,12 +85,10 @@ public function register(Request $request, string $voucher = ''): Response $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $this->registrationHandler->handle($registration); + $user = $this->registrationHandler->handle($registration); - if (null !== $user = $this->manager->getRepository(User::class)->findByEmail($registration->getEmail())) { - $token = new UsernamePasswordToken($user, 'default', $user->getRoles()); - $this->tokenStorage->setToken($token); - } + $token = new UsernamePasswordToken($user, 'default', $user->getRoles()); + $this->tokenStorage->setToken($token); $recoveryToken = $user->getPlainRecoveryToken(); diff --git a/src/Doctrine/ApiQueryExtension.php b/src/Doctrine/ApiQueryExtension.php new file mode 100644 index 00000000..80a028c1 --- /dev/null +++ b/src/Doctrine/ApiQueryExtension.php @@ -0,0 +1,60 @@ +security->getUser()) { + return; + } + $this->filterEntity($queryBuilder, $resourceClass, $this->security->getUser()); + } + + public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, Operation $operation = null, array $context = []): void + { + // Same filters as for collections + $this->applyToCollection($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } + + private function filterEntity(QueryBuilder $queryBuilder, string $resourceClass, User $user): void + { + $rootAlias = $queryBuilder->getRootAliases()[0]; + if (User::class === $resourceClass) { + $queryBuilder->andWhere(sprintf('%s.id = :current_user', $rootAlias)); + $queryBuilder->setParameter('current_user', $user->getId()); + } else if ( + (OpenPgpKey::class === $resourceClass) || + (Alias::class === $resourceClass) || + (Voucher::class === $resourceClass) + ) { + $queryBuilder->andWhere(sprintf('%s.user = :current_user', $rootAlias)); + $queryBuilder->setParameter('current_user', $user->getId()); + } + // additional contraints for vouchers + if (Voucher::class === $resourceClass) { + $queryBuilder->andWhere(sprintf('%s.redeemedTime is NULL', $rootAlias)); + } + // additional constraints for aliases + if (Alias::class === $resourceClass) { + $queryBuilder->andWhere(sprintf('%s.deleted = false', $rootAlias)); + } + } +} diff --git a/src/Dto/AliasDto.php b/src/Dto/AliasDto.php new file mode 100644 index 00000000..2aa9d901 --- /dev/null +++ b/src/Dto/AliasDto.php @@ -0,0 +1,16 @@ +newPassword; + } + + public function setNewPassword(?string $newPassword): void + { + $this->newPassword = $newPassword; + } +} diff --git a/src/Dto/PasswordDto.php b/src/Dto/PasswordDto.php new file mode 100644 index 00000000..4e7b0abb --- /dev/null +++ b/src/Dto/PasswordDto.php @@ -0,0 +1,10 @@ +newPassword; + } + + public function setNewPassword(?string $newPassword): void + { + $this->newPassword = $newPassword; + } + + public function lowerCaseRecoveryToken(): string + { + return strtolower($this->recoveryToken); + } + + public function setRecoveryToken(string $recoveryToken): void + { + $this->recoveryToken = $recoveryToken; + } +} diff --git a/src/Dto/RegistrationDto.php b/src/Dto/RegistrationDto.php new file mode 100644 index 00000000..df2e71af --- /dev/null +++ b/src/Dto/RegistrationDto.php @@ -0,0 +1,36 @@ +newPassword; + } + + public function setNewPassword(string $newPassword) + { + $this->newPassword = $newPassword; + } +} diff --git a/src/Dto/Traits/PasswordTrait.php b/src/Dto/Traits/PasswordTrait.php new file mode 100644 index 00000000..8f499fda --- /dev/null +++ b/src/Dto/Traits/PasswordTrait.php @@ -0,0 +1,21 @@ +password; + } + + public function setPassword(string $password) + { + $this->password = $password; + } +} diff --git a/src/Dto/TwoFactorDto.php b/src/Dto/TwoFactorDto.php new file mode 100644 index 00000000..a4b442f7 --- /dev/null +++ b/src/Dto/TwoFactorDto.php @@ -0,0 +1,25 @@ + no getter or setter needed + #[Assert\NotNull] + #[Assert\NotBlank] + #[TotpSecret] + private string $totpSecret; + + public function getTotpSecret() + { + return $this->totpSecret; + } + + public function setTotpSecret(string $totpSecret) + { + $this->totpSecret = $totpSecret; + } +} diff --git a/src/Dto/WkdDto.php b/src/Dto/WkdDto.php new file mode 100644 index 00000000..f77329aa --- /dev/null +++ b/src/Dto/WkdDto.php @@ -0,0 +1,15 @@ +getRequest(); + if ($request->attributes->getBoolean('_stateless')) { + return; + } $session = $request->getSession(); $sessionLocale = $session->get('_locale'); diff --git a/src/Handler/DeleteHandler.php b/src/Handler/DeleteHandler.php index 1cf6cc9d..86f90a9f 100644 --- a/src/Handler/DeleteHandler.php +++ b/src/Handler/DeleteHandler.php @@ -13,8 +13,11 @@ class DeleteHandler /** * DeleteHandler constructor. */ - public function __construct(private readonly PasswordUpdater $passwordUpdater, private readonly EntityManagerInterface $manager, private readonly WkdHandler $wkdHandler) - { + public function __construct( + private readonly PasswordUpdater $passwordUpdater, + private readonly EntityManagerInterface $manager, + private readonly WkdHandler $wkdHandler + ) { } public function deleteAlias(Alias $alias, User $user = null) @@ -52,12 +55,14 @@ public function deleteUser(User $user) $user->eraseMailCryptPublicKey(); $user->eraseMailCryptSecretBox(); - // Delete OpenPGP key from WKD - $this->wkdHandler->deleteKey($user->getEmail()); - // Flag user as deleted $user->setDeleted(true); + // Delete OpenPGP key from WKD if no other user owns mail handle + if (!$this->wkdHandler->userToUserIdExists($user->getEmail())) { + $this->wkdHandler->deleteKey($user->getEmail()); + } + $this->manager->flush(); } } diff --git a/src/Handler/RegistrationHandler.php b/src/Handler/RegistrationHandler.php index 349f2f61..261a1416 100644 --- a/src/Handler/RegistrationHandler.php +++ b/src/Handler/RegistrationHandler.php @@ -20,14 +20,22 @@ class RegistrationHandler /** * Constructor. */ - public function __construct(private readonly EntityManagerInterface $manager, private readonly DomainGuesser $domainGuesser, private readonly EventDispatcherInterface $eventDispatcher, private readonly PasswordUpdater $passwordUpdater, private readonly MailCryptKeyHandler $mailCryptKeyHandler, private readonly RecoveryTokenHandler $recoveryTokenHandler, private readonly bool $registrationOpen, private readonly bool $mailCrypt) - { + public function __construct( + private readonly EntityManagerInterface $manager, + private readonly DomainGuesser $domainGuesser, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly PasswordUpdater $passwordUpdater, + private readonly MailCryptKeyHandler $mailCryptKeyHandler, + private readonly RecoveryTokenHandler $recoveryTokenHandler, + private readonly bool $registrationOpen, + private readonly bool $mailCrypt + ) { } /** * @throws Exception */ - public function handle(Registration $registration): void + public function handle(Registration $registration): User { if (!$this->isRegistrationOpen()) { throw new Exception('The Registration is closed!'); @@ -53,6 +61,8 @@ public function handle(Registration $registration): void $this->manager->flush(); $this->eventDispatcher->dispatch(new UserEvent($user), Events::MAIL_ACCOUNT_CREATED); + + return $user; } public function isRegistrationOpen(): bool diff --git a/src/Handler/WkdHandler.php b/src/Handler/WkdHandler.php index 9b20b2f9..7abd2010 100644 --- a/src/Handler/WkdHandler.php +++ b/src/Handler/WkdHandler.php @@ -2,6 +2,7 @@ namespace App\Handler; +use App\Entity\Alias; use App\Entity\OpenPgpKey; use App\Entity\User; use App\Exception\MultipleGpgKeysForUserException; @@ -12,6 +13,7 @@ use Doctrine\ORM\EntityManagerInterface; use RuntimeException; use Tuupola\Base32; +use Symfony\Component\Validator\Validator\ValidatorInterface; class WkdHandler { @@ -20,21 +22,24 @@ class WkdHandler /** * WkdHandler constructor. */ - public function __construct(private readonly EntityManagerInterface $manager, - private readonly string $wkdDirectory, - private readonly string $wkdFormat) - { + public function __construct( + private readonly EntityManagerInterface $manager, + private readonly string $wkdDirectory, + private readonly string $wkdFormat, + private ValidatorInterface $validator, + ) { $this->repository = $manager->getRepository(OpenPgpKey::class); + $this->validator = $validator; } private function getWkdPath(string $domain): string { if ('advanced' === $this->wkdFormat) { - $wkdPath = $this->wkdDirectory.DIRECTORY_SEPARATOR.strtolower($domain).DIRECTORY_SEPARATOR.'hu'; - $policyPath = $this->wkdDirectory.DIRECTORY_SEPARATOR.strtolower($domain).DIRECTORY_SEPARATOR.'policy'; + $wkdPath = $this->wkdDirectory . DIRECTORY_SEPARATOR . strtolower($domain) . DIRECTORY_SEPARATOR . 'hu'; + $policyPath = $this->wkdDirectory . DIRECTORY_SEPARATOR . strtolower($domain) . DIRECTORY_SEPARATOR . 'policy'; } elseif ('simple' === $this->wkdFormat) { - $wkdPath = $this->wkdDirectory.DIRECTORY_SEPARATOR.'hu'; - $policyPath = $this->wkdDirectory.DIRECTORY_SEPARATOR.'policy'; + $wkdPath = $this->wkdDirectory . DIRECTORY_SEPARATOR . 'hu'; + $policyPath = $this->wkdDirectory . DIRECTORY_SEPARATOR . 'policy'; } else { throw new RuntimeException(sprintf('Error: unsupported WKD format: %s', $this->wkdFormat)); } @@ -67,7 +72,7 @@ private function getWkdKeyPath(string $email): string $wkdPath = $this->getWkdPath($domain); $wkdHash = $this->wkdHash($localPart); - return $wkdPath.DIRECTORY_SEPARATOR.$wkdHash; + return $wkdPath . DIRECTORY_SEPARATOR . $wkdHash; } /** @@ -110,10 +115,10 @@ public function getKey(string $email): OpenPgpKey return $openPgpKey; } - public function deleteKey(string $email): void + public function deleteKey(string $email): bool { if (null === $openPgpKey = $this->repository->findByEmail($email)) { - return; + return false; } $wkdKeyPath = $this->getWkdKeyPath($email); @@ -124,6 +129,8 @@ public function deleteKey(string $email): void $this->manager->remove($openPgpKey); $this->manager->flush(); + + return true; } /** @@ -138,4 +145,19 @@ public function getDomainWkdPath(string $domain): string { return $this->getWkdPath($domain); } + + /** + * Assert that at least one non-deleted user exists to use the given uid + */ + public function userToUserIdExists(string $uid): bool + { + if ($this->manager->getRepository(Alias::class)->findOneBySource($uid, false)) { + return true; + } + + if ($this->manager->getRepository(User::class)->findByEmail($uid, false)) { + return true; + } + return false; + } } diff --git a/src/Repository/AliasRepository.php b/src/Repository/AliasRepository.php index a176010b..3c14a357 100644 --- a/src/Repository/AliasRepository.php +++ b/src/Repository/AliasRepository.php @@ -8,6 +8,18 @@ class AliasRepository extends EntityRepository { + + /** + * @return Alias|null + */ + public function findOneByUserAndSource(User $user, string $email, ?bool $random = null): ?Alias + { + if (isset($random)) { + return $this->findOneBy(['user' => $user, 'source' => $email, 'random' => $random, 'deleted' => false]); + } + return $this->findOneBy(['user' => $user, 'source' => $email, 'deleted' => false]); + } + /** * @param string $email * @param bool|null $includeDeleted @@ -35,4 +47,13 @@ public function findByUser(User $user, ?bool $random = null): array return $this->findBy(['user' => $user, 'deleted' => false]); } + + public function countByUser(User $user, ?bool $random = null): int + { + if (isset($random)) { + return $this->count(['user' => $user, 'random' => $random, 'deleted' => false]); + } + + return $this->count(['user' => $user, 'deleted' => false]); + } } diff --git a/src/Repository/OpenPgpKeyRepository.php b/src/Repository/OpenPgpKeyRepository.php index 2e64586e..42ad254f 100644 --- a/src/Repository/OpenPgpKeyRepository.php +++ b/src/Repository/OpenPgpKeyRepository.php @@ -21,6 +21,18 @@ public function findByEmail(string $email): ?OpenPgpKey return $this->findOneBy(['email' => $email]); } + /** + * @return OpenPgpKey[] + */ + public function findByEmailList(array $emails): array + { + $qb = $this->createQueryBuilder('e'); + + $qb->where($qb->expr()->in('e.email', ':emails')) + ->setParameter('emails', $emails); + + return $qb->getQuery()->getResult(); + } public function countKeys(): int { return $this->count([]); diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 6902b94d..275d360e 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -17,18 +17,15 @@ class UserRepository extends EntityRepository implements PasswordUpgraderInterface { - /** - * @param $email - * - * @return User|null - */ - public function findByEmail($email): ?User + public function findByEmail(string $email, ?bool $deleted = false): ?User { - return $this->findOneBy(['email' => $email]); + if (!$deleted) { + return $this->findOneBy(['email' => $email]); + } + return $this->findOneBy(['email' => $email, 'deleted' => $deleted]); } /** - * @param DateTime $dateTime * @return AbstractLazyCollection|(AbstractLazyCollection&Selectable)|LazyCriteriaCollection */ public function findUsersSince(DateTime $dateTime) @@ -37,7 +34,6 @@ public function findUsersSince(DateTime $dateTime) } /** - * @param int $days * @return AbstractLazyCollection|(AbstractLazyCollection&Selectable)|LazyCriteriaCollection * @throws Exception */ @@ -49,7 +45,7 @@ public function findInactiveUsers(int $days) $expression = $expressionBuilder->eq('deleted', 0); } else { $dateTime = new DateTime(); - $dateTime->sub(new DateInterval('P'.$days.'D')); + $dateTime->sub(new DateInterval('P' . $days . 'D')); $expression = $expressionBuilder->andX( $expressionBuilder->eq('deleted', 0), $expressionBuilder->orX( @@ -73,55 +69,43 @@ public function findDeletedUsers(): array return $this->findBy(['deleted' => true]); } - /** - * @return int - */ public function countUsers(): int { return $this->matching(Criteria::create() ->where(Criteria::expr()->eq('deleted', false)))->count(); } - /** - * @return int - */ public function countDeletedUsers(): int { return $this->matching(Criteria::create() ->where(Criteria::expr()->eq('deleted', true)))->count(); } - /** - * @return int - */ public function countUsersWithRecoveryToken(): int { - return $this->matching(Criteria::create() - ->where(Criteria::expr()->eq('deleted', false)) - ->andWhere(Criteria::expr()->neq('recoverySecretBox', null)) + return $this->matching( + Criteria::create() + ->where(Criteria::expr()->eq('deleted', false)) + ->andWhere(Criteria::expr()->neq('recoverySecretBox', null)) )->count(); } - /** - * @return int - */ public function countUsersWithMailCrypt(): int { - return $this->matching(Criteria::create() - ->where(Criteria::expr()->eq('deleted', false)) - ->andWhere(Criteria::expr()->eq('mailCrypt', true)) + return $this->matching( + Criteria::create() + ->where(Criteria::expr()->eq('deleted', false)) + ->andWhere(Criteria::expr()->eq('mailCrypt', true)) )->count(); } - /** - * @return int - */ public function countUsersWithTwofactor(): int { - return $this->matching(Criteria::create() - ->where(Criteria::expr()->eq('deleted', false)) - ->andWhere(Criteria::expr()->eq('totpConfirmed', 1)) - ->andWhere(Criteria::expr()->neq('totpSecret', null)) + return $this->matching( + Criteria::create() + ->where(Criteria::expr()->eq('deleted', false)) + ->andWhere(Criteria::expr()->eq('totpConfirmed', 1)) + ->andWhere(Criteria::expr()->neq('totpSecret', null)) )->count(); } diff --git a/src/Security/JWTAuthenticationSuccessHandler.php b/src/Security/JWTAuthenticationSuccessHandler.php new file mode 100644 index 00000000..aa2e9659 --- /dev/null +++ b/src/Security/JWTAuthenticationSuccessHandler.php @@ -0,0 +1,20 @@ +handleAuthenticationSuccess($token->getUser()); + } +} \ No newline at end of file diff --git a/src/Security/TwoFactorAuthenticationFailureHandler.php b/src/Security/TwoFactorAuthenticationFailureHandler.php new file mode 100644 index 00000000..b2051e70 --- /dev/null +++ b/src/Security/TwoFactorAuthenticationFailureHandler.php @@ -0,0 +1,16 @@ +handleAuthenticationSuccess($token->getUser()); + } +} diff --git a/src/Traits/AliasAwareTrait.php b/src/Traits/AliasAwareTrait.php index f3466a4f..f4d6ca44 100644 --- a/src/Traits/AliasAwareTrait.php +++ b/src/Traits/AliasAwareTrait.php @@ -2,8 +2,10 @@ namespace App\Traits; + use App\Entity\Alias; + trait AliasAwareTrait { private ?Alias $alias = null; diff --git a/src/Traits/MailCryptTrait.php b/src/Traits/MailCryptTrait.php index 0dcb0213..a4f48d2e 100644 --- a/src/Traits/MailCryptTrait.php +++ b/src/Traits/MailCryptTrait.php @@ -4,6 +4,7 @@ use Doctrine\ORM\Mapping as ORM; + trait MailCryptTrait { #[ORM\Column(options: ['default' => false])] diff --git a/src/Traits/OpenPgpKeyTrait.php b/src/Traits/OpenPgpKeyTrait.php index 22226915..5b55d921 100644 --- a/src/Traits/OpenPgpKeyTrait.php +++ b/src/Traits/OpenPgpKeyTrait.php @@ -5,6 +5,7 @@ use DateTime; use Doctrine\ORM\Mapping as ORM; + trait OpenPgpKeyTrait { #[ORM\Column(type: 'text')] diff --git a/src/Traits/QuotaTrait.php b/src/Traits/QuotaTrait.php index eab563fd..7890fe5a 100644 --- a/src/Traits/QuotaTrait.php +++ b/src/Traits/QuotaTrait.php @@ -4,6 +4,7 @@ use Doctrine\ORM\Mapping as ORM; + trait QuotaTrait { #[ORM\Column(nullable: true)] diff --git a/src/Traits/RandomTrait.php b/src/Traits/RandomTrait.php index fba15fc8..ace155cc 100644 --- a/src/Traits/RandomTrait.php +++ b/src/Traits/RandomTrait.php @@ -4,8 +4,11 @@ use Doctrine\ORM\Mapping as ORM; + + trait RandomTrait { + #[ORM\Column(options: ['default' => false])] private bool $random = false; diff --git a/src/Traits/RecoveryStartTimeTrait.php b/src/Traits/RecoveryStartTimeTrait.php index ae01f688..e28e0012 100644 --- a/src/Traits/RecoveryStartTimeTrait.php +++ b/src/Traits/RecoveryStartTimeTrait.php @@ -2,21 +2,21 @@ namespace App\Traits; -use DateTime; +use DateTimeImmutable; use Exception; use Doctrine\ORM\Mapping as ORM; trait RecoveryStartTimeTrait { #[ORM\Column(nullable: true)] - private ?DateTime $recoveryStartTime = null; + private ?DateTimeImmutable $recoveryStartTime = null; - public function getRecoveryStartTime(): ?DateTime + public function getRecoveryStartTime(): ?DateTimeImmutable { return $this->recoveryStartTime; } - public function setRecoveryStartTime(DateTime $recoveryStartTime): void + public function setRecoveryStartTime(DateTimeImmutable $recoveryStartTime): void { $this->recoveryStartTime = $recoveryStartTime; } @@ -26,7 +26,7 @@ public function setRecoveryStartTime(DateTime $recoveryStartTime): void */ public function updateRecoveryStartTime(): void { - $this->setRecoveryStartTime(new DateTime()); + $this->setRecoveryStartTime(new DateTimeImmutable()); } public function eraseRecoveryStartTime(): void diff --git a/src/Traits/TwofactorTrait.php b/src/Traits/TwofactorTrait.php index d9250c6c..80c7f617 100644 --- a/src/Traits/TwofactorTrait.php +++ b/src/Traits/TwofactorTrait.php @@ -6,6 +6,7 @@ use Scheb\TwoFactorBundle\Model\Totp\TotpConfiguration; use Scheb\TwoFactorBundle\Model\Totp\TotpConfigurationInterface; + trait TwofactorTrait { #[ORM\Column(nullable: true)] diff --git a/src/Traits/UserAwareTrait.php b/src/Traits/UserAwareTrait.php index 2fff4a45..2535f5a4 100644 --- a/src/Traits/UserAwareTrait.php +++ b/src/Traits/UserAwareTrait.php @@ -5,6 +5,8 @@ use App\Entity\User; use Doctrine\ORM\Mapping as ORM; + + trait UserAwareTrait { #[ORM\ManyToOne(targetEntity: User::class)] diff --git a/src/Validator/Constraints/AliasCreate.php b/src/Validator/Constraints/AliasCreate.php new file mode 100644 index 00000000..4dd09698 --- /dev/null +++ b/src/Validator/Constraints/AliasCreate.php @@ -0,0 +1,31 @@ +custom_alias_limit = $custom_alias_limit ?? $this->custom_alias_limit; + $this->random_alias_limit = $random_alias_limit ?? $this->random_alias_limit; + } +} diff --git a/src/Validator/Constraints/AliasCreateValidator.php b/src/Validator/Constraints/AliasCreateValidator.php new file mode 100644 index 00000000..8652613b --- /dev/null +++ b/src/Validator/Constraints/AliasCreateValidator.php @@ -0,0 +1,48 @@ +security = $security; + $this->repository = $manager->getRepository(Alias::class);; + } + + /** + */ + public function validate(mixed $object, Constraint $constraint): void + { + if (!$constraint instanceof AliasDto) { + throw new UnexpectedTypeException('Wrong constraint type given', AliasDto::class); + } + + $user = $this->security->getUser(); + + if (!$object) { + if ($constraint->random_alias_limit <= $this->repository->countByUser($user, true)) { + $this->context->addViolation('alias-limit-random-reached'); + return; + } + } + + if ($constraint->custom_alias_limit <= $this->repository->countByUser($user, false)) { + $this->context->addViolation('alias-limit-custom-reached'); + return; + } + } +} diff --git a/src/Validator/Constraints/AliasDelete.php b/src/Validator/Constraints/AliasDelete.php new file mode 100644 index 00000000..54773601 --- /dev/null +++ b/src/Validator/Constraints/AliasDelete.php @@ -0,0 +1,10 @@ +security = $security; + $this->repository = $manager->getRepository(Alias::class);; + } + + /** + */ + public function validate(mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof AliasDelete) { + throw new UnexpectedTypeException('Wrong constraint type given', AliasDelete::class); + } + + if (!$value instanceof Alias) { + throw new UnexpectedTypeException('Wrong object type given', Alias::class); + } + + if ($this->security->getUser() != $value->getUser()) { + $this->context->addViolation('forbidden'); + return; + } + + if (!$value->isRandom()) { + $this->context->addViolation('not allowed to delete custom alias. contact your system administrator'); + } + } +} diff --git a/src/Validator/Constraints/RecoveryToken.php b/src/Validator/Constraints/RecoveryToken.php new file mode 100644 index 00000000..a828e621 --- /dev/null +++ b/src/Validator/Constraints/RecoveryToken.php @@ -0,0 +1,16 @@ +email || !$value->lowerCaseRecoveryToken()) { + throw new InvalidArgumentException('"email" and "recoveryToken" attributes cannot be null'); + } + + $user = $this->manager->getRepository(User::class)->findByEmail($value->email); + if (!$user) { + $this->context->buildViolation($constraint->message)->addViolation(); + } elseif (!$this->recoveryTokenHandler->verify($user, $value->lowerCaseRecoveryToken())) { + $this->context->buildViolation($constraint->message)->addViolation(); + } + } +} diff --git a/src/Validator/Constraints/WkdQuery.php b/src/Validator/Constraints/WkdQuery.php new file mode 100644 index 00000000..6a11f48d --- /dev/null +++ b/src/Validator/Constraints/WkdQuery.php @@ -0,0 +1,11 @@ +security = $security; + $this->aliasRepository = $manager->getRepository(Alias::class); + } + + /** + * Validate if value matches either the username or an alias source of the requesting user + */ + public function validate($value, Constraint $constraint) + { + if (!$constraint instanceof WkdQuery) { + throw new UnexpectedTypeException($constraint, WkdQuery::class); + } + if (!is_string($value)) { + throw new UnexpectedValueException($value, 'string'); + } + + /** @var User */ + $user = $this->security->getUser(); + if (!$user) { + throw new UnexpectedValueException(null, User::class); + } + + // pass if value matches user email + if ($value === $user->getEmail()) { + return; + } + // pass if value matches any of users non random aliases + if ($this->aliasRepository->findOneByUserAndSource($user, $value, false)) { + return; + } + // Add violation in any other case + return $this->context->buildViolation($constraint->message)->addViolation(); + } +} diff --git a/symfony.lock b/symfony.lock index b828ced1..7d0696d2 100644 --- a/symfony.lock +++ b/symfony.lock @@ -125,6 +125,18 @@ "knplabs/knp-menu-bundle": { "version": "v2.2.1" }, + "lexik/jwt-authentication-bundle": { + "version": "2.20", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.5", + "ref": "e9481b233a11ef7e15fe055a2b21fd3ac1aa2bb7" + }, + "files": [ + "config/packages/lexik_jwt_authentication.yaml" + ] + }, "monolog/monolog": { "version": "1.23.0" }, diff --git a/tests/EventListener/LocaleListenerTest.php b/tests/EventListener/LocaleListenerTest.php index f5dfb541..3dff7d28 100644 --- a/tests/EventListener/LocaleListenerTest.php +++ b/tests/EventListener/LocaleListenerTest.php @@ -5,6 +5,7 @@ use App\EventListener\LocaleListener; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\InputBag; +use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpKernel\Event\RequestEvent; @@ -22,6 +23,9 @@ public function setUp(): void $this->session = $this->createMock(Session::class); $this->request = $this->createMock(Request::class); + $attributes = $this->createMock(ParameterBag::class); + $attributes->method('getBoolean')->willReturn(false); + $this->request->attributes = $attributes; $this->request->method('getSession') ->willReturn($this->session); $this->request->query = new InputBag();