diff --git a/.env b/.env index a00827d5..d1f78e83 100644 --- a/.env +++ b/.env @@ -60,3 +60,7 @@ 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/config/packages/security.yaml b/config/packages/security.yaml index 28add15a..48de0a3f 100755 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -105,7 +105,7 @@ security: pattern: ^/(_(profiler|error|wdt)|css|images|js)/ security: false api-login: - pattern: ^/api/login + pattern: ^/api/user/login stateless: false json_login: check_path: api_login @@ -167,5 +167,7 @@ security: - { path: "^/alias", roles: ROLE_USER, allow_if: "!is_granted('ROLE_SPAM')"} - { path: "^/account", roles: ROLE_USER, allow_if: "!is_granted('ROLE_SPAM')"} - { path: "^/admin", roles: ROLE_DOMAIN_ADMIN } - - { path: "^/api/login", roles: PUBLIC_ACCESS } - - { path: "^/api", roles: ROLE_USER } \ No newline at end of file + - { 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/", roles: ROLE_USER } diff --git a/src/Controller/Api/AliasController.php b/src/Controller/Api/AliasController.php new file mode 100644 index 00000000..415e2c5e --- /dev/null +++ b/src/Controller/Api/AliasController.php @@ -0,0 +1,114 @@ +aliasRepository = $manager->getRepository(Alias::class); + } + + #[Route('/api/user/aliases', name: 'get_user_aliases', methods: ['GET'])] + public function getAliases(): JsonResponse + { + $user = $this->security->getUser(); + if ($customAliases = $this->aliasRepository->findByUser($user, false, false)) { + $customAliasData = array_map(function (Alias $alias) { + return $alias->getSource(); + }, $customAliases); + } + if ($randomAliases = $this->aliasRepository->findByUser($user, true, false)) { + $randomAliasData = array_map(function (Alias $alias) { + return $alias->getSource(); + }, $randomAliases); + } + return $this->json( + [ + 'status' => 'success', + 'customAliases' => $customAliasData, + 'randomAliases' => $randomAliasData, + ], + 200 + ); + } + + /** + * Creates random alias if request is empty ('{}'), + * Creates custom alias if request contains "localpart" key + */ + #[Route('/api/user/aliases', name: 'post_user_alias', methods: ['POST'])] + public function createRandomAlias(#[MapRequestPayload] LocalpartDto $dto): JsonResponse + { + $user = $this->security->getUser(); + try { + $alias = $this->aliasHandler->create($user, $dto->localpart); + } catch (ValidationException $e) { + return $this->json([ + 'status' => 'failed', + 'message' => 'email address is unavailable' + ], 400); + } + if (!$alias) { + return $this->json([ + 'status' => 'failed', + 'message' => 'limit reached' + ], 400); + } + return $this->json([ + 'status' => 'success', + 'alias' => $alias->getSource() + ], 200); + } + + /** + * Delegates password validation to PasswordDto + * Deletes random user-owned alias with source ${uid} if it exists + */ + #[Route('/api/user/aliases/{uid}', name: 'delete_user_alias', methods: ['DELETE'])] + public function getAlias(#[MapRequestPayload] PasswordDto $dto, string $uid): JsonResponse + { + $user = $this->security->getUser(); + /** + * Return random aliases owned by authenticated user + * @var Alias $alias + */ + $alias = $this->aliasRepository->findOneByUserAndSource($user, $uid); + if ($alias === null) { + return $this->json([ + 'status' => 'failed', + 'message' => 'forbidden' + ], 400); + } + if (!$alias->isRandom()) { + return $this->json([ + 'status' => 'failed', + 'message' => 'contact administrator for custom alias deletion' + ], 400); + } + $this->deleteHandler->deleteAlias($alias); + $dto->erasePassword(); + return $this->json(['status' => 'success'], 200); + } +} diff --git a/src/Controller/ApiLoginController.php b/src/Controller/Api/LoginController.php similarity index 74% rename from src/Controller/ApiLoginController.php rename to src/Controller/Api/LoginController.php index 9ba502da..f8024b58 100644 --- a/src/Controller/ApiLoginController.php +++ b/src/Controller/Api/LoginController.php @@ -1,6 +1,6 @@ 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..943c9443 --- /dev/null +++ b/src/Controller/Api/RecoveryController.php @@ -0,0 +1,10 @@ +registrationHandler->isRegistrationOpen()) { + return $this->json([ + 'status' => 'failed', + 'message' => 'registration closed' + ], 423); + } + // TODO Could be moved to Dto validation, but requires to initialize whole DTO class for validation with little benefit + if ($dto->newPassword !== $dto->newPasswordConfirm) { + return $this->json([ + 'status' => 'failed', + 'message' => 'passwords do not match' + ], 400); + } + $registration = new Registration(); + $registration->setVoucher($dto->voucher); + $registration->setPlainPassword($dto->newPassword); + $registration->setEmail($dto->email); + $this->registrationHandler->handle($registration); + $dto->eraseNewPassword(); + + if (null === $user = $this->manager->getRepository(User::class)->findByEmail($registration->getEmail())) { + return $this->json([ + 'status' => 'failed', + '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/TotpController.php b/src/Controller/Api/TotpController.php new file mode 100644 index 00000000..4bfa4664 --- /dev/null +++ b/src/Controller/Api/TotpController.php @@ -0,0 +1,10 @@ +security->getUser(); + return $this->json([ + 'status' => 'success', + 'username' => $user->getEmail(), + 'totp_enabled' => $user->isTotpAuthenticationEnabled(), + ], 200); + } + + #[Route('/api/user', name: 'patch_user', methods: ['PATCH'])] + public function patchSelf(#[MapRequestPayload] PasswordChangeDto $dto): JsonResponse + { + /** @var User $user */ + $user = $this->security->getUser(); + // TODO: move this to proper validator + if ($dto->newPassword !== $dto->newPasswordConfirm) { + return $this->json([ + 'status' => 'failed', + 'message' => 'passwords do not match' + ], 400); + } + if ($dto->newPassword === $dto->password) { + return $this->json([ + 'status' => 'failed', + 'message' => 'new password and old password are same' + ], 400); + } + $user->setPlainPassword($dto->newPassword); + $this->passwordUpdater->updatePassword($user); + // Reencrypt the MailCrypt key with new password + if ($user->hasMailCryptSecretBox()) { + $this->mailCryptKeyHandler->update($user, $dto->password); + } + $this->manager->flush(); + + $user->eraseCredentials(); + $dto->eraseNewPassword(); + $dto->erasePassword(); + return $this->json(['status' => 'success'], 200); + } + + /** + * Delegates password validation to PasswordDto + */ + #[Route('/api/user', name: 'delete_user', methods: ['DELETE'])] + public function deleteSelf(#[MapRequestPayload] PasswordDto $dto): JsonResponse + { + /** @var User $user */ + $user = $this->security->getUser(); + $this->security->logout(); + $this->deleteHandler->deleteUser($user); + $dto->erasePassword(); + 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..e569b210 --- /dev/null +++ b/src/Controller/Api/VoucherController.php @@ -0,0 +1,67 @@ +security->getUser(); + $vouchers = $this->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'])] + public function createVoucher(): JsonResponse + { + /** @var User $user */ + $user = $this->security->getUser(); + if (!$this->security->isGranted(Roles::MULTIPLIER)) { + return $this->json([ + 'status' => 'failed', + 'message' => 'forbidden' + ], 403); + } + try { + $voucher = $this->voucherCreator->create($user); + } catch (ValidationException) { + return $this->json([ + 'status' => 'failed', + '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..202c0379 --- /dev/null +++ b/src/Controller/Api/WkdController.php @@ -0,0 +1,123 @@ +repository = $manager->getRepository(OpenPgpKey::class); + } + + #[Route('/api/user/wkd', methods: ['GET'])] + public function getOpenPgpKeys(): JsonResponse + { + $user = $this->security->getUser(); + $allowedUids = $this->wkdHandler->getAllowedUserIdByUser($user); + $openPgpKeys = $this->repository->findByEmailList($allowedUids); + $keyData = []; + if ($openPgpKeys) { + $keyData = array_map(function (OpenPgpKey $openPgpKey) { + return [ + 'userId' => $openPgpKey->getEmail(), + 'keyId' => $openPgpKey->getKeyId(), + 'fingerprint' => $openPgpKey->getKeyFingerprint(), + 'expireTime' => $openPgpKey->getKeyExpireTime(), + 'uploadedBy' => $openPgpKey->getUser()->__toString(), + ]; + }, $openPgpKeys); + } + return $this->json(['status' => 'success', 'allowedUids' => $allowedUids, 'uploadedKeys' => $keyData], 200); + } + + #[Route('/api/user/wkd/{uid}', methods: ['GET'])] + public function getOpenPgpKey(string $uid): JsonResponse + { + if ($error = $this->wkdHandler->entitledToUserId($uid)) { + return $this->json(['status' => 'failed', 'message' => $error], 403); + } + $openPgpKey = $this->repository->findByEmail($uid); + $keyData = []; + if ($openPgpKey) { + $keyData = [ + 'userId' => $openPgpKey->getEmail(), + 'keyId' => $openPgpKey->getKeyId(), + 'fingerprint' => $openPgpKey->getKeyFingerprint(), + 'expireTime' => $openPgpKey->getKeyExpireTime(), + 'uploadedBy' => $openPgpKey->getUser()->__toString(), + ]; + } + return $this->json(['status' => 'success', 'keyData' => $keyData], 200); + } + + #[Route('/api/user/wkd/{uid}', methods: ['PUT'])] + public function putOpenPgpKey(#[MapRequestPayload] WkdRequestDto $dto, string $uid): JsonResponse + { + $user = $this->security->getUser(); + if ($error = $this->wkdHandler->entitledToUserId($uid)) { + return $this->json(['status' => 'failed', 'message' => $error], 403); + } + try { + $openpgpkey = $this->wkdHandler->importKey($dto->keydata, $uid, $user); + } catch (NoGpgDataException $e) { + return $this->json(['status' => 'failed', 'message' => $e->getMessage()], 400); + } catch (NoGpgKeyForUserException $e) { + return $this->json(['status' => 'failed', 'message' => $e->getMessage()], 403); + } catch (MultipleGpgKeysForUserException $e) { + return $this->json(['status' => 'failed', 'message' => $e->getMessage()], 500); + } + $keyData = [ + 'keyUid' => $openpgpkey->getKeyId(), + 'fingerprint' => $openpgpkey->getKeyFingerprint(), + ]; + $dto->erasePassword(); + return $this->json(['status' => 'success', 'keyData' => $keyData], 200); + } + + /** + * Delegates password validation to AuthenticateDto + */ + #[Route('/api/user/wkd/{uid}', methods: ['DELETE'])] + public function deleteOpenPgpKey(#[MapRequestPayload] PasswordDto $dto, string $uid): JsonResponse + { + if ($error = $this->wkdHandler->entitledToUserId($uid)) { + return $this->json(['status' => 'failed', 'message' => $error], 403); + } + try { + $deleted = $this->wkdHandler->deleteKey($uid); + } catch (RuntimeException $e) { + return $this->json(['status' => 'failed', 'message' => $e->getMessage()], 400); + } + if (!$deleted) { + return $this->json(['status' => 'failed', 'message' => 'ressource does not exists'], 404); + } + $dto->erasePassword(); + + return $this->json(['status' => 'success'], 200); + } +} diff --git a/src/Controller/RegistrationController.php b/src/Controller/RegistrationController.php index 16613e19..55c39fed 100644 --- a/src/Controller/RegistrationController.php +++ b/src/Controller/RegistrationController.php @@ -133,4 +133,5 @@ public function register(Request $request, string $voucher = null): Response return $this->render('Registration/register.html.twig', ['form' => $form->createView()]); } + } diff --git a/src/Controller/StartController.php b/src/Controller/StartController.php index bda0291c..f5c7cca4 100644 --- a/src/Controller/StartController.php +++ b/src/Controller/StartController.php @@ -45,8 +45,7 @@ public function __construct( private readonly MailCryptKeyHandler $mailCryptKeyHandler, private readonly EntityManagerInterface $manager, private readonly WkdHandler $wkdHandler - ) - { + ) { } /** 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/LocalpartDto.php b/src/Dto/LocalpartDto.php new file mode 100644 index 00000000..60de2c00 --- /dev/null +++ b/src/Dto/LocalpartDto.php @@ -0,0 +1,8 @@ +newPassword = ''; + $this->newPasswordConfirm = ''; + } +} diff --git a/src/Dto/Traits/PasswordTrait.php b/src/Dto/Traits/PasswordTrait.php new file mode 100644 index 00000000..97b42efc --- /dev/null +++ b/src/Dto/Traits/PasswordTrait.php @@ -0,0 +1,16 @@ +password = ''; + } +} diff --git a/src/Dto/WkdRequestDto.php b/src/Dto/WkdRequestDto.php new file mode 100644 index 00000000..07914305 --- /dev/null +++ b/src/Dto/WkdRequestDto.php @@ -0,0 +1,12 @@ +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 +73,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 +116,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 +130,8 @@ public function deleteKey(string $email): void $this->manager->remove($openPgpKey); $this->manager->flush(); + + return true; } /** @@ -138,4 +146,45 @@ public function getDomainWkdPath(string $domain): string { return $this->getWkdPath($domain); } + + /** + * Helper function to get non-random mail handles owned by a user. + * @return string[] + */ + public function getAllowedUserIdByUser(User $user): array + { + 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()]); + } + + public function entitledToUserId(string $uid): ?string + { + $constraint = new WkdQuery(); + $violations = $this->validator->validate($uid, $constraint); + $message = ""; + if (count($violations) > 0) { + foreach ($violations as $violation) { + $message .= $violation->getMessage() . '\n'; + } + return $message; + } + } + + /** + * 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)->findOneBy($uid, false)) { + return true; + } + return false; + } } diff --git a/src/Repository/AliasRepository.php b/src/Repository/AliasRepository.php index a176010b..d94ca6e8 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 @@ -22,6 +34,7 @@ public function findOneBySource(string $email, ?bool $includeDeleted = false): ? return $this->findOneBy(['source' => $email, 'deleted' => false]); } + /** * @param User $user * @param bool|null $random 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/TwoFactorAuthenticationSuccessHandler.php b/src/Security/TwoFactorAuthenticationSuccessHandler.php index fb892867..c0f8acfc 100644 --- a/src/Security/TwoFactorAuthenticationSuccessHandler.php +++ b/src/Security/TwoFactorAuthenticationSuccessHandler.php @@ -5,12 +5,12 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; -use Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationSuccessHandler; +use Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationSuccessHandler; class TwoFactorAuthenticationSuccessHandler extends AuthenticationSuccessHandler { public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response { - return $this->handleAuthenticationSuccess($token->getUser()); + return $this->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/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/WkdQuery.php b/src/Validator/Constraints/WkdQuery.php new file mode 100644 index 00000000..5030b358 --- /dev/null +++ b/src/Validator/Constraints/WkdQuery.php @@ -0,0 +1,11 @@ +security = $security; + $this->aliasRepository = $manager->getRepository(Alias::class); + } + + /** + * Checks 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 + $this->context->buildViolation($constraint->message)->addViolation(); + } +}